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
npx create-expo-app my-jazz-app --template blank-typescript
cd my-jazz-app
Terminal
mkdir my-jazz-app && cd my-jazz-app
pnpm init
pnpm add vite typescript

Install

Terminal
pnpm add jazz-tools@alpha
Terminal
pnpm add jazz-tools@alpha
Terminal
pnpm add jazz-tools@alpha
Terminal
pnpm add jazz-tools@alpha jazz-rn expo
pnpm add -D @babel/plugin-transform-flow-strip-types

Terminal
pnpm add jazz-tools@alpha

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

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

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

Example apps

On this page