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.
pnpm create vite my-jazz-app --template solid-ts
cd my-jazz-app
pnpm installmkdir my-jazz-app && cd my-jazz-app
pnpm init
pnpm add vite typescriptInstall
bash title="Terminal" pnpm add jazz-tools@alpha bash title="Terminal" pnpm add jazz-tools@alpha bash title="Terminal" pnpm add jazz-tools@alpha bash title="Terminal" pnpm add jazz-tools@alpha bash title="Terminal" pnpm add jazz-tools@alpha React Native and Expo are supported as an alpha through jazz-tools/react-native and the direct
jazz-rn native dependency. Use a matching development or release build: Expo Go is unsupported.
The canonical Expo
scaffold
prepares an AccountHandle through jazz-tools/expo, opens an effect-owned createJazzClient,
and supplies it to JazzClientProvider. See the React Native install
guide for the required New
Architecture, Expo plugin, and bare-host setup.
Get 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 your schema and permissions later. Generated apps are unclaimed until you claim them in the dashboard, and unclaimed apps are automatically deleted after 14 days.
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.reverse("todos", "project") },
),
todos: s.table(
{
title: s.string(),
done: s.boolean(),
description: s.string().optional(),
parentId: s.uuid().optional(),
projectId: s.uuid().optional(),
},
{
parent: s.rel("todos", "parentId"),
children: s.reverse("todos", "parent"),
project: s.rel("projects", "projectId"),
},
),
};
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 = ReturnType<typeof app.todos.limit>;The most common DSL column builders are s.string(), s.boolean(), s.int(), s.float(), s.timestamp(), s.bytes(), s.uuid(), 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 type { AccountHandle } from "jazz-tools";
import { JazzProvider } from "jazz-tools/react";
import { TodoList } from "./TodoList.js";
// Prepare the account outside the context with createAccountManager.
export default function App({ account }: { account: AccountHandle }) {
return (
<JazzProvider
config={{
appId: "<your-app-id>",
account,
}}
>
<h1>Todos</h1>
<TodoList />
</JazzProvider>
);
}<script setup lang="ts">
import type { DbConfig } from "jazz-tools";
import { JazzProvider } from "jazz-tools/vue";
import TodoList from "./TodoList.vue";
defineProps<{ config: DbConfig }>();
</script>
<template>
<JazzProvider :config="config">
<h1>Todos</h1>
<TodoList />
<template #fallback>
<p>Loading...</p>
</template>
</JazzProvider>
</template><script lang="ts">
import type { DbConfig } from "jazz-tools";
import { JazzSvelteProvider } from 'jazz-tools/svelte';
import TodoList from './TodoList.svelte';
let { config }: { config: DbConfig } = $props();
</script>
<JazzSvelteProvider {config}>
{#snippet children()}
<h1>Todos</h1>
<TodoList />
{/snippet}
{#snippet fallback()}
<p>Loading...</p>
{/snippet}
</JazzSvelteProvider>import { JazzProvider, type JazzAppConfig } from "jazz-tools/solid";
import { TodoList } from "./TodoList.js";
export function App(props: { config: JazzAppConfig }) {
return (
<JazzProvider {...props.config}>
<h1>Todos</h1>
<TodoList />
</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 { createAccountManager, createDb } from "jazz-tools";
import { app } from "../schema.js";
import { renderTodoItem } from "./TodoItem.js";
const appId = "<your-app-id>";
const config = { appId, serverUrl: "https://core.example" };
const accounts = await createAccountManager(config);
const account = accounts.getLoggedIn() ?? accounts.createLocalFirst();
const db = await createDb({ ...config, account });
// 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 { createSignal } from "solid-js";
import { useDb } from "jazz-tools/solid";
import { app } from "../schema.js";
export function QuickstartAdd() {
const db = useDb();
const [title, setTitle] = createSignal("");
function addTodo() {
db().insert(app.todos, { title: title(), done: false });
setTitle("");
}
return (
<form
onSubmit={(e) => {
e.preventDefault();
addTodo();
}}
>
<input
value={title()}
onInput={(e) => setTitle(e.currentTarget.value)}
type="text"
placeholder="What needs to be done?"
/>
<button type="submit">Add</button>
</form>
);
}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 { data: todos = [] } = useAll(app.todos.where({ id }).limit(1));
const [todo] = todos;
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 { data: 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 { Show } from "solid-js";
import { useAll, useDb } from "jazz-tools/solid";
import { app } from "../schema.js";
export function QuickstartItem(props: { id: string }) {
const db = useDb();
const todos = useAll(() => ({ query: app.todos.where({ id: props.id }).limit(1) }));
const todo = () => todos.data?.[0];
return (
<Show when={todo()}>
{(item) => (
<li classList={{ done: item().done }}>
<input
type="checkbox"
checked={item().done}
onChange={() => db().update(app.todos, props.id, { done: !item().done })}
/>
<span>{item().title}</span>
<button onClick={() => db().delete(app.todos, props.id)}>×</button>
</li>
)}
</Show>
);
}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 { data: 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 { data: 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 { For } from "solid-js";
import { useAll } from "jazz-tools/solid";
import { app } from "../schema.js";
import { QuickstartItem } from "./quickstart-item.js";
import { QuickstartAdd } from "./quickstart-add.js";
export function QuickstartList() {
const todos = useAll(() => ({ query: app.todos }));
return (
<>
<ul>
<For each={todos.data ?? []}>{(todo) => <QuickstartItem id={todo.id} />}</For>
</ul>
<QuickstartAdd />
</>
);
}const list = document.getElementById("todos")!;
db.subscribe(app.todos, (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, deploy your schema and permissions to the server.
Add permissions
Without permissions, permission-scoped server reads return no rows and writes are rejected. For this quickstart, allow everything:
import { schema as s } from "jazz-tools";
import { app } from "./schema.js";
export default s.definePermissions(app, ({ policy }) => {
policy.projects.allowRead.always();
policy.projects.allowInsert.always();
policy.todos.allowRead.always();
policy.todos.allowInsert.always();
policy.todos.allowUpdate.always();
policy.todos.allowDelete.always();
});Then run deploy from the directory containing both schema.ts and permissions.ts, 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/",}When you update schema.ts, run deploy again. Compatible schema changes can use an inferred migration; changes that transform existing rows need a reviewed migration path before deployment.
Permissions can be updated without a schema migration by re-running pnpm dlx jazz-tools@alpha deploy <appId> with both files present.
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
- Todo app (Solid) — the same app built with
jazz-tools/solid