Client
Build a local-first to-do app with Jazz, step by step.
Create a project
Start with a fresh app. If you already have one, skip to Install.
pnpm create vite my-jazz-app --template react-ts
cd my-jazz-app
pnpm installpnpm create vite my-jazz-app --template vue-ts
cd my-jazz-app
pnpm installpnpm create vite my-jazz-app --template svelte-ts
cd my-jazz-app
pnpm installThis quickstart uses Svelte 5 features ($state, $props, $derived, snippets). Make sure you're on Svelte 5 or later.
npx create-expo-app my-jazz-app --template blank-typescript
cd my-jazz-appmkdir my-jazz-app && cd my-jazz-app
pnpm init
pnpm add vite typescriptInstall
pnpm add jazz-tools@alphapnpm add jazz-tools@alphapnpm add jazz-tools@alphapnpm add jazz-tools@alpha jazz-rn expo
pnpm add -D @babel/plugin-transform-flow-strip-typespnpm add jazz-tools@alphaGet an app ID
Your app ID namespaces your data for storage and sync. Click below to generate one on Jazz Cloud (sync URL: https://v2.sync.jazz.tools/) — you'll also get the secrets you need to deploy permissions later.
Or from the command line (AI agents: use this to provision your own app):
curl -X POST https://v2.dashboard.jazz.tools/api/apps/generateJazz Cloud sync URL: https://v2.sync.jazz.tools/
Define your schema
Create schema.ts at the root of your project (or src/lib/schema.ts for SvelteKit). This is the source of truth for your data model.
import { schema as s } from "jazz-tools";
const schema = {
projects: s.table({
name: s.string(),
}),
todos: s.table({
title: s.string(),
done: s.boolean(),
description: s.string().optional(),
parentId: s.ref("todos").optional(),
projectId: s.ref("projects").optional(),
}),
};
type AppSchema = s.Schema<typeof schema>;
export const app: s.App<AppSchema> = s.defineApp(schema);
export type Todo = s.RowOf<typeof app.todos>;
export type TodoQueryBuilder = typeof app.todos;The most common DSL column builders are s.string(), s.boolean(), s.int(), s.float(), s.timestamp(), s.bytes(), s.ref("table"), s.array(s.string()), s.enum("a", "b"), s.json(), and s.json(schema).
For the full TypeScript/SQL mapping table, see Schemas: available column types.
Learn more about schemas, optional validation, and migrations.
Set up your app
Create the basic structure for your app with Jazz and a to-do list.
import { JazzProvider } from "jazz-tools/react";
import { TodoList } from "./TodoList.js";
export default function App() {
return (
<JazzProvider
config={{
appId: "<your-app-id>",
}}
>
<h1>Todos</h1>
<TodoList />
</JazzProvider>
);
}<script setup lang="ts">
import { createJazzClient, JazzProvider } from "jazz-tools/vue";
import TodoList from "./TodoList.vue";
const client = createJazzClient({
appId: "<your-app-id>",
});
</script>
<template>
<JazzProvider :client="client">
<h1>Todos</h1>
<TodoList />
<template #fallback>
<p>Loading...</p>
</template>
</JazzProvider>
</template><script lang="ts">
import { createJazzClient, JazzSvelteProvider } from 'jazz-tools/svelte';
import TodoList from './TodoList.svelte';
const client = createJazzClient({
appId: '<your-app-id>',
});
</script>
<JazzSvelteProvider {client}>
{#snippet children()}
<h1>Todos</h1>
<TodoList />
{/snippet}
{#snippet fallback()}
<p>Loading...</p>
{/snippet}
</JazzSvelteProvider>import { JazzProvider } from "jazz-tools/react-native";
import { SafeAreaView, Text, View } from "react-native";
import { TodoList } from "./TodoList";
export function App() {
return (
<JazzProvider
config={{
appId: "<your-app-id>",
}}
>
<SafeAreaView style={{ flex: 1 }}>
<View style={{ flex: 1, padding: 16, gap: 16 }}>
<Text style={{ fontSize: 28, fontWeight: "700" }}>Todos</Text>
<TodoList />
</View>
</SafeAreaView>
</JazzProvider>
);
}<!doctype html>
<html>
<body>
<ul id="todos"></ul>
<script type="module" src="./src/main.ts"></script>
</body>
</html>Create src/main.ts and initialise the database:
import { createDb } from "jazz-tools";
import { app } from "../schema.js";
import { renderTodoItem } from "./TodoItem.js";
const db = await createDb({
appId: "<your-app-id>",
});
// use db.shutdown() to clean up when finishedAdd a to-do
Use db.insert to create a new row.
import { useState } from "react";
import { useDb } from "jazz-tools/react";
import { app } from "../schema.js";
export function AddTodo() {
const db = useDb();
const [title, setTitle] = useState("");
return (
<form
onSubmit={(e) => {
e.preventDefault();
db.insert(app.todos, { title, done: false });
setTitle("");
}}
>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="What needs to be done?"
/>
<button type="submit">Add</button>
</form>
);
}<script setup lang="ts">
import { ref } from "vue";
import { useDb } from "jazz-tools/vue";
import { app } from "../schema.js";
const db = useDb();
const title = ref("");
function addTodo() {
db.insert(app.todos, { title: title.value, done: false });
title.value = "";
}
</script>
<template>
<form @submit.prevent="addTodo">
<input v-model="title" type="text" placeholder="What needs to be done?" />
<button type="submit">Add</button>
</form>
</template><script lang="ts">
import { getDb } from 'jazz-tools/svelte';
import { app } from '../schema.js';
const db = getDb();
let title = $state('');
</script>
<form onsubmit={(e) => {
e.preventDefault();
db.insert(app.todos, { title, done: false });
title = '';
}}>
<input type="text" bind:value={title} placeholder="What needs to be done?" />
<button type="submit">Add</button>
</form>import { useState } from "react";
import { Pressable, Text, TextInput, View } from "react-native";
import { useDb, useSession } from "jazz-tools/react-native";
import { app } from "../schema";
export function AddTodo() {
const db = useDb();
const session = useSession();
const [title, setTitle] = useState("");
const addTodo = () => {
if (!title.trim() || !session) return;
db.insert(app.todos, { title, done: false, owner_id: session.user_id });
setTitle("");
};
return (
<View>
<TextInput
value={title}
onChangeText={setTitle}
placeholder="What needs to be done?"
onSubmitEditing={addTodo}
/>
<Pressable onPress={addTodo}>
<Text>Add</Text>
</Pressable>
</View>
);
}const form = document.createElement("form");
const input = Object.assign(document.createElement("input"), {
placeholder: "What needs to be done?",
});
form.append(input, Object.assign(document.createElement("button"), { textContent: "Add" }));
form.onsubmit = (e) => {
e.preventDefault();
db.insert(app.todos, { title: input.value, done: false });
input.value = "";
};
document.body.append(form);Display and edit a to-do
Display a to-do with toggle and delete controls.
import { useDb, useAll } from "jazz-tools/react";
import { app } from "../schema.js";
export function TodoItem({ id }: { id: string }) {
const db = useDb();
const [todo] = useAll(app.todos.where({ id }).limit(1)) ?? [];
if (!todo) return null;
return (
<li className={todo.done ? "done" : ""}>
<input
type="checkbox"
checked={todo.done}
onChange={() => db.update(app.todos, id, { done: !todo.done })}
/>
<span>{todo.title}</span>
<button onClick={() => db.delete(app.todos, id)}>×</button>
</li>
);
}<script setup lang="ts">
import { computed } from "vue";
import { useDb, useAll } from "jazz-tools/vue";
import { app } from "../schema.js";
const props = defineProps<{ id: string }>();
const db = useDb();
const todos = useAll(() => app.todos.where({ id: props.id }).limit(1));
const todo = computed(() => todos.value?.[0]);
</script>
<template>
<li v-if="todo" :class="{ done: todo.done }">
<input
type="checkbox"
:checked="todo.done"
@change="db.update(app.todos, props.id, { done: !todo.done })"
/>
<span>{{ todo.title }}</span>
<button @click="db.delete(app.todos, props.id)">×</button>
</li>
</template><script lang="ts">
import { getDb, QuerySubscription } from 'jazz-tools/svelte';
import { app } from '../schema.js';
const { id }: { id: string } = $props();
const db = getDb();
const todos = new QuerySubscription(app.todos.where({ id }).limit(1));
const todo = $derived(todos.current?.[0]);
</script>
{#if todo}
<li class={todo.done ? 'done' : ''}>
<input
type="checkbox"
checked={todo.done}
onchange={() => db.update(app.todos, id, { done: !todo.done })}
/>
<span>{todo.title}</span>
<button onclick={() => db.delete(app.todos, id)}>×</button>
</li>
{/if}import { Pressable, Switch, Text, View } from "react-native";
import { useDb, useAll } from "jazz-tools/react-native";
import { app } from "../schema";
export function TodoItem({ id }: { id: string }) {
const db = useDb();
const [todo] = useAll(app.todos.where({ id }).limit(1)) ?? [];
if (!todo) return null;
return (
<View>
<Switch
value={todo.done}
onValueChange={() => {
db.update(app.todos, id, { done: !todo.done });
}}
/>
<Text>{todo.title}</Text>
<Pressable onPress={() => db.delete(app.todos, id)}>
<Text>Delete</Text>
</Pressable>
</View>
);
}import type { Db } from "jazz-tools";
import { app as schemaApp, type Todo } from "../schema.js";
export function renderTodoItem(todo: Todo, db: Db, app: typeof schemaApp) {
const li = Object.assign(document.createElement("li"), {
textContent: todo.title,
});
const toggle = Object.assign(document.createElement("input"), {
type: "checkbox",
checked: todo.done,
onchange: () => db.update(app.todos, todo.id, { done: !todo.done }),
});
const remove = Object.assign(document.createElement("button"), {
textContent: "\u00d7",
onclick: () => db.delete(app.todos, todo.id),
});
li.prepend(toggle);
li.append(remove);
return li;
}Full mutation API: Writing Data.
List to-dos
Subscribe to a query to get real-time updates as data changes, regardless of where the change originates.
import { useAll } from "jazz-tools/react";
import { app } from "../schema.js";
import { TodoItem } from "./TodoItem.js";
import { AddTodo } from "./AddTodo.js";
export function TodoList() {
const todos = useAll(app.todos) ?? [];
return (
<>
<ul>
{todos.map((todo) => (
<TodoItem key={todo.id} id={todo.id} />
))}
</ul>
<AddTodo />
</>
);
}<script setup lang="ts">
import { useAll } from "jazz-tools/vue";
import { app } from "../schema.js";
import TodoItem from "./TodoItem.vue";
import AddTodo from "./AddTodo.vue";
const todos = useAll(app.todos);
</script>
<template>
<ul>
<TodoItem v-for="todo in todos ?? []" :key="todo.id" :id="todo.id" />
</ul>
<AddTodo />
</template><script lang="ts">
import { QuerySubscription } from 'jazz-tools/svelte';
import { app } from '../schema.js';
import TodoItem from './TodoItem.svelte';
import AddTodo from './AddTodo.svelte';
const todos = new QuerySubscription(app.todos);
</script>
<ul>
{#each todos.current ?? [] as todo (todo.id)}
<TodoItem id={todo.id} />
{/each}
</ul>
<AddTodo />
import { FlatList, View } from "react-native";
import { useAll } from "jazz-tools/react-native";
import { app } from "../schema";
import { TodoItem } from "./TodoItem";
import { AddTodo } from "./AddTodo";
export function TodoList() {
const todos = useAll(app.todos) ?? [];
return (
<View style={{ flex: 1, gap: 12 }}>
<FlatList
data={todos}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <TodoItem id={item.id} />}
/>
<AddTodo />
</View>
);
}const list = document.getElementById("todos")!;
db.subscribeAll(app.todos, ({ all: todos }) => {
list.replaceChildren(...todos.map((todo) => renderTodoItem(todo, db, app)));
});The callback receives a { all, delta } object — all is the current full result set, and delta is an array of row-level changes (each with a kind: added, removed, or updated).
Framework hooks return undefined while loading, then an array of matching rows. For filtering, sorting, and pagination, see Queries.
Enable sync
So far, your to-do list only works locally. To sync across devices, you need permissions published to the server.
Add permissions
The server rejects all reads and writes unless you define permissions. For this quickstart, allow everything:
import { schema as s } from "jazz-tools";
import { app } from "./schema.js";
export default s.definePermissions(app, ({ policy }) => {
policy.todos.allowRead.always();
policy.todos.allowInsert.always();
policy.todos.allowUpdate.always();
policy.todos.allowDelete.always();
});Then publish the permissions bundle using the app ID and admin secret from above:
pnpm dlx jazz-tools@alpha deploy \ <your-app-id> \ --server-url https://v2.sync.jazz.tools/ \ --admin-secret <your-admin-secret>Connect the client
Update your client config to add serverUrl — Jazz Cloud is at https://v2.sync.jazz.tools/. If you generated an app ID above, these values are already filled in:
{ appId: "<your-app-id>", serverUrl: "https://v2.sync.jazz.tools/",}Clients pick up the schema from the server when they connect. If you update schema.ts you need to create and push a migration to the new schema so that clients can understand existing data with the new schema.
Permissions can be updated without a schema migration by re-running pnpm dlx jazz-tools@alpha deploy.
For self-hosted deployments, see Server Setup.
Next steps
- Authentication — local-first and external JWT auth
- Permissions — row-level access policies
- Queries — filtering, sorting, pagination, and relations
- Durability tiers — control when writes are confirmed
Example apps
- Todo app (React) — the app you just built, as a complete project
- File upload (React) — image upload and rendering with Jazz
- Wequencer (Svelte) — collaborative real-time music sequencer