Install

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.

Terminal
pnpm create vite my-jazz-app --template react-ts
cd my-jazz-app
pnpm install
Terminal
pnpm create vite my-jazz-app --template vue-ts
cd my-jazz-app
pnpm install
Terminal
pnpm create vite my-jazz-app --template svelte-ts
cd my-jazz-app
pnpm install

This quickstart uses Svelte 5 features ($state, $props, $derived, snippets). Make sure you're on Svelte 5 or later.

Terminal
pnpm create vite my-jazz-app --template solid-ts
cd my-jazz-app
pnpm install
Terminal
mkdir my-jazz-app && cd my-jazz-app
pnpm init
pnpm add vite typescript

Install

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.

Apps generated here are unclaimed. Claim the app in the dashboard within 14 days. 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/generate

Jazz 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.

schema.ts
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.

src/App.tsx
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>
  );
}
src/App.vue
<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>
src/App.svelte
<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>
src/App.tsx
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>
  );
}
index.html
<!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:

src/main.ts
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 finished

Add a to-do

Use db.insert to create a new row.

src/AddTodo.tsx
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>
  );
}
src/AddTodo.vue
<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>
src/AddTodo.svelte
<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>
src/AddTodo.tsx
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>
  );
}
src/main.ts
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.

src/TodoItem.tsx
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)}>&times;</button>
    </li>
  );
}
src/TodoItem.vue
<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)">&times;</button>
  </li>
</template>
src/TodoItem.svelte
<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)}>&times;</button>
  </li>
{/if}
src/TodoItem.tsx
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)}>&times;</button>
        </li>
      )}
    </Show>
  );
}
src/TodoItem.ts
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.

src/TodoList.tsx
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 />
    </>
  );
}
src/TodoList.vue
<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>
src/TodoList.svelte
<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 />
src/TodoList.tsx
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 />
    </>
  );
}
src/main.ts
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:

permissions.ts
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

Example apps

On this page