# FAQ



import { Accordion, Accordions } from "fumadocs-ui/components/accordion";

<Accordions type="multiple">
  <Accordion id="reset-browser-storage" title="How do I reset browser storage?">
    In browser apps using the React, Vue, or Svelte Jazz clients, open the devtools console and run:

    ```ts
    await window.__jazz.clearStorage();
    ```

    If the page has more than one live Jazz storage context, inspect the available namespaces and then choose one explicitly:

    ```ts
    window.__jazz.listLiveStorageNamespaces();
    await window.__jazz.clearStorage("my-app::alice");
    ```

    If you're working directly with a `Db` handle instead of the window helper, `await db.deleteClientStorage()` is the underlying API. See [Auth Lifecycle](/docs/auth/lifecycle#storage-reset) for how this differs from logout and local-first identity storage.

    * Browser persistent storage only.
    * If exactly one live namespace exists, `clearStorage()` uses it automatically.
    * If multiple live namespaces exist, Jazz throws and lists the available namespaces until you choose one.
    * Can be initiated from either leader or follower tabs; Jazz coordinates the reset across tabs for that namespace.
    * Deletes OPFS storage only. It does not clear `localStorage` local-first auth data.
    * Reopens a clean worker/runtime so the same live client remains usable after the wipe.

    This is useful when iterating on your schema during development.
  </Accordion>

  <Accordion id="undefined-vs-empty-array" title="Why does useAll return undefined?">
    `useAll` and `QuerySubscription` return `undefined` until the first response arrives from the requested tier. After that, the value is an array — empty (`[]`) if no rows match, or populated. See [The undefined loading state](/docs/reading/queries#the-undefined-loading-state) for details.
  </Accordion>

  <Accordion id="offline-only" title="Can I use Jazz without a server?">
    Yes. Omit `serverUrl` from your config — data will be persisted locally only.
  </Accordion>

  <Accordion id="local-first-to-external" title="Can I upgrade from local-first auth to external auth without losing data?">
    Yes. When a user signs up with an external provider, their identity carries over. See [Signing up with BetterAuth](/docs/auth/local-first-auth#signing-up-with-betterauth).
  </Accordion>

  <Accordion id="rust-migrations" title="Do Rust apps need separate migration files?">
    No. Rust services use the same TypeScript migration files. The Rust runtime consumes the compiled schema representation through the CLI. See [Migrations](/docs/schemas/migrations) for details.
  </Accordion>

  <Accordion id="common-errors" title="What do the sync error messages mean?">
    Jazz may return the following errors when a client's request is rejected by the server:

    * **`PermissionDenied`** — a write was rejected because it failed the table's
      [permission policy](/docs/auth/permissions). Check that the session has the right to perform
      that operation on the row.
    * **`SessionRequired`** — a write was attempted without an authenticated session.
      Make sure the client is using a valid auth mode (local-first or external JWT).
    * **`CatalogueWriteDenied`** — a client attempted to write a schema or lens
      catalogue object without the required admin secret. In production, catalogue writes require
      `--admin-secret`.
    * **`QuerySubscriptionRejected`** — a query subscription was rejected by the
      server, typically because the query references a table or schema the server doesn't know about.
      This can happen if the schema hasn't synced yet or if there's a version mismatch.
  </Accordion>
</Accordions>


# Overview



Jazz is a local-first relational database with row-level permissions, real-time sync, and offline support — no separate API layer needed. Your app reads from and writes to a local replica, and Jazz syncs it with a server in the background.

```ts
import { schema as s } from "jazz-tools";

// Define your schema
const schema = {
  todos: s.table({
    title: s.string(),
    done: s.boolean(),
  }),
};
const app = s.defineApp(schema);

// Write — instant, works offline
db.insert(app.todos, { title: "Ship it", done: false });

// Read — reactive, stays up to date across devices
db.subscribeAll(app.todos.where({ done: false }), ({ all }) => {
  console.log(all);
});
```

Jazz works with React, Vue, Expo/React Native, Svelte, plain TypeScript, and Rust.

(Looking for [classic Jazz docs?](https://classic.jazz.tools/docs))

Quickstart [#quickstart]

<Cards>
  <Card title="Quickstart" href="/docs/quickstart" />
</Cards>

Install [#install]

<Cards>
  <Card title="Client" href="/docs/install/client" />

  <Card title="TypeScript Server" href="/docs/install/typescript-server" />
</Cards>

Setup [#setup]

<Cards>
  <Card title="Client Setup" href="/docs/getting-started/client-setup" />

  <Card title="Server Setup" href="/docs/getting-started/server-setup" />
</Cards>

Reference [#reference]

<Cards>
  <Card title="Authentication" href="/docs/auth/authentication" />

  <Card title="Schemas" href="/docs/schemas/defining-tables" />

  <Card title="Reading Data" href="/docs/reading/queries" />

  <Card title="Writing Data" href="/docs/writing/writing-data" />

  <Card title="Permissions" href="/docs/auth/permissions" />

  <Card title="Migrations" href="/docs/schemas/migrations" />
</Cards>


# Quickstart



Scaffold a new Jazz app [#scaffold-a-new-jazz-app]

```bash title="Terminal"
pnpm create jazz
```

Pick a framework, hosting mode, and auth style when prompted. The scaffolder installs dependencies and — if you chose hosted — provisions an app on Jazz Cloud with env vars filled in for you.

What you get [#what-you-get]

* `schema.ts` — the source of truth for your data. Tables, types, and queries all flow from here. See [Schemas](/docs/schemas/defining-tables).
* `permissions.ts` — row-level access, pre-wired. See [Permissions](/docs/auth/permissions).
* A Jazz provider and a todo UI you can reshape into your own app.

Next steps [#next-steps]

* **Model your own data** — edit `schema.ts`, then use `db.insert` and `db.subscribeAll` from your components. See [Writing Data](/docs/writing/writing-data) and [Queries](/docs/reading/queries).
* **Add auth** — see [Authentication](/docs/auth/authentication) to go beyond an anonymous identity.
* **Install into an existing app** — see [Install → Client](/docs/install/client) or [Install → TypeScript Server](/docs/install/typescript-server).


# Authentication



Jazz has three auth modes: `anonymous`, `local-first`, and `external`. You pick the mode implicitly by what you pass in `DbConfig`: nothing for anonymous, `secret` for local-first, or `jwtToken` for external.

| Mode          | What it does                                                                | When to use it                                   |
| ------------- | --------------------------------------------------------------------------- | ------------------------------------------------ |
| `anonymous`   | Ephemeral read-only identity, no secret, no server                          | Public/marketing surfaces, try-before-anything   |
| `local-first` | Stable identity from a device secret, no server needed                      | Production offline-first apps, try-before-signup |
| `external`    | Validates a JWT from your auth provider via JWKS or a configured static key | Production apps with real user accounts          |

Anonymous sessions can subscribe to queries but are **structurally denied writes** — every insert, update, and delete path checks the session's auth mode before any policy evaluation runs, and surfaces an `AnonymousWriteDeniedError` to the client. Gate reads the same way you gate any other access, via the permissions DSL (`session.where({ authMode: "anonymous" })`).

Local-first auth [#local-first-auth]

Local-first auth lets users start using your app without signing up. They can later upgrade to an external provider while keeping their identity. See [Local-first auth](/docs/auth/local-first-auth) for the full guide.

External auth for production [#external-auth-for-production]

Pass a JWT from your auth provider using the `jwtToken` option. Jazz validates it against your server's configured JWKS endpoint or static key.

<Callout type="info" title="JWT? JWKS?">
  JWTs, or JSON Web Tokens, are small JSON payloads signed by an authority — normally this is your authentication provider. Often this JSON payload includes details about your identity, but it can also include various other pieces of information known as 'claims'. Jazz can read the signed payload and use it in your permissions policies. To validate the signature and protect against spoofing, Jazz either retrieves the signing key from the JWKS endpoint you specify or uses a single configured JWK / public key directly.

  For more details, the [Auth0 docs](https://auth0.com/docs/secure/tokens/json-web-tokens) are a great starting point.
</Callout>

<Tabs groupId="jazz-framework" persist updateAnchor items={["React", "Vue", "Svelte", "TypeScript"]}>
  <Tab value="React">
    ```tsx title="App.tsx"
    export function JwtAuthApp() {
      return (
        <JazzProvider
          config={{
            appId: "my-app",
            serverUrl: "http://127.0.0.1:4200",
            jwtToken: "<provider-jwt>",
          }}
        >
          <TodoApp />
        </JazzProvider>
      );
    }
    ```
  </Tab>

  <Tab value="Vue">
    ```vue title="App.vue"
    <script setup lang="ts">
    import { createJazzClient, JazzProvider } from "jazz-tools/vue";

    const client = createJazzClient({
      appId: "my-app",
      serverUrl: "http://127.0.0.1:4200",
      jwtToken: "<provider-jwt>",
    });
    </script>

    <template>
      <JazzProvider :client="client">
        <slot />
      </JazzProvider>
    </template>

    ```
  </Tab>

  <Tab value="Svelte">
    ```svelte title="App.svelte"
    <script lang="ts">
      import { createJazzClient, JazzSvelteProvider } from 'jazz-tools/svelte';

      const client = createJazzClient({
        appId: 'my-app',
        serverUrl: 'http://127.0.0.1:4200',
        jwtToken: '<provider-jwt>',
      });
    </script>

    <JazzSvelteProvider {client}>
      {#snippet children({ db })}
        <slot />
      {/snippet}
    </JazzSvelteProvider>
    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts title="app.ts"
    export async function createJwtDb() {
      return createDb({
        appId: "my-app",
        serverUrl: "http://127.0.0.1:4200",
        jwtToken: "<provider-jwt>",
      });
    }
    ```
  </Tab>
</Tabs>

<Callout type="warn">
  Use `jwtToken` in `JazzProvider` or `createDb(...)` for initial setup and whenever the signed-in
  user changes. Recreate the client for sign-in, sign-out, or principal switches. Reserve
  `db.updateAuthToken(...)` for refreshing a JWT for the same user on an already-authenticated
  client.
</Callout>

Jazz uses the JWT `sub` claim as `session.user_id`. This is used internally by Jazz to record the
identity for every write, permission check, and `$createdBy` reference. Use a stable value.

* In most cases, use your provider's internal user ID, as this is generally fixed for the lifetime
  of the user account
* Avoid email addresses, as these can and do change.
* Do not use any ephemeral identifier (e.g. a session ID).

If your provider doesn't provide a stable ID in `sub` by default, either configure it
or remint the token through your own JWT layer that rewrites `sub`.

<Callout type="info">
  Policies are enforced based on the `sub` claim. If you ever change what you use for `sub` in your
  application, Jazz will consider this to be a different user, which may lead to unintended
  consequences when enforcing policies.
</Callout>

Managing JWT changes on a live client [#managing-jwt-changes-on-a-live-client]

For sign-in and sign-out, recreate `JazzProvider` or `Db` with a new auth config:

```tsx
const [jwtToken, setJwtToken] = useState<string | null>(readStoredJwt());
const { secret, isLoading } = useLocalFirstAuth();

if (!jwtToken && (isLoading || !secret)) return <p>Loading…</p>;

const config = jwtToken
  ? {
      appId: "my-app",
      serverUrl: "https://sync.example.com",
      jwtToken,
    }
  : {
      appId: "my-app",
      serverUrl: "https://sync.example.com",
      secret: secret!,
    };

<JazzProvider key={jwtToken ? "external" : "local"} config={config} />;
```

On a live client, `db.updateAuthToken(jwt)` is only for replacing the bearer token with a new JWT for
the same principal:

* `db.updateAuthToken(jwt)` refreshes bearer auth for the same principal on the live client.
* Backend-scoped wrappers from `createJazzContext().asBackend()`, `.forRequest()`, and `.forSession()` do not own shared bearer auth and ignore `updateAuthToken(...)`.
* Do not use `db.updateAuthToken(null)` for logout or local-first fallback on a live client.
* Switching from one principal to another, like `alice -> bob`, is not supported on a live `Db`.
  Recreate `Db` or `JazzProvider` when the authenticated user changes.

If you change the `config` passed to `JazzProvider`, Jazz recreates the client. That is the
recommended path for login, logout, and any principal change. See
[Lifecycle](/docs/auth/lifecycle) for logout, storage reset, and local-first identity storage.

Cookie-based auth [#cookie-based-auth]

Use `cookieSession` only when your app authenticates sync with an HttpOnly cookie instead of a JS-readable bearer token. In that setup, the cookie is the real transport credential, while `cookieSession` mirrors the current Jazz session into the client so local permission checks, `useSession()`, and `db.getAuthState()` know which user is active.

```ts
const db = await createDb({
  appId: "my-app",
  serverUrl: "https://sync.example.com",
  cookieSession: {
    user_id: "user_123",
    claims: { role: "member" },
    authMode: "external",
  },
});
```

You must choose between `cookieSession` and `secret`/`jwtToken`. If the same user is signed in, but their session changes (e.g. new claims, refreshed cookie), call `db.updateCookieSession(nextSession)`. To sign in, sign out, or authenticate as a different user, recreate `Db` or `JazzProvider` with a new config.

Reacting to expiry and unauthenticated responses [#reacting-to-expiry-and-unauthenticated-responses]

Auth state is flat:

```ts
interface AuthState {
  authMode: "anonymous" | "local-first" | "external";
  session: Session | null;
  error?: "expired" | "missing" | "invalid" | "disabled";
}
```

Presence of `error` indicates trouble; `authMode` and the last-known `session` are always available.

React: onJWTExpired prop [#react-onjwtexpired-prop]

For JWT refresh, pass `onJWTExpired` to `JazzProvider`. Jazz calls it when the sync server rejects
the current token as expired, serialized so concurrent failed writes trigger a single refresh:

```tsx
<JazzProvider
  config={{ appId: "my-app", jwtToken }}
  onJWTExpired={async () => await fetchFreshJwt()}
>
  <App />
</JazzProvider>
```

Read auth state from any component with `useAuthState()`:

```ts
const { authMode, userId, claims, error } = useAuthState();
```

TypeScript: db.onAuthChanged [#typescript-dbonauthchanged]

Outside React, subscribe to auth-state changes on `Db`:

```ts
const stop = db.onAuthChanged(async (state) => {
  if (state.error !== "expired") return;

  const freshJwt = await getFreshJwtForCurrentUser().catch(() => null);
  if (freshJwt) {
    db.updateAuthToken(freshJwt);
    return;
  }

  // Sign-out or principal change: recreate Jazz without bearer auth.
  setJwtToken(null);
});
```

Read the current snapshot synchronously with `db.getAuthState()`.

When Jazz receives a structured unauthenticated response from the sync server, it:

* sets `error` on the auth state
* preserves the last known `session`
* pauses authenticated sync and reconnects until the app either refreshes the same user's JWT with
  `db.updateAuthToken(...)` or recreates the client with a new auth config

<Callout type="info">
  `useSession()` and `db.getAuthState().session` can still return the last known session while
  `error` is set. That's intentional: it keeps user-scoped UI and local authorship stable while your
  app refreshes or replaces the JWT. For signed-in vs signed-out UI, check whether `error` is set
  (or the `authMode` you expect) instead of only whether a session exists.
</Callout>

Server configuration [#server-configuration]

See [Server Setup](/docs/getting-started/server-setup) for the full list of server flags and storage options.

```bash title="Terminal"
NODE_ENV=production \
jazz-tools server "$JAZZ_APP_ID" \
  --port 1625 \
  --data-dir ./data \
  --allow-local-first-auth \
  --jwks-url https://auth.example.com/.well-known/jwks.json \
  --backend-secret "$JAZZ_BACKEND_SECRET" \
  --admin-secret "$JAZZ_ADMIN_SECRET"
```

Session resolution [#session-resolution]

When the server receives a request, it tries each auth method in priority order and uses the first match:

1. **Backend impersonation** — the request includes `X-Jazz-Backend-Secret` and `X-Jazz-Session` headers. A trusted backend service (e.g. a cron job or webhook handler) can act as any user by providing the shared secret and the session as a base64-encoded JSON string in `X-Jazz-Session`.
2. **External JWT** — the request includes an `Authorization: Bearer <jwt>` header. The token is validated against the configured `--jwks-url` or `--jwt-public-key`.
3. **Local-first auth** — the request includes an `Authorization: Bearer <jwt>` header with a self-signed local-first token.
4. **No session** — none of the above matched.

Backend impersonation always takes precedence, so a backend service can reliably impersonate users even if the request also carries a JWT.

On TypeScript backends, `await createJazzContext(...).forRequest(req)` follows the same rule set. Configure `jwksUrl` or `jwtPublicKey` on the backend context to verify external JWTs there too, but not both. Cookie-based app auth is resolved by your app server, not by the Jazz CLI server; once you have a Jazz session from a cookie, use `context.forSession(session)`.

Upgrading to external auth [#upgrading-to-external-auth]

Users on local-first auth can sign up with an external provider and keep the same identity. See [Signing up with BetterAuth](/docs/auth/local-first-auth#signing-up-with-betterauth) for the full walkthrough.

Next steps [#next-steps]

* [Sessions](/docs/auth/sessions) — read the current user and scope queries to their identity
* [Permissions](/docs/auth/permissions) — define row-level access policies


# Lifecycle



import { Callout } from "fumadocs-ui/components/callout";

Jazz clients have two pieces of local state that are easy to confuse:

* **Identity secret** — the local-first auth secret. In browser apps this is usually stored
  by `BrowserAuthSecretStore` in `localStorage`; in Expo it is stored by `ExpoAuthSecretStore` in
  secure storage.
* **Database storage** — the local relational database. Browser persistent clients store this
  in OPFS; React Native uses native storage; memory drivers keep it only for the life of the process.

Clearing one does not automatically clear the other. That separation is intentional: a development
storage reset should not silently destroy a user's identity, and signing out of an auth provider
should not necessarily delete offline data.

Local-first identity storage [#local-first-identity-storage]

Local-first auth derives the user's Jazz identity from a 32-byte secret. The same secret always
produces the same Jazz user ID, so preserving the secret preserves identity across app restarts.

```ts
const secret = await BrowserAuthSecretStore.getOrCreateSecret();

const db = await createDb({
  appId: "my-app",
  secret,
});
```

`useLocalFirstAuth()` wraps this storage for React and Expo apps. Its `signOut()` method clears the
stored secret, which means the next login without a restored secret creates a different Jazz
identity.

<Callout type="warn">
  The local-first secret is the user's identity. If it is lost, rows owned only by that identity may
  become inaccessible unless the user restores the same secret from a recovery passphrase, passkey
  backup, or linked external account. The secret should be protected carefully: anyone with the
  secret can authenticate as the user.
</Callout>

Use [Local-first auth](/docs/auth/local-first-auth#backing-up-and-restoring-the-secret) to add
recovery before shipping flows that clear or replace a local-first secret.

Switching users [#switching-users]

Recreate the
client with a new auth config whenever the active principal changes:

```tsx
<JazzProvider key={sessionKey} config={config}>
  <App />
</JazzProvider>
```

For the same external user, `db.updateAuthToken(freshJwt)` is fine for refreshing an expiring
bearer token. Do not use `db.updateAuthToken(null)` to sign out or switch users on a live `Db`. For cookie-based auth, `db.updateCookieSession(nextSession)` updates the mirrored
session for the same user while the HttpOnly cookie remains the transport credential.

Logout [#logout]

Use `db.logout()` when you are done with a `Db` instance during logout or switching users. It
shuts down subscriptions, workers, and cached runtime clients. Your auth provider is still
responsible for clearing its own token or cookie, and your app should recreate `Db` or
`JazzProvider` with the next auth config.

```ts
await db.logout();
```

In browser persistent mode, pass `wipeData: true` to also clear the local OPFS database namespace
before shutdown:

```ts
await db.logout({ wipeData: true });
```

`wipeData` clears the local database for this browser storage namespace. It does not clear
local-first auth secrets from `localStorage`, Expo secure storage, or your external provider's
cookies/tokens.

Storage reset [#storage-reset]

For development tools that only need to clear browser database storage without treating it as
logout, call:

```ts
await db.deleteClientStorage();
```

This API is only available for browser worker-backed persistent storage. It clears OPFS database
files and coordinates across tabs, but intentionally leaves local-first auth secrets alone.

Need a console-only reset while debugging? See [How do I reset browser storage?](/docs/faq#reset-browser-storage).

Upgrading to external auth [#upgrading-to-external-auth]

Users can start with local-first auth and later sign up with an external provider while keeping the
same Jazz identity. The upgrade flow is:

1. Start the app with a local-first `secret`.
2. Before sign-up, call `db.getLocalFirstIdentityProof(...)` to prove ownership of that identity.
3. Verify that proof on your server.
4. Create or link the external account so its future JWTs use the same Jazz user ID as `sub`.
5. Recreate `Db` or `JazzProvider` with `jwtToken` or `cookieSession` for the linked external account.

After the external account is linked, keep the local-first secret backed up until you are confident
the external provider can recover the same Jazz user ID. See
[Signing up with BetterAuth](/docs/auth/local-first-auth#signing-up-with-betterauth) for the full
proof-token flow.

Related APIs [#related-apis]

| API                               | Use it for                                                                           |
| --------------------------------- | ------------------------------------------------------------------------------------ |
| `BrowserAuthSecretStore`          | Browser local-first secret storage                                                   |
| `ExpoAuthSecretStore`             | Expo secure local-first secret storage                                               |
| `useLocalFirstAuth()`             | React/Expo hook (and Vue composable) for loading, replacing, and clearing the secret |
| `LocalFirstAuth`                  | Svelte reactive class for loading, replacing, and clearing the secret                |
| `db.updateAuthToken(jwt)`         | Refreshing a bearer JWT for the same principal                                       |
| `db.updateCookieSession(session)` | Updating a mirrored cookie-backed session for the same principal                     |
| `db.logout()`                     | Shutting down a client during logout or principal switch                             |
| `db.logout({ wipeData: true })`   | Logout plus browser OPFS database wipe                                               |
| `db.deleteClientStorage()`        | Development-only browser OPFS storage reset                                          |


# Local-first auth



Local-first auth lets users start using your app immediately without signing up. Jazz generates a secret on the client, derives a stable account ID from it, and uses self-signed tokens to prove the user owns that account. The secret itself effectively *is* the account.

This secret (and therefore the account) lives wherever the user uses the app. To log in from other devices, the user needs to use the same secret.

| Mode          | Identity source        | Server needed? | Best for                                               |
| ------------- | ---------------------- | -------------- | ------------------------------------------------------ |
| `local-first` | Keypair held by client | No             | Production offline-first apps, try-before-signup flows |
| `external`    | JWT from auth provider | Yes            | Production apps with real user accounts                |

When to use local-first auth [#when-to-use-local-first-auth]

If you want users to be able to start immediately, local-first ([client setup](#client-setup)) is a great option, but any user who clears site data (intentionally or otherwise) loses their account.

You can add a [recovery passphrase](#recovery-passphrase) or [passkey backup](#passkey-backup) which allows users to more easily log in on multiple devices or recover their accounts if they delete them. However, these are often less familiar and can add UX friction.

A good middle ground is starting users on local-first so they can play around immediately, but let them upgrade to a managed account when they want to, for example using Better Auth (see [signing up with BetterAuth](#signing-up-with-betterauth)).

If you don't want a try-before-signup flow at all, skip local-first entirely and use [external auth](/docs/auth/authentication) from day one.

You can also combine modes: let local-first users read and experiment, but require an upgrade before any sensitive action — see [Permissions by auth mode](#permissions-by-auth-mode).

Client setup [#client-setup]

Fetch or create a secret and pass it to your Jazz client. Jazz stores the secret in the browser (or equivalent on native) so subsequent loads reuse the same account.

<Tabs groupId="jazz-framework" persist updateAnchor items={["React", "Vue", "Svelte", "TypeScript"]}>
  <Tab value="React">
    ```tsx title="App.tsx"
    export function LocalFirstAuthApp() {
      const { secret, isLoading } = useLocalFirstAuth();

      if (isLoading || !secret) return null;

      return (
        <JazzProvider
          config={{
            appId: "my-app",
            secret,
          }}
        >
          <TodoApp />
        </JazzProvider>
      );
    }
    ```

    `useLocalFirstAuth()` also exposes `login` and `signOut` for switching or clearing accounts.
  </Tab>

  <Tab value="Vue">
    ```vue title="App.vue"
    <script setup lang="ts">
    import { computed } from "vue";
    import { createJazzClient, JazzProvider, useLocalFirstAuth } from "jazz-tools/vue";

    const { secret, isLoading } = useLocalFirstAuth();

    const client = computed(() =>
      !isLoading.value && secret.value
        ? createJazzClient({ appId: "my-app", secret: secret.value })
        : null,
    );
    </script>

    <template>
      <JazzProvider v-if="client" :client="client">
        <slot />
      </JazzProvider>
    </template>

    ```
  </Tab>

  <Tab value="Svelte">
    ```svelte title="App.svelte"
    <script lang="ts">
      import {
        LocalFirstAuth,
        createJazzClient,
        JazzSvelteProvider,
      } from 'jazz-tools/svelte';
      import type { Snippet } from 'svelte';

      let { children }: { children: Snippet } = $props();

      const auth = new LocalFirstAuth();

      let client = $derived(
        !auth.isLoading && auth.secret
          ? createJazzClient({ appId: 'my-app', secret: auth.secret })
          : null,
      );
    </script>

    {#if client}
      <JazzSvelteProvider {client}>
        {@render children()}
      </JazzSvelteProvider>
    {/if}
    ```

    `LocalFirstAuth` from `jazz-tools/svelte` also exposes `login` and `signOut` for switching or
    clearing accounts.
  </Tab>

  <Tab value="TypeScript">
    ```ts title="jazz-client.ts"
    export async function createLocalFirstDb() {
      const secret = await BrowserAuthSecretStore.getOrCreateSecret({ appId: "my-app" });

      return createDb({
        appId: "my-app",
        secret,
      });
    }
    ```
  </Tab>
</Tabs>

<Callout type="warn">
  Lose the secret and you lose the account. If it's cleared (e.g. `localStorage` wiped), the account
  and any data owned by it become inaccessible unless the user restores from a recovery passphrase,
  a passkey backup, or an external provider they linked earlier.
</Callout>

For how the secret relates to local database storage, logout, and user switching, see
[Auth Lifecycle](/docs/auth/lifecycle).

Backing up and restoring the secret [#backing-up-and-restoring-the-secret]

You can keep local-first auth fully serverless and still make it recoverable. Jazz ships two backup
helpers:

| Method                      | Platforms     | Best for                                       | Trade-offs                                                                                      |
| --------------------------- | ------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `jazz-tools/passphrase`     | Browser, Expo | Manual backup users can carry anywhere         | User must securely store a 24-word recovery passphrase, and UI has to convey the weight of this |
| `jazz-tools/passkey-backup` | Browser only  | Fast recovery on devices that support passkeys | Passkey sync is platform-bounded                                                                |

Recovery passphrase [#recovery-passphrase]

`jazz-tools/passphrase` encodes the secret as a 24-word English passphrase, similar to
a crypto-wallet seed phrase. Decoding the passphrase produces the exact same secret, so the user
keeps the same Jazz account.

<Callout type="warn">
  The 24 words are just the secret encoded differently, not a password layered over it. There's no
  hashing, no key-wrapping, no challenge-response: whoever sees the passphrase can sign in as the
  user. This poses a difficult UX challenge: the phrase is too long for most people to remember, and
  the risk of insecure storage is high.
</Callout>

<Tabs groupId="jazz-framework" persist updateAnchor items={["React", "Vue", "Svelte", "TypeScript"]}>
  <Tab value="React">
    ```tsx
    export function useRecoveryPhraseBackup(): {
      isLoading: boolean;
      recoveryPhrase: string | null;
    } {
      const { secret, isLoading } = useLocalFirstAuth();

      return {
        isLoading,
        recoveryPhrase: secret ? RecoveryPhrase.fromSecret(secret) : null,
      };
    }
    ```
  </Tab>

  <Tab value="Vue">
    ```ts
    export function useRecoveryPhraseBackup() {
      const { secret, isLoading } = useLocalFirstAuth();
      const recoveryPhrase = computed(() =>
        secret.value ? RecoveryPhrase.fromSecret(secret.value) : null,
      );
      return { isLoading, recoveryPhrase };
    }
    ```
  </Tab>

  <Tab value="Svelte">
    ```ts
    export function createRecoveryPhraseBackup(auth: LocalFirstAuth) {
      return {
        get isLoading() {
          return auth.isLoading;
        },
        get recoveryPhrase() {
          return auth.secret ? RecoveryPhrase.fromSecret(auth.secret) : null;
        },
      };
    }
    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts
    export async function getRecoveryPhrase(): Promise<string | null> {
      const secret = await BrowserAuthSecretStore.loadSecret();
      return secret ? RecoveryPhrase.fromSecret(secret) : null;
    }
    ```
  </Tab>
</Tabs>

To restore, decode the user-provided passphrase back into a secret and hand it back to your
local-first auth flow:

<Tabs groupId="jazz-framework" persist updateAnchor items={["React", "Vue", "Svelte", "TypeScript"]}>
  <Tab value="React">
    ```tsx
    export function useRecoveryPhraseRestore(): (userInput: string) => Promise<void> {
      const { login } = useLocalFirstAuth();

      return async (userInput: string) => {
        const restoredSecret = RecoveryPhrase.toSecret(userInput);
        await login(restoredSecret);
      };
    }
    ```
  </Tab>

  <Tab value="Vue">
    ```ts
    export function useRecoveryPhraseRestore() {
      const { login } = useLocalFirstAuth();
      return async (userInput: string) => {
        const restoredSecret = RecoveryPhrase.toSecret(userInput);
        await login(restoredSecret);
      };
    }
    ```
  </Tab>

  <Tab value="Svelte">
    ```ts
    export function createRecoveryPhraseRestore(auth: LocalFirstAuth) {
      return async (userInput: string) => {
        const restoredSecret = RecoveryPhrase.toSecret(userInput);
        await auth.login(restoredSecret);
      };
    }
    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts
    export async function restoreFromRecoveryPhrase(userInput: string): Promise<void> {
      const restoredSecret = RecoveryPhrase.toSecret(userInput);
      await BrowserAuthSecretStore.saveSecret(restoredSecret);
      // Reload so the live Jazz client picks up the restored secret.
      location.reload();
    }
    ```
  </Tab>
</Tabs>

The parser is case-insensitive and tolerant of extra whitespace between words. Invalid input throws
`RecoveryPhraseError` with codes like `invalid-length`, `invalid-word`, and
`invalid-checksum`.

<Callout type="info">
  Calling `login()` on the framework-specific local-first auth handle (`useLocalFirstAuth()` in
  React/Expo/Vue, `LocalFirstAuth` in Svelte) updates the mounted provider immediately. Plain
  TypeScript uses `BrowserAuthSecretStore.saveSecret()` directly, which doesn't notify the live
  client — the snippets above reload the page so the new secret is picked up on the next load.
</Callout>

Passkey backup [#passkey-backup]

A passkey is a credential stored and synced by the user's browser or platform (via the WebAuthn
browser API), typically unlocked with biometrics. `jazz-tools/passkey-backup` uses this as an
encrypted at-rest store for the secret: the secret is held inside a resident WebAuthn
credential and released after a user verification prompt. This is not a full WebAuthn
authentication flow — Jazz is not challenging the key, just using the passkey as a vault for the
seed.

Passkey availability depends on where the user's passkey provider works. OS-native stores
(iCloud Keychain, Google Password Manager) have platform boundaries that are not always visible
to users. For example, a passkey created on Safari/macOS may not be available on Chrome/Windows unless the
user is using a third-party passkey manager (1Password, Bitwarden, Dashlane etc.) that spans
both. WebAuthn's cross-device flow (QR from laptop to phone) can bridge a session but requires
the original device, so it doesn't help after a loss.

<Callout type="warn">
  Treat passkey backup as one option among several. Pair it with a recovery passphrase or a linked
  provider account so the user doesn't get locked out.
</Callout>

Create the passkey from the current local-first secret:

<Tabs groupId="jazz-framework" persist updateAnchor items={["React", "Vue", "Svelte", "TypeScript"]}>
  <Tab value="React">
    ```tsx
    const passkeyBackup = new BrowserPasskeyBackup({
      appName: "My App",
      // Pin to your canonical production hostname. If omitted, defaults to `location.hostname`,
      // which scopes passkeys per preview-deploy URL.
      appHostname: "myapp.com",
    });

    export function usePasskeyBackup(): {
      isLoading: boolean;
      backupWithPasskey: (displayName: string) => Promise<void>;
    } {
      const { secret, isLoading } = useLocalFirstAuth();

      return {
        isLoading,
        backupWithPasskey: async (displayName: string) => {
          if (!secret) {
            throw new Error("Local-first secret is not ready yet");
          }

          await passkeyBackup.backup(secret, displayName);
        },
      };
    }
    ```
  </Tab>

  <Tab value="Vue">
    ```ts
    const passkeyBackup = new BrowserPasskeyBackup({
      appName: "My App",
      // Pin to your canonical production hostname. If omitted, defaults to `location.hostname`,
      // which scopes passkeys per preview-deploy URL.
      appHostname: "myapp.com",
    });

    export function usePasskeyBackup() {
      const { secret } = useLocalFirstAuth();
      return async (displayName: string) => {
        if (!secret.value) throw new Error("No local secret to back up yet");
        await passkeyBackup.backup(secret.value, displayName);
      };
    }
    ```
  </Tab>

  <Tab value="Svelte">
    ```ts
    const passkeyBackup = new BrowserPasskeyBackup({
      appName: "My App",
      // Pin to your canonical production hostname. If omitted, defaults to `location.hostname`,
      // which scopes passkeys per preview-deploy URL.
      appHostname: "myapp.com",
    });

    export function createPasskeyBackup(auth: LocalFirstAuth) {
      return async (displayName: string) => {
        if (!auth.secret) throw new Error("No local secret to back up yet");
        await passkeyBackup.backup(auth.secret, displayName);
      };
    }
    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts
    const passkeyBackup = new BrowserPasskeyBackup({
      appName: "My App",
      // Pin to your canonical production hostname. If omitted, defaults to `location.hostname`,
      // which scopes passkeys per preview-deploy URL.
      appHostname: "myapp.com",
    });

    export async function backupToPasskey(displayName: string): Promise<void> {
      const secret = await BrowserAuthSecretStore.loadSecret();
      if (!secret) throw new Error("No local secret to back up yet");
      await passkeyBackup.backup(secret, displayName);
    }
    ```
  </Tab>
</Tabs>

Restore by reading the secret back from the passkey:

<Tabs groupId="jazz-framework" persist updateAnchor items={["React", "Vue", "Svelte", "TypeScript"]}>
  <Tab value="React">
    ```tsx
    export function usePasskeyRestore(): () => Promise<void> {
      const { login } = useLocalFirstAuth();

      return async () => {
        const restoredSecret = await passkeyBackup.restore();
        await login(restoredSecret);
      };
    }
    ```
  </Tab>

  <Tab value="Vue">
    ```ts
    export function usePasskeyRestore() {
      const { login } = useLocalFirstAuth();
      return async () => {
        const restoredSecret = await passkeyBackup.restore();
        await login(restoredSecret);
      };
    }
    ```
  </Tab>

  <Tab value="Svelte">
    ```ts
    export function createPasskeyRestore(auth: LocalFirstAuth) {
      return async () => {
        const restoredSecret = await passkeyBackup.restore();
        await auth.login(restoredSecret);
      };
    }
    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts
    export async function restoreFromPasskey(): Promise<void> {
      const restoredSecret = await passkeyBackup.restore();
      await BrowserAuthSecretStore.saveSecret(restoredSecret);
      location.reload();
    }
    ```
  </Tab>
</Tabs>

<Callout type="info">
  Passkey backup is currently browser-only. Mobile platform support is planned.
</Callout>

* `appHostname` becomes the WebAuthn relying-party ID which is the namespace passkeys are scoped to.
  If you change it, all existing passkeys will stop working.

* The `displayName` argument to `backup(secret,
  displayName)` is the user-visible name shown in the platform passkey sheet.

Handle `PasskeyBackupError` in your UI to surface cases like unsupported browsers, no saved
credential, or missing user verification. Keep a recovery passphrase or external sign-in as a
fallback if the user might lose access to their passkey provider.

Server configuration [#server-configuration]

Local-first auth is enabled by default in the cloud, and in local dev mode. For self-hosted production setups, enable it explicitly:

```bash
pnpm dlx jazz-tools@alpha server <APP_ID> --allow-local-first-auth
```

No JWKS endpoint is needed: the token is self-contained and the server can verify it on its own.

Signing up with BetterAuth [#signing-up-with-betterauth]

Users can try your app before creating an account. When they sign up later through BetterAuth, all the data they created carries over: same Jazz account, now with an email (or OAuth identity) attached.

The client generates a short-lived proof that it owns the current Jazz account and hands it to BetterAuth alongside the sign-up credentials. BetterAuth verifies the proof, records the Jazz account ID against the new user, and issues JWTs that keep pointing to the same ID. For the reasoning and sequence diagram, see [Upgrading to a provider account](/docs/reference/local-first-auth-internals#upgrading-to-a-provider-account).

Generating the proof token [#generating-the-proof-token]

Before calling your sign-up endpoint, the client generates a short-lived proof that it owns the current Jazz account:

```tsx
function SignUpButton() {
  const db = useDb();

  async function handleSignUp(email: string, password: string) {
    // Generate proof of ownership of the current Jazz identity
    const proofToken = await db.getLocalFirstIdentityProof({
      ttlSeconds: 60,
      audience: "betterauth-signup",
    });

    if (!proofToken) {
      throw new Error("Sign up requires an active Jazz session");
    }

    const res = await authClient.signUp.email({
      email,
      name: email,
      password,
      proofToken,
    } as Parameters<typeof authClient.signUp.email>[0]);

    if (res.error) {
      throw new Error(res.error.message);
    }
  }

  return <button onClick={() => handleSignUp("user@example.com", "password")}>Sign Up</button>;
}
```

`audience` is application-defined; the string only needs to match what the server passes when verifying. `ttlSeconds` keeps the window short — 60 seconds is usually enough for a sign-up round-trip.

Verifying on sign-up [#verifying-on-sign-up]

On the server, a BetterAuth middleware hook intercepts sign-up requests, verifies the proof token, and assigns the proven Jazz user ID to the new BetterAuth user:

```ts
export const auth = betterAuth({
  // ...your database, email, plugins config
  plugins: [
    jwt({
      jwks: { keyPairConfig: { alg: "ES256" } },
      jwt: {
        issuer: "https://your-app.example.com",
        definePayload: ({ user }) => ({
          claims: { role: (user as { role?: string }).role ?? "" },
        }),
      },
    }),
  ],
  hooks: {
    before: createAuthMiddleware(async (ctx) => {
      if (ctx.path !== "/sign-up/email") return;

      const { verifyLocalFirstIdentityProof } = await import("jazz-napi");
      const {
        ok,
        error,
        id: provedUserId,
      } = verifyLocalFirstIdentityProof(ctx.body?.proofToken, "betterauth-signup");
      if (!ok) {
        throw new APIError("BAD_REQUEST", { message: error });
      }

      return {
        context: {
          ...ctx,
          body: { ...ctx.body, provedUserId },
        },
      };
    }),
  },
  databaseHooks: {
    user: {
      create: {
        before: async (user: any, ctx: any) => {
          // Assign the proven Jazz user ID to the BetterAuth user
          const provedUserId = ctx?.body?.provedUserId;
          if (provedUserId) {
            return { data: { ...user, id: provedUserId } };
          }
        },
      },
    },
  },
});
```

`verifyLocalFirstIdentityProof` from `jazz-napi` handles validation — it returns `{ ok, id, error }`. If the token is missing or invalid, `error` contains the reason. The `databaseHooks.user.create.before` hook assigns the proven ID to the new BetterAuth user, so the identity carries over.

Config resolution [#config-resolution]

The app switches between local-first auth and external JWT based on whether the user has a BetterAuth session:

```tsx
function useBetterAuthJWT() {
  const { data, isPending } = authClient.useSession();
  const [jwt, setJwt] = useState<string | null>(null);
  const [isFetching, setIsFetching] = useState(false);

  useEffect(() => {
    if (isPending) return;
    if (!data?.session) {
      setJwt(null);
      return;
    }
    setIsFetching(true);
    void getJwtFromBetterAuth().then((token) => {
      setJwt(token ?? null);
      setIsFetching(false);
    });
  }, [isPending, data?.session?.id]);

  return {
    isLoading: isPending || isFetching,
    jwt,
    getRefreshedJWT: () => getJwtFromBetterAuth(),
  };
}

function App() {
  const betterAuth = useBetterAuthJWT();
  const { secret: localFirstSecret, isLoading: localFirstLoading } = useLocalFirstAuth();

  // Only mint a local-first secret when there's no BetterAuth session.
  const secret = !betterAuth.jwt ? (localFirstSecret ?? undefined) : undefined;

  const config = useMemo<DbConfig>(
    () => ({
      appId: process.env.NEXT_PUBLIC_JAZZ_APP_ID!,
      serverUrl: process.env.NEXT_PUBLIC_JAZZ_SERVER_URL!,
      jwtToken: betterAuth.jwt ?? undefined,
      secret,
    }),
    [betterAuth.jwt, secret],
  );

  if (betterAuth.isLoading || (!betterAuth.jwt && localFirstLoading)) return <p>Loading auth…</p>;

  return (
    <JazzProvider
      config={config}
      onJWTExpired={() => betterAuth.getRefreshedJWT()}
      fallback={<p>Loading Jazz DB…</p>}
    >
      <YourApp />
    </JazzProvider>
  );
}
```

Permissions by auth mode [#permissions-by-auth-mode]

You can restrict what local-first users can do until they sign up. Define an `isLocalFirstAuthMode` check in your permissions and use it to gate writes:

```ts
export default definePermissions(app, ({ policy, allOf, session }) => {
  const isLocalFirstAuthMode = session.where({
    authMode: "local-first",
  });

  // Everyone can read
  policy.messages.allowRead.always();

  // Local-first users can only read — block inserts until they sign up
  policy.messages.allowInsert.where(allOf([{ not: isLocalFirstAuthMode }]));
});
```

Signing up with other providers [#signing-up-with-other-providers]

The BetterAuth example above uses a framework-specific middleware hook. With any other provider the pattern is the same — you need a server endpoint that:

1. Accepts `proofToken` alongside the sign-up credentials
2. Verifies it with `verifyLocalFirstIdentityProof` from `jazz-napi`
3. Stores the proven Jazz user ID linked to the new user account
4. Issues JWTs with `sub: jazzId`

Sketched in pseudo-Express (`db.users.create` and `issueJwt` stand in for whatever your stack provides):

```ts title="Illustrative — not a runnable example"
import { verifyLocalFirstIdentityProof } from "jazz-napi";

// POST /signup
app.post("/signup", async (req, res) => {
  const { email, password, proofToken } = req.body;

  const {
    ok,
    id: jazzUserId,
    error,
  } = verifyLocalFirstIdentityProof(
    proofToken,
    "my-app-signup", // must match the audience used on the client
  );
  if (!ok) return res.status(400).json({ error });

  // Store jazzUserId so you can mint future JWTs with it as `sub`.
  const user = await db.users.create({ email, password, jazzId: jazzUserId });

  res.json({ token: issueJwt({ sub: user.jazzId /* ... */ }) });
});
```

The `audience` string on the client and server must match. `jazz-napi` normalizes it internally, so any consistent string works.

Jazz reads the JWT `sub` claim verbatim as `session.user_id` — no custom-claim fallback — so your provider needs to emit the Jazz ID as `sub`. If it doesn't let you override `sub` (e.g. it pins it to its own user ID), you'll need to mint the JWT yourself rather than using the provider's issuer.

Next steps [#next-steps]

* [Lifecycle](/docs/auth/lifecycle) — local-first storage, logout, and upgrading auth
* [Sessions](/docs/auth/sessions) — read the current user and scope queries to their identity
* [Permissions](/docs/auth/permissions) — define row-level access policies
* [Auth provider integration](/docs/recipes/auth/auth-provider-integration) — set up BetterAuth or WorkOS as your JWT provider
* [Local-first auth internals](/docs/reference/local-first-auth-internals) — token format, verification, and design rationale


# Permissions



import { Accordion, Accordions } from "fumadocs-ui/components/accordion";

Permissions control who can read, insert, update, and delete rows. Jazz enforces them server-side using row-level policies defined in `permissions.ts`. Clients that fail a policy check have their writes rejected and their reads filtered.

Locally, Jazz distinguishes between two runtime states:

* no compiled policy bundle loaded: local session-scoped reads and writes stay permissive
* compiled policy bundle loaded: every read, insert, update, and delete needs an explicit grant, or it is denied

Authoring workflow [#authoring-workflow]

Permissions are authored in TypeScript.

If we have the following schema:

```ts title="schema.ts"
const schema = {
  projects: s.table({
    name: s.string(),
    owner_id: s.string(),
  }),
  todos: s.table({
    title: s.string(),
    done: s.boolean(),
    parentId: s.ref("todos").optional(),
    projectId: s.ref("projects").optional(),
    owner_id: s.string(),
  }),
  todoShares: s.table({
    todoId: s.ref("todos"),
    user_id: s.string(),
    can_read: s.boolean(),
  }),
};
```

We can define permissions in `permissions.ts`:

```ts title="permissions.ts"
import { schema as s } from "jazz-tools";
import { app } from "./schema.js";

export default s.definePermissions(app, ({ policy, session }) => {
  // Users can only read, create, update, and delete their own todos.
  policy.todos.allowRead.where({ owner_id: session.user_id });
  policy.todos.allowInsert.where({ owner_id: session.user_id });
  policy.todos.allowUpdate.where({ owner_id: session.user_id });
  policy.todos.allowDelete.where({ owner_id: session.user_id });
});

```

<Callout type="info">
  Write your permissions policies in `permissions.ts` next to `schema.ts`. By default Jazz looks for both files at your project root — except in SvelteKit projects, where they should live in `src/lib/` (the standard location for shared app code).

  Run `pnpm dlx jazz-tools@alpha validate` before publishing to surface warnings about tables with no explicit permission policy. You do not need to write a schema migration to update permissions policies. Push the updated policies by running `pnpm dlx jazz-tools@alpha deploy <appId>`.
</Callout>

Apps that do not need user-scoped filtering should still declare explicit grants (`policy.todos.allowRead.always()` or `policy.todos.allowRead.where({})`) once a compiled bundle is loaded.

Basic policies [#basic-policies]

Simple conditions [#simple-conditions]

Use the policy helpers `allowRead`, `allowInsert`, `allowUpdate`, and `allowDelete` with [`.where(...)`](/docs/reference/where-operators) to restrict access based on column values.

```ts title="permissions.ts"
s.definePermissions(exampleApp, ({ policy, allOf, session }) => {
  policy.todos.allowRead.where({ owner_id: session.user_id });
  // Users cannot create todos with different owners
  policy.todos.allowInsert.where({ owner_id: session.user_id });
  // Users can update their own todos, but only if not already done
  policy.todos.allowUpdate
    .whereOld(allOf([{ owner_id: session.user_id }, { done: false }]))
    .whereNew({ owner_id: session.user_id });
  // Users can only delete their own todos
  policy.todos.allowDelete.where({ owner_id: session.user_id });
});
```

.always() [#always]

Use `.always()` when an operation should always be permitted. It is equivalent to `.where({})`.

```ts title="permissions.ts"
s.definePermissions(exampleApp, ({ policy }) => {
  policy.todos.allowRead.always();
  policy.todos.allowInsert.always();
  policy.todos.allowUpdate.always();
  policy.todos.allowDelete.always();
});
```

.never() [#never]

Use `.never()` when an operation should be impossible. It is equivalent to
`.where(anyOf([]))`.

```ts title="permissions.ts"
s.definePermissions(exampleApp, ({ policy }) => {
  policy.todos.allowRead.never();
  policy.todos.allowInsert.never();
  policy.todos.allowUpdate.never();
  policy.todos.allowDelete.never();
});
```

Composing policies [#composing-policies]

Combining conditions (allOf / anyOf) [#combining-conditions-allof--anyof]

Combine conditions with `allOf` (all must match) or `anyOf` (any can match).

```ts title="permissions.ts"
s.definePermissions(exampleApp, ({ policy, allOf, anyOf, allowedTo, session }) => {
  // Users can read a todo if they own it, or if it's not done and they can read its project.
  policy.todos.allowRead.where(
    anyOf([{ owner_id: session.user_id }, allOf([{ done: false }, allowedTo.read("project")])]),
  );
});
```

JWT session claims [#jwt-session-claims]

When external auth JWTs carry claims, `session.where(...)` lets you check them directly in permissions without mapping them onto row columns first.

```ts title="permissions.ts"
s.definePermissions(exampleApp, ({ policy, anyOf, session }) => {
  policy.todos.allowRead.where(
    anyOf([{ owner_id: session.user_id }, session.where({ "claims.role": "manager" })]),
  );
});
```

Inherited access (allowedTo.*) [#inherited-access-allowedto]

A row can inherit its access from a related row. Use
`allowedTo.read/insert/update/delete(...)` to express that inheritance.

```ts title="permissions.ts"
s.definePermissions(exampleApp, ({ policy, anyOf, allOf, allowedTo }) => {
  // Users can read a todo if it's not done, or if they can read its project.
  policy.todos.allowRead.where(anyOf([{ done: false }, allowedTo.read("project")]));
  // Users can update a todo if they can update its project and it's not done.
  policy.todos.allowUpdate
    .whereOld(allOf([allowedTo.update("project"), { done: false }]))
    .whereNew(allowedTo.update("project"));
});
```

Share-based access [#share-based-access]

Sometimes access isn't determined by ownership or a parent relationship, but by a separate "shares" table. Use `policy.<table>.exists.where(...)` to check whether a matching row exists in another table.

```ts title="permissions.ts"
s.definePermissions(exampleApp, ({ policy, anyOf, session }) => {
  // Users can read a todo if they own it, or if someone shared it with them.
  policy.todos.allowRead.where((todo) =>
    anyOf([
      { owner_id: session.user_id },
      policy.todoShares.exists.where({
        todoId: todo.id,
        user_id: session.user_id,
        can_read: true,
      }),
    ]),
  );
});
```

The callback form `(todo) => ...` gives you access to the current row, so you can correlate it with rows in other tables.

<Accordions type="single">
  <Accordion title="Recursive inheritance">
    If a table references itself (e.g. a comment that can have sub-comments), you can inherit access recursively up to a fixed depth.

    ```ts title="permissions.ts"
    s.definePermissions(exampleApp, ({ policy, allowedTo }) => {
      // Users can read a todo if they can read its parent (follows the chain upward).
      policy.todos.allowRead.where(allowedTo.read("parent"));
      // Users can update a todo if they can update its parent, up to 5 levels deep.
      policy.todos.allowUpdate
        .whereOld(allowedTo.update("parent", { maxDepth: 5 }))
        .whereNew(allowedTo.update("parent", { maxDepth: 5 }));
    });
    ```

    `maxDepth` must be a positive integer.
  </Accordion>
</Accordions>

Update policies: old row vs new row [#update-policies-old-row-vs-new-row]

Update policies can check both the row **before** the update (`.whereOld(...)`) and the row
**after** the update (`.whereNew(...)`). This is useful when you need to verify that the user had
permission to modify the original row and that the result is also valid.

```ts title="permissions.ts"
s.definePermissions(exampleApp, ({ policy, session }) => {
  // User can only update their own rows, and the result must still be owned by them
  policy.todos.allowUpdate
    .whereOld({ owner_id: session.user_id })
    .whereNew({ owner_id: session.user_id });
});
```

If you only use `.whereOld(...)`, the same condition is applied to both the old and new row. The
same applies if you only use `.whereNew(...)`. Use both when the old-row and new-row checks differ.

`exists` runs before a write is applied, so it can be used to prevent some columns from being updated.

```ts title="permissions.ts"
s.definePermissions(exampleApp, ({ policy, allOf, session }) => {
  policy.todos.allowUpdate.whereOld({ owner_id: session.user_id }).whereNew((updatedTodo) =>
    allOf([
      { owner_id: session.user_id },
      // `parentId` and `projectId` cannot be updated.
      policy.todos.exists.where({
        id: updatedTodo.id,
        parentId: updatedTodo.parentId,
        projectId: updatedTodo.projectId,
      }),
    ]),
  );
});
```

Policy enforcement and request context [#policy-enforcement-and-request-context]

Every policy is evaluated against a session. On the frontend, this is the authenticated user's session. In a backend handler, create a scoped session per request so queries run with the right identity.

```ts title="handler.ts"
export async function listTodosForRequester(req: Request, res: Response): Promise<void> {
  try {
    const requester = await context.forRequest(req, schemaApp);
    const rows = await requester.all(schemaApp.todos.where({ done: true }));
    res.json(rows);
  } catch {
    sendQueryError(res);
  }
}
```

See [Server Setup](/docs/getting-started/server-setup#backend-context-setup) for backend context setup details.

<Callout type="info">
  Structural-only client runtimes stay permissive locally so offline reads and writes keep working.
  Once a compiled bundle is loaded, Jazz enforces explicit grants locally too. Sync servers still
  reject violating writes, and server-scoped reads are filtered before data is sent to the client.
</Callout>

Magic columns [#magic-columns]

Jazz exposes a small set of system-provided magic columns at query time. They do not exist in your
schema, and they are omitted from `select("*")`, so opt in explicitly when you want them.

Permission introspection columns [#permission-introspection-columns]

* `$canRead` — whether the current session can read the row. Since read policies already gate which rows appear, this will usually be `true`.
* `$canEdit` — whether the current session passes the row's update policy.
* `$canDelete` — whether the current session passes the row's delete policy.
* Without a session, all three return `null`.

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    export async function readTodoPermissionIntrospection(db: Db) {
      return db.all(
        app.todos.select("title", "$canRead", "$canEdit", "$canDelete").orderBy("title", "asc"),
      );
    }

    export async function readTodosWithDeletePermission(db: Db) {
      return db.all(app.todos.select("*", "$canDelete").orderBy("title", "asc"));
    }

    export async function readEditableTodos(db: Db) {
      return db.all(app.todos.where({ $canEdit: true }).select("title", "$canEdit"));
    }

    export async function readDeletableTodos(db: Db) {
      return db.all(app.todos.where({ $canDelete: true }).select("title", "$canDelete"));
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    pub async fn read_todos_with_permissions(client: &JazzClient) -> jazz_tools::Result<usize> {
        let query = QueryBuilder::new("todos")
            .select(&["title", "$canRead", "$canEdit", "$canDelete"])
            .build();

        let rows = client.query(query, None).await?;
        Ok(rows.len())
    }
    ```
  </Tab>
</Tabs>

Permission columns work the same way inside `include(...)` subqueries: the booleans are evaluated **per row**, against the policy on the included table, not inherited from the parent. A query that loads a project with its todos can ask whether the current session can edit each individual todo, driving per-row UI affordances like edit buttons or delete confirmations without mirroring your permission policy on the client.

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    export async function readProjectsWithTodoPermissions(db: Db) {
      return db.all(
        app.projects.include({
          todosViaProject: app.todos.select("title", "$canEdit", "$canDelete").orderBy("title", "asc"),
        }),
      );
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    pub async fn read_projects_with_todo_permissions(client: &JazzClient) -> jazz_tools::Result<usize> {
        let query = QueryBuilder::new("projects")
            .with_array("todos_via_project", |sub| {
                sub.from("todos").correlate("project_id", "_id").select(&[
                    "title",
                    "$canEdit",
                    "$canDelete",
                ])
            })
            .build();

        let rows = client.query(query, None).await?;
        Ok(rows.len())
    }
    ```
  </Tab>
</Tabs>

Edit metadata columns [#edit-metadata-columns]

Jazz also tracks row authorship and timestamps automatically:

* `$createdBy` — the Jazz principal that created the row
* `$createdAt` — when the row was first created
* `$updatedBy` — the Jazz principal that last updated the row
* `$updatedAt` — when the row was last updated

Jazz always tracks this metadata; select the columns explicitly to include them in query results. Backend writes that are not attributed to a user are authored as `jazz:system`.

Authorship-based policies [#authorship-based-policies]

You can use these edit metadata magic columns directly in `permissions.ts`. This is useful for simple
"creator can read/edit/delete their own rows" policies without adding explicit `owner_id` columns.

For the most common case, Jazz also exposes `policy.<table>.managedByCreator()` and `isCreator` as
shorthand for the same `$createdBy === session.user_id` condition. You can still write the raw
`$createdBy` comparison yourself whenever you want the explicit form.

```ts title="permissions.ts"
s.definePermissions(exampleApp, ({ policy }) => {
  // Sugar for applying `$createdBy === session.user_id` to read/insert/update/delete.
  policy.todos.managedByCreator();
});
```

When you want to reuse the same check inside a larger rule, compose `isCreator` directly:

```ts title="permissions.ts"
s.definePermissions(exampleApp, ({ policy, anyOf, isCreator }) => {
  // The same creator condition can still be composed with other rules.
  policy.todos.allowRead.where(anyOf([isCreator, { done: true }]));
});
```

For more dynamic sharing or ownership models, prefer explicit tables and relations rather than
encoding extra meaning into authorship alone.

Testing permissions [#testing-permissions]

Jazz provides utilities to test permissions in isolation without testing your whole app's logic.

The `createPolicyTestApp` helper from `jazz-tools/testing` starts an isolated local Jazz server, publishes your app schema
and compiled permissions, and gives you session-scoped database clients for assertions.

```typescript title="permissions.test.ts"
import { createPolicyTestApp, type PolicyTestApp } from "jazz-tools/testing";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { app } from "../schema.js";
import permissions from "../permissions.js";

let testApp: PolicyTestApp;

beforeEach(async () => {
  testApp = await createPolicyTestApp(app, permissions, expect);
});

afterEach(async () => {
  await testApp.shutdown();
});

describe("todo permissions", () => {
  it("allows owners to update their own todos", () => {
    const todo = testApp.seed((db) => {
      const { value } = db.insert(app.todos, {
        title: "Buy milk",
        ownerId: "alice",
      });
      return value;
    });

    const alice = testApp.as({
      user_id: "alice",
      claims: {},
      authMode: "local-first",
    });
    const bob = testApp.as({
      user_id: "bob",
      claims: {},
      authMode: "local-first",
    });

    alice.expectAllowed((db) =>
      db.update(app.todos, todo.id, {
        title: "Buy oat milk",
      }),
    );

    bob.expectDenied((db) =>
      db.update(app.todos, todo.id, {
        title: "Buy orange juice",
      }),
    );
  });
});
```

`createPolicyTestApp` takes the app created with `defineApp(...)`, the
permissions object created with `definePermissions(...)`, and your test runner's `expect` function.

The returned `PolicyTestApp` provides:

* `testApp.seed(fn)` — run setup writes as an admin, bypassing policy checks
* `testApp.as(session)` — create a `TestDb` client scoped to a specific session
* `testDb.expectAllowed(fn)` — assert that a write does not throw a policy error
* `testDb.expectDenied(fn)` — assert that a write is rejected by a policy
* `testApp.shutdown()` — stop the local client and server

For read policies, assert on returned rows directly. Denied reads are filtered out rather than
throwing.

```typescript
const bob = testApp.as({
  user_id: "bob",
  claims: {},
  authMode: "local-first",
});

await expect(bob.all(app.todos.where({ id: privateTodo.id }))).resolves.toEqual([]);
```

If your policies depend on JWT claims, put those values in `session.claims` using the same claim
names your policy reads.

```typescript
const invitedUser = testApp.as({
  user_id: "bob",
  claims: { join_code: "invite-123" },
  authMode: "local-first",
});
```


# Sessions



After [setting up authentication](/docs/auth/authentication), you can read the current session to scope queries and inserts to the logged-in user.

Get the session and user ID [#get-the-session-and-user-id]

<Tabs groupId="jazz-framework" persist updateAnchor items={["React", "Vue", "Svelte", "TypeScript"]}>
  <Tab value="React">
    ```tsx
    const session = useSession();
    ```

    ```tsx
    const sessionUserId = session?.user_id ?? null;
    ```
  </Tab>

  <Tab value="Vue">
    ```vue
    const session = useSession();
    ```

    ```vue
    const sessionUserId = computed(() => session.value?.user_id ?? null);
    ```
  </Tab>

  <Tab value="Svelte">
    ```svelte
    const session = getSession();
    ```

    ```svelte
    const sessionUserId = $derived(session.current?.user_id ?? null);
    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts
    const session = db.getAuthState().session;
    ```

    ```ts
    const sessionUserId = session?.user_id ?? null;
    ```
  </Tab>
</Tabs>

Read rows for the current user [#read-rows-for-the-current-user]

Use the session's user ID with [`.where()`](/docs/reading/filters-and-sorting) to scope [queries](/docs/reading/queries) to the current user.

<Tabs groupId="jazz-framework" persist updateAnchor items={["React", "Vue", "Svelte", "TypeScript"]}>
  <Tab value="React">
    ```tsx
    const ownedTodos =
      useAll(sessionUserId ? app.todos.where({ owner_id: sessionUserId }) : undefined) ?? [];
    ```
  </Tab>

  <Tab value="Vue">
    ```vue
    const { data: ownedTodos } = useAll(
      computed(() =>
        sessionUserId.value ? app.todos.where({ owner_id: sessionUserId.value }) : undefined,
      ),
    );
    ```
  </Tab>

  <Tab value="Svelte">
    ```svelte
    const ownedTodos = new QuerySubscription(
      () => (sessionUserId ? app.todos.where({ owner_id: sessionUserId }) : undefined),
    );
    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts
    const ownedTodos = sessionUserId
      ? await db.all(app.todos.where({ owner_id: sessionUserId }))
      : [];
    ```
  </Tab>
</Tabs>

Insert a user-owned row [#insert-a-user-owned-row]

[Insert](/docs/writing/writing-data) a row with the session's user ID to associate it with the current user.

<Tabs groupId="jazz-framework" persist updateAnchor items={["React", "Vue", "Svelte", "TypeScript"]}>
  <Tab value="React">
    ```tsx
    function addOwnedTodo(title: string) {
      if (!sessionUserId) return;

      db.insert(app.todos, {
        title,
        done: false,
        owner_id: sessionUserId,
      });
    }
    ```
  </Tab>

  <Tab value="Vue">
    ```vue
    function addOwnedTodo(title: string) {
      if (!sessionUserId.value) return;

      db.insert(app.todos, {
        title,
        done: false,
        owner_id: sessionUserId.value,
      });
    }
    ```
  </Tab>

  <Tab value="Svelte">
    ```svelte
    function addOwnedTodo(title: string) {
      if (!sessionUserId) return;

      db.insert(app.todos, {
        title,
        done: false,
        owner_id: sessionUserId,
      });
    }
    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts
    function addOwnedTodo(title: string) {
      if (!sessionUserId) return;

      db.insert(app.todos, {
        title,
        done: false,
        owner_id: sessionUserId,
      });
    }
    ```
  </Tab>
</Tabs>

Use whatever column fits your schema — `owner_id`, `author_id`, `assignee_id`, etc. — the column names have no special significance.

Session identity and authorship [#session-identity-and-authorship]

Jazz also records authorship and edit times through the edit metadata magic columns `$createdBy`,
`$createdAt`, `$updatedBy`, and `$updatedAt`.

See [Queries](/docs/reading/queries#edit-metadata-columns) for how to select and filter on these
columns.

On clients, writes are automatically attributed to the current session user. On backends,
`await context.forRequest(...)` and `context.forSession(...)` both run as that user for permissions and
also stamp authorship as that user.

Attribution without impersonation [#attribution-without-impersonation]

Sometimes backend code should keep backend-level permissions but still record a user's identity in
edit metadata. Use the attribution helpers for this:

* `context.withAttribution(userId)` — stamps writes as the given user ID
* `context.withAttributionForSession(session)` — derives authorship from a resolved session
* `await context.withAttributionForRequest(req)` — derives authorship from an authenticated request

```ts
export async function createAttributedHandles(req: Request) {
  const syntheticSession = {
    user_id: "user_123",
    authMode: "external" as const,
    claims: {},
  };

  return {
    backendDb: context.asBackend(schemaApp),
    attributedDb: context.withAttribution("user_123", schemaApp),
    attributedSessionDb: context.withAttributionForSession(syntheticSession, schemaApp),
    attributedRequestDb: await context.withAttributionForRequest(req, schemaApp),
  };
}
```

These differ from `forRequest` / `forSession` in one key way:

* `await context.forRequest(...)` / `context.forSession(...)` change both permission evaluation **and** authorship
* `withAttribution*` only changes authorship — permissions stay at the backend level
* `asBackend()` and unscoped `db()` writes default to `jazz:system`

<Callout type="info">
  Client-side filters are useful for UX, but they don't enforce access control on their own. Use
  [Permissions](/docs/auth/permissions) to define row-level policies that control which sessions can
  read or mutate a row.
</Callout>

Next steps [#next-steps]

* [Permissions](/docs/auth/permissions) — define row-level access policies using session identity
* [Queries](/docs/reading/queries) — subscribe to data and filter by user
* [Writing Data](/docs/writing/writing-data) — insert, update, and delete rows


# Branches



Every row version in Jazz belongs to a **branch** — a named, independent view of your
data. Some branches are merged automatically at query time (schema versions), while others are
fully isolated (environments and user branches). This makes it easy to keep development data
separate from production, or to create parallel lines of work like drafts or staging.

How branches are composed [#how-branches-are-composed]

A branch name in Jazz has three parts:

| Part            | Set by                         | Example           |
| --------------- | ------------------------------ | ----------------- |
| **Environment** | Your app config (`env`)        | `dev`, `prod`     |
| **Schema hash** | Jazz (automatically)           | `a1b2c3d4`        |
| **User branch** | Your app config (`userBranch`) | `main`, `staging` |

These are joined into a single identifier: `dev-a1b2c3d4-main`.

The schema hash is derived automatically from your current schema definition.

Configuring branches [#configuring-branches]

By default, Jazz uses `env: "dev"` and `userBranch: "main"`. To target a different branch, set `env` and/or `userBranch` when creating your database context:

<Tabs groupId="jazz-framework" persist updateAnchor items={["React", "Vue", "Svelte", "TypeScript"]}>
  <Tab value="React">
    ```tsx
    export function BranchApp() {
      return (
        <JazzProvider
          config={{
            appId: "my-app",
            env: "prod",
            userBranch: "staging",
          }}
        >
          <TodoApp />
        </JazzProvider>
      );
    }
    ```
  </Tab>

  <Tab value="Vue">
    ```vue
    import { createJazzClient, JazzProvider } from "jazz-tools/vue";

    const client = createJazzClient({
      appId: "my-app",
      env: "prod",
      userBranch: "staging",
    });
    ```
  </Tab>

  <Tab value="Svelte">
    ```svelte
    import { createJazzClient, JazzSvelteProvider } from 'jazz-tools/svelte';

    const client = createJazzClient({
      appId: 'my-app',
      env: 'prod',
      userBranch: 'staging',
    });
    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts
    export async function createBranchDb() {
      return createDb({
        appId: "my-app",
        env: "prod",
        userBranch: "staging",
      });
    }
    ```
  </Tab>
</Tabs>

All [reads](/docs/reading/queries) and [writes](/docs/writing/writing-data) through this context will operate on the `prod-{schemaHash}-staging` branch. Data on other branches is unaffected.

<Callout type="info">
  As a best practice, set `env` to `"prod"` in production using an environment variable. This avoids
  cross-pollination of data between development and production.
</Callout>

Schema versions are merged automatically [#schema-versions-are-merged-automatically]

When your schema changes, Jazz creates a new branch with a different schema hash (e.g. `prod-a1b2c3d4-main` for v1 and `prod-e5f6g7h8-main` for v2). At the storage level, data from different schema versions is kept separate.

At query time, Jazz automatically fetches data from all known schema versions within your current environment and user branch. It applies [migrations](/docs/schemas/migrations) to adapt older data to your current schema automatically. See the [schemas, lenses and branches diagram](/docs/schemas/migrations#schemas-lenses-and-branches) for how schema versions, lenses and per-hash branches fit together.

Environments and user branches are fully isolated [#environments-and-user-branches-are-fully-isolated]

Unlike schema versions, different environments and user branches are never merged. A query on `dev-*-main` does not include data from `prod-*-main`, and a query on `*-*-staging` does not include data from `*-*-main`. This is hard isolation: there is no way to combine data across environments or user branches at query time.

<Callout type="info" title="Not to be confused with row history">
  A branch is a named table view such as `prod-a1b2c3d4-main`. A row history is the per-row version
  graph that records how one row changed over time. They are related, but they are not the same
  thing.
</Callout>


# How Sync Works



Traditional apps send queries to a remote server, which queries a database and returns data.

This creates a few familiar problems:

* the data is immediately stale
* both the client and the server must be online
* your app's performance depends on the speed of each network hop

Techniques like websockets, client-side caching, and optimistic updates help, but they add
complexity without changing the basic request/response shape.

Jazz solves these problems with **query subscriptions** backed by a local replica.

Queries drive everything [#queries-drive-everything]

When your app [subscribes to a query](/docs/reading/queries) (say, all todos where
[`done = false`](/docs/reading/filters-and-sorting)), Jazz sends that query subscription upstream.
The server evaluates the query against its own current relational state, finds the matching rows,
and sends them back. The client only ever sees rows it has asked for (and
[has permission to read](/docs/auth/permissions)).

The set of active queries on a client defines the rows it can see and will keep receiving updates
for.

The server keeps you subscribed [#the-server-keeps-you-subscribed]

When you register a query subscription with the server, it remembers.

The server keeps a live query graph for that subscription. When local writes, remote replay, or
schema/policy changes affect the result, it re-settles only the changed parts of the query and
pushes the relevant row updates downstream.

That means:

* if a new row matches your query, it is pushed automatically
* if a matching row changes and no longer fits, it stops appearing in the subscription result
* clients update their local replicas from deltas instead of from full snapshots every time

What happens offline [#what-happens-offline]

Reads always come from local storage.

In the browser, Jazz uses a dedicated worker reading from data stored locally in OPFS (Origin
Private File System). When multiple tabs are open, Jazz elects one tab as the storage leader and
routes follower tabs through it. If the leader tab closes, a new leader is elected.

Even without a network connection, your app can still read data it already has locally, whether
that data was synced earlier or written locally on the device.

[Writes](/docs/writing/writing-data) behave the same way: they are stored locally immediately so the
UI updates without waiting on a round-trip. Jazz queues the corresponding row-version updates for
upstream sync. When the client reconnects, queued writes are sent and active query subscriptions are
replayed automatically.

Infrastructure tiers [#infrastructure-tiers]

Jazz sync runs across three tiers:

<TierSyncDiagram />

**Local** is the first tier on the client itself. In browser persistent mode, a dedicated worker
hosts that local durable copy in OPFS so it can respond immediately while updates from higher tiers
stream in.

**Edge** is the first server hop after the client. In cloud configurations it is usually a nearby
node. Edge servers hold the data needed to serve the queries currently flowing through them.

**Global** is the global reconciliation tier. Edge servers reconcile through global, which is how
updates eventually spread to every subscribed client.

How data flows [#how-data-flows]

Writes flow **upward**:

```text
app -> local -> edge -> global
```

As a write flows through the network, each tier can confirm that it has durably received it. That
is what durability tiers are built on.

Reads flow **downward on demand**. When you create a query subscription, it is forwarded upward.
Each tier registers the subscription with the next tier and asks for the rows needed to satisfy it.
That allows the system to replicate only the data that has actually been requested.

Lower tiers have lower latency, but writes have further to travel before every other client can see
them. As a rough guide:

* waiting for the global core is only necessary if you want the strongest cross-region visibility
* waiting for edge is useful when you want to know data has left the user's device
* the default local tier is right for most local-first interactions

<Callout type="info" title="Sync is automatic">
  Sync happens whenever a node is online. Writes keep propagating upward even if your promise
  resolves at the local tier. Reads similarly propagate downward as each tier registers the query
  with the next one up. If higher tiers have newer rows, they stream down automatically.
</Callout>

Consistency model [#consistency-model]

Every write in Jazz produces a new **row version**. That row version is stored locally, can be
replicated upward, and contributes to the current visible state for that row.

Because sync is local-first, different tiers can temporarily disagree:

* your local tier may already have a newer row version than edge
* one edge may have data another edge has not fetched yet
* another client may have its own concurrent local write

All of that still converges. Row versions propagate upward, are durably stored at higher tiers, and
flow back down to every subscribed client that needs them.

When clients write concurrently to the same field of the same row, Jazz uses
**last-writer-wins (LWW)** to decide the current visible result. Even row versions that lose that
race remain in row history, so the system keeps enough information to reconcile deterministically
and to support richer history-aware behavior later.

See it in action [#see-it-in-action]

[Wequencer](https://github.com/garden-co/jazz2/tree/main/examples/wequencer) is a collaborative
real-time music sequencer built with Jazz and Svelte. Multiple users place beats on a shared grid
and hear each other's changes immediately, which makes it a good example of query subscriptions,
real-time sync, and conflict-friendly collaboration in practice.


# Local-First Data Model



Jazz embeds a database on each device and syncs to the server in the background.

How Jazz stores data [#how-jazz-stores-data]

As covered in [How Sync Works](/docs/concepts/how-sync-works), traditional apps wait on a network
round-trip for every read and write. Jazz eliminates that entirely by embedding a subset of the
database on your users' devices.

[Reads](/docs/reading/queries) are immediate from local storage. [Writes](/docs/writing/writing-data)
(`insert`, `update`, `delete`) are also applied locally immediately, with no network round-trip.
The sync layer picks those changes up in the background and propagates them whenever a client is
online.

This has a practical consequence: there is no difference between "optimistic" and "real" state.
The local write *is* the state. There is no single always-online source of truth. Instead, every
client that has received the same row-history updates converges on the same current result.

The only loading moment [#the-only-loading-moment]

Devices cannot show data they do not have. On the very first load from the server, there will be a
loading moment while data starts to populate the local embedded database. From that point on, data
for that query is available locally and can be read again even while offline.

<Callout type="info" title="Durability Tiers">
  If you need to ensure data is fully up-to-date before displaying it, you can opt in to waiting for
  a higher [durability tier](/docs/reference/durability-tiers).
</Callout>

Tables, row versions, and visible state [#tables-row-versions-and-visible-state]

Jazz stays table-first all the way down.

Each application table still behaves like a table, but the engine also tracks a little extra
information for each logical row:

* a stable row identity
* a current visible state used for ordinary reads
* a retained history of row versions over time

The easiest picture is:

```text
todos
  visible: current answer for each row in a branch
  history: row versions over time for that same row
```

That is why the app-facing API can stay simple while the runtime still has enough information for
replay, reconnect, and conflict resolution.

Row history [#row-history]

Every write to a row creates a new **row version**.

That version records:

* the new row values
* which earlier version(s) it came from
* engine-managed metadata such as branch, delete state, and durability state

Physically, that row version is still one flat stored row: user columns plus reserved `_jazz_*`
columns in the same binary row format. So the runtime keeps a row-local history graph rather than
just overwriting the row in place.

Concurrent edits [#concurrent-edits]

When only one device is editing a row, the history is effectively linear. When multiple peers edit
the same row concurrently, several row versions can exist at once and later be reconciled into a
single current visible result.

You can think of it like this:

<Graph
  eyebrow="Row version history"
  description={
  <>
    One device edits linearly. Concurrent edits branch into separate row versions, then reconcile
    into a single current visible result.
  </>
}
  direction="LR"
  converge
  grid={{ gap: "1.25rem 3rem" }}
  nodes={[
  { id: "v1", rank: 0, label: "v1" },
  { id: "v2", rank: 1, label: "v2" },
  { id: "a3", rank: 2, order: 0, label: "a3" },
  { id: "b3", rank: 2, order: 1, label: "b3" },
  { id: "m4", rank: 3, label: "m4" },
]}
  edges={[
  { from: "v1", to: "v2" },
  { from: "v2", to: "a3" },
  { from: "v2", to: "b3" },
  { from: "a3", to: "m4" },
  { from: "b3", to: "m4" },
]}
/>

The important point is not the exact shape of the graph. The important point is that Jazz preserves
enough row history to converge deterministically after peers reconnect.

Conflict resolution [#conflict-resolution]

When concurrent writes touch the same field of the same row, Jazz resolves the visible result with
[last-writer-wins (LWW)](/docs/concepts/how-sync-works#consistency-model). The later row version
wins for the conflicting field.

If two peers update different fields, both changes can still be preserved in the resulting visible
row state. Even row versions that lose out in the current visible result remain in row history, so
no local-first reconciliation information is discarded.

<Callout type="warn" title="Beware of unexpected results">
  Jazz can resolve structural conflicts for you, but it cannot fully understand the meaning of your
  data. If Alice renames a to-do while Bob marks it complete, both changes may be preserved even if
  the new title changes what the task means. You still need application-level judgment about what
  kinds of concurrent editing should be allowed.
</Callout>

How this differs from traditional apps [#how-this-differs-from-traditional-apps]

|                        | Traditional                                | Jazz                                                               |
| ---------------------- | ------------------------------------------ | ------------------------------------------------------------------ |
| **Read path**          | HTTP request, wait for response            | Read from local storage                                            |
| **Write path**         | HTTP request, wait for confirmation        | Immediate local persistence, background sync                       |
| **Optimistic updates** | Manually implemented, must handle rollback | Not needed, the local write is authoritative                       |
| **Offline support**    | Bespoke queueing and retry logic           | Reads and writes continue to work against the local database       |
| **Loading states**     | Every network call                         | Only on first connection                                           |
| **Source of truth**    | Single authoritative database              | Every client with the same row-history updates sees the same state |
| **Conflict handling**  | Server rejects or last-request-wins        | Automatic visible-state reconciliation with retained history       |


# Client Setup



import { Accordion, Accordions } from "fumadocs-ui/components/accordion";
import { Callout } from "fumadocs-ui/components/callout";
import CreateJazzClientReference from "../../partials/create-jazz-client-reference.mdx";

Zero-config client setup [#zero-config-client-setup]

Every client needs an `appId` and a `secret` for [local-first auth](/docs/auth/local-first-auth). Without `secret`, the client runs in anonymous mode and every write is rejected with `AnonymousWriteDeniedError`. Add a `serverUrl` to enable sync. For the full auth matrix (anonymous, local-first, external JWT), see [Authentication](/docs/auth/authentication).

<Tabs groupId="jazz-framework" items={["React", "Vue", "Svelte", "Expo", "TypeScript"]} persist updateAnchor>
  <Tab value="React">
    ```tsx title="App.tsx"
    export function LocalFirstAuthApp() {
      const { secret, isLoading } = useLocalFirstAuth();

      if (isLoading || !secret) return null;

      return (
        <JazzProvider
          config={{
            appId: "my-app",
            secret,
          }}
        >
          <TodoApp />
        </JazzProvider>
      );
    }
    ```

    `useLocalFirstAuth()` from `jazz-tools/react` loads the device secret (or generates one on first run) and exposes `login` / `signOut` for switching identity.
  </Tab>

  <Tab value="Vue">
    ```vue title="App.vue"
    <script setup lang="ts">
    import { computed } from "vue";
    import { createJazzClient, JazzProvider, useLocalFirstAuth } from "jazz-tools/vue";

    const { secret, isLoading } = useLocalFirstAuth();

    const client = computed(() =>
      !isLoading.value && secret.value
        ? createJazzClient({ appId: "my-app", secret: secret.value })
        : null,
    );
    </script>

    <template>
      <JazzProvider v-if="client" :client="client">
        <slot />
      </JazzProvider>
    </template>

    ```

    `useLocalFirstAuth()` from `jazz-tools/vue` loads the device secret (or generates one on first run) and exposes `login` / `signOut` for switching identity.
  </Tab>

  <Tab value="Svelte">
    ```svelte title="App.svelte"
    <script lang="ts">
      import {
        LocalFirstAuth,
        createJazzClient,
        JazzSvelteProvider,
      } from 'jazz-tools/svelte';
      import type { Snippet } from 'svelte';

      let { children }: { children: Snippet } = $props();

      const auth = new LocalFirstAuth();

      let client = $derived(
        !auth.isLoading && auth.secret
          ? createJazzClient({ appId: 'my-app', secret: auth.secret })
          : null,
      );
    </script>

    {#if client}
      <JazzSvelteProvider {client}>
        {@render children()}
      </JazzSvelteProvider>
    {/if}
    ```

    `LocalFirstAuth` from `jazz-tools/svelte` loads the device secret (or generates one on first run) and exposes `login` / `signOut` for switching identity.
  </Tab>

  <Tab value="Expo">
    ```tsx title="App.tsx"
    export function LocalFirstAuthExpoApp() {
      const { secret, isLoading } = useLocalFirstAuth();

      if (isLoading || !secret) return null;

      return (
        <JazzProvider
          config={{
            appId: "my-app",
            secret,
          }}
        >
          <View>
            <Text>My App</Text>
            <TodoApp />
          </View>
        </JazzProvider>
      );
    }
    ```

    `ExpoAuthSecretStore` persists the secret in the device's secure storage. Or use `useLocalFirstAuth()` from `jazz-tools/expo` for a hook-based API.
  </Tab>

  <Tab value="TypeScript">
    ```ts title="app.ts"
    export async function createLocalFirstDb() {
      const secret = await BrowserAuthSecretStore.getOrCreateSecret({ appId: "my-app" });

      return createDb({
        appId: "my-app",
        secret,
      });
    }
    ```
  </Tab>
</Tabs>

The secret is generated on first load, persisted locally (`localStorage` in the browser, secure storage on Expo), and reused on every subsequent run — the same device keeps the same Jazz identity.

<Callout type="info">
  Using Jazz completely offline? Leave out `serverUrl` and everything above still works — writes
  stay on the device until a server is reachable.
</Callout>

<Callout type="warn">
  The local-first secret **is** the user's identity. If it's lost (e.g. cleared `localStorage`), the
  identity is gone and any data owned by that user becomes inaccessible. Plan for recovery with
  [backup and restore](/docs/auth/local-first-auth#backing-up-and-restoring-the-secret).
</Callout>

<Accordions type="single">
  <Accordion title="Zero config not working?">
    Some bundlers and runtimes cannot automatically resolve Jazz's Wasm and worker assets. See [Runtime source overrides](#runtime-source-overrides) below.
  </Accordion>
</Accordions>

Client config options [#client-config-options]

<CreateJazzClientReference />

For React Native and Expo clients, make sure `serverUrl` points to a host your runtime can reach. For example, the Android emulator often needs `10.0.2.2` instead of `localhost`.

Use `cookieSession` only when your app authenticates sync with an HttpOnly cookie instead of a JS-readable bearer token. In that setup, the cookie is the real transport credential, while `cookieSession` mirrors the current Jazz session into the client so local permission checks, `useSession()`, and `db.getAuthState()` know which user is active. You must choose between `cookieSession` and `secret`/`jwtToken`. Call `db.updateCookieSession(nextSession)` for cookie-backed session changes or recreate `JazzProvider` / `Db` on sign-in and sign-out.

Storage driver [#storage-driver]

Browser clients default to `driver: { type: "persistent" }`, which stores data locally for offline support. Set `driver: { type: "memory" }` to keep data in memory only. This option requires you to set a `serverUrl` since nothing is saved locally.

Browser storage eviction [#browser-storage-eviction]

`{ type: "persistent" }` writes to the [Origin Private File System (OPFS)](https://developer.mozilla.org/docs/Web/API/File_System_API/Origin_private_file_system). By default browsers treat this as **best-effort** storage — under disk pressure, or after long inactivity, the browser can clear it without warning. For a local-first app that means a returning user can find their identity (the local-first secret) and any data owned only by that user wiped.

The trade-offs are:

* **Best-effort (default).** No prompt, no friction. Acceptable when the server holds a copy of every row the user cares about, or when the app gracefully reseeds from sync. Anything that exists only on this device is at risk.
* **Persistent.** Storage is only cleared by an explicit user action (clearing site data, uninstalling). Required for true offline-first apps and for any data that doesn't round-trip through a server. Browsers gate this behind [`navigator.storage.persist()`](https://developer.mozilla.org/docs/Web/API/StorageManager/persist), which may show a prompt or grant silently based on engagement heuristics.

To request persistent storage, call it once after the user has signed in or interacted enough that a prompt makes sense:

```ts
if (navigator.storage?.persist) {
  const granted = await navigator.storage.persist();
  if (!granted) {
    // Fall back: rely on sync, warn the user, or retry later.
  }
}
```

Check `navigator.storage.persisted()` on subsequent loads to see whether the grant is still in effect. Pair this with the [backup and restore](/docs/auth/local-first-auth#backing-up-and-restoring-the-secret) flow so the local-first secret can be recovered if storage is ever cleared.

Dev plugins [#dev-plugins]

Jazz ships bundler plugins that remove boilerplate in development. They start a local Jazz dev server, watch `schema.ts` and `permissions.ts`, auto-push both on change, and inject the app ID and server URL as framework-appropriate env vars so `createJazzClient({ appId, serverUrl })` picks them up without any manual wiring.

<Tabs groupId="jazz-framework" items={["React (Vite)", "Vue (Vite)", "Svelte (Vite)", "SvelteKit", "Next.js", "Expo"]} persist updateAnchor>
  <Tab value="React (Vite)">
    ```ts title="vite.config.ts"
    import { defineConfig } from "vite";
    import react from "@vitejs/plugin-react";
    import { jazzPlugin } from "jazz-tools/dev/vite";

    export default defineConfig({
      plugins: [react(), jazzPlugin()],
    });
    ```

    Injects `VITE_JAZZ_APP_ID` and `VITE_JAZZ_SERVER_URL` into `import.meta.env`.
  </Tab>

  <Tab value="Vue (Vite)">
    ```ts title="vite.config.ts"
    import { defineConfig } from "vite";
    import vue from "@vitejs/plugin-vue";
    import { jazzPlugin } from "jazz-tools/dev/vite";

    export default defineConfig({
      plugins: [vue(), jazzPlugin()],
    });
    ```

    Injects `VITE_JAZZ_APP_ID` and `VITE_JAZZ_SERVER_URL` into `import.meta.env`.
  </Tab>

  <Tab value="Svelte (Vite)">
    ```ts title="vite.config.ts"
    import { defineConfig } from "vite";
    import { svelte } from "@sveltejs/vite-plugin-svelte";
    import { jazzSvelteKit } from "jazz-tools/dev/sveltekit";

    export default defineConfig({
      plugins: [svelte(), jazzSvelteKit()],
    });
    ```

    Injects `PUBLIC_JAZZ_APP_ID` and `PUBLIC_JAZZ_SERVER_URL`. Use `jazzSvelteKit` for both Vite+Svelte and SvelteKit projects.
  </Tab>

  <Tab value="SvelteKit">
    ```ts title="vite.config.ts"
    import { sveltekit } from "@sveltejs/kit/vite";
    import { jazzSvelteKit } from "jazz-tools/dev/sveltekit";
    import { defineConfig } from "vite";

    export default defineConfig({
      plugins: [sveltekit(), jazzSvelteKit()],
    });
    ```

    Defaults `schemaDir` to `src/lib/`. Injects `PUBLIC_JAZZ_APP_ID` and `PUBLIC_JAZZ_SERVER_URL`.
  </Tab>

  <Tab value="Next.js">
    ```ts title="next.config.ts"
    import { withJazz } from "jazz-tools/dev/next";

    export default withJazz({});
    ```

    Injects `NEXT_PUBLIC_JAZZ_APP_ID` and `NEXT_PUBLIC_JAZZ_SERVER_URL`. Only runs in the Next.js dev phase; production builds are untouched.
  </Tab>

  <Tab value="Expo">
    ```js title="metro.config.js"
    import { getDefaultConfig } from "expo/metro-config";
    import { withJazz } from "jazz-tools/dev/expo";

    const config = getDefaultConfig(__dirname);

    await withJazz(config, { schemaDir: __dirname });

    export default config;
    ```

    Injects `EXPO_PUBLIC_JAZZ_APP_ID` and `EXPO_PUBLIC_JAZZ_SERVER_URL` via `expoConfig.extra`. Skipped automatically when `NODE_ENV=production`.

    Expo and React Native apps still need the runtime polyfills from the quickstart. Import
    `jazz-tools/expo/polyfills` as the first line of your app entry point before any other Jazz
    import.
  </Tab>
</Tabs>

On first run the plugin generates an app ID, persists it to `.env`, and starts a local Jazz server under `node_modules/.cache/jazz-dev-server/`. Every subsequent run reuses both.

Env var names [#env-var-names]

Each plugin writes the same two values (`appId`, `serverUrl`) under the prefix its bundler uses to expose env vars to the client bundle. SvelteKit's `PUBLIC_*` prefix also covers Svelte+Vite via `jazzSvelteKit`. Secrets are always unprefixed — they're server-only: `JAZZ_ADMIN_SECRET` and `BACKEND_SECRET`.

| Bundler   | App ID                    | Server URL                    |
| --------- | ------------------------- | ----------------------------- |
| Vite      | `VITE_JAZZ_APP_ID`        | `VITE_JAZZ_SERVER_URL`        |
| Next.js   | `NEXT_PUBLIC_JAZZ_APP_ID` | `NEXT_PUBLIC_JAZZ_SERVER_URL` |
| SvelteKit | `PUBLIC_JAZZ_APP_ID`      | `PUBLIC_JAZZ_SERVER_URL`      |
| Expo      | `EXPO_PUBLIC_JAZZ_APP_ID` | `EXPO_PUBLIC_JAZZ_SERVER_URL` |

Jazz Cloud sync URL: `https://v2.sync.jazz.tools/`. For self-hosted deployments, point the server URL at your own host.

Plugin options [#plugin-options]

All plugins accept the same base options:

| Option        | Description                                                                                                                                                                       |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `server`      | `true` (default) starts an embedded local server. `false` disables the plugin. A URL string connects to an existing server. An object configures the embedded server (see below). |
| `schemaDir`   | Directory containing `schema.ts` and `permissions.ts`. Defaults to the project root, or `src/lib/` for SvelteKit.                                                                 |
| `appId`       | Override the app ID. Defaults to the value in `.env`, otherwise a generated UUID persisted on first run.                                                                          |
| `adminSecret` | Required when `server` is a URL. For the embedded server, used as its admin secret unless `server.adminSecret` is set; otherwise a random secret is generated.                    |

When `server` is an object, the embedded server accepts: `port` (default: random), `appId`, `adminSecret`, `dataDir` (default: `node_modules/.cache/jazz-dev-server`), `inMemory`, `allowLocalFirstAuth`, and `jwksUrl`. See [Server Setup](/docs/getting-started/server-setup) for the full semantics.

Connecting to an existing server [#connecting-to-an-existing-server]

Pass a URL to point the plugin at a server you already run — hosted cloud, staging, or a shared self-hosted instance. You must also provide `adminSecret` (or set `JAZZ_ADMIN_SECRET`) so the plugin can push schema and permissions.

```ts title="vite.config.ts"
jazzPlugin({
  server: "https://my-jazz-server.example.com",
  adminSecret: process.env.JAZZ_ADMIN_SECRET,
  appId: process.env.VITE_JAZZ_APP_ID,
});
```

What auto-pushes [#what-auto-pushes]

On startup and on every change to `schema.ts` or `permissions.ts`, the plugin publishes the current structural schema and permissions bundle to the server. Structural schema push works without an admin secret in development, so a bare `schema.ts` edit is enough to see clients pick up the new shape. Permission pushes use the resolved server admin secret (from `server.adminSecret`, root `adminSecret`, or a generated embedded-server secret).

<Callout type="warn">
  Auto-push covers the development loop. For production — or any change that needs a schema migration — run `pnpm dlx jazz-tools@alpha deploy <appId>` to publish the migration, the new schema, and the current permissions in one step. See [Migrations](/docs/schemas/migrations).
</Callout>

Runtime source overrides [#runtime-source-overrides]

Use `runtimeSources` when Jazz can't automatically find its internal assets. This usually only happens with non-standard bundler configurations or on edge runtimes like Cloudflare Workers.

| Field                       | Use it when                                                                 |
| --------------------------- | --------------------------------------------------------------------------- |
| `runtimeSources.baseUrl`    | Jazz runtime assets are served from a shared base path like `/assets/jazz/` |
| `runtimeSources.wasmUrl`    | The Wasm file has an explicit public URL                                    |
| `runtimeSources.workerUrl`  | The worker script has an explicit public URL                                |
| `runtimeSources.wasmSource` | Your runtime gives you Wasm bytes directly                                  |
| `runtimeSources.wasmModule` | Your runtime gives you a precompiled `WebAssembly.Module`                   |

Jazz resolves these in this order:

1. `runtimeSources.wasmModule`
2. `runtimeSources.wasmSource`
3. `runtimeSources.wasmUrl` / `runtimeSources.workerUrl`
4. `runtimeSources.baseUrl`
5. built-in zero-config fallback

Browser asset overrides [#browser-asset-overrides]

The config is the same across frameworks. Plain TypeScript uses `createDb(...)` directly.

<Tabs groupId="jazz-framework" items={["React", "Vue", "Svelte", "TypeScript"]} persist updateAnchor>
  <Tab value="React">
    ```tsx title="App.tsx"
    export function AppWithRuntimeSources() {
      return (
        <JazzProvider
          config={{
            appId: "my-app",
            serverUrl: "https://my-jazz-server.example.com",
            runtimeSources: {
              baseUrl: "/assets/jazz/",
            },
          }}
          fallback={<p>Loading...</p>}
        >
          {/* Your app's main component */}
          <TodoList />
        </JazzProvider>
      );
    }
    ```
  </Tab>

  <Tab value="Vue">
    ```vue title="App.vue"
    <script setup lang="ts">
    import { createJazzClient, JazzProvider } from "jazz-tools/vue";

    const client = createJazzClient({
      appId: "my-app",
      serverUrl: "https://my-jazz-server.example.com",
      runtimeSources: {
        baseUrl: "/assets/jazz/",
      },
    });
    </script>

    <template>
      <JazzProvider :client="client">
        <!-- ... -->
      </JazzProvider>
    </template>
    ```
  </Tab>

  <Tab value="Svelte">
    ```svelte title="App.svelte"
    <script lang="ts">
      import { JazzSvelteProvider, createJazzClient } from "jazz-tools/svelte";

      const client = createJazzClient({
        appId: "my-app",
        serverUrl: "https://my-jazz-server.example.com",
        runtimeSources: {
          baseUrl: "/assets/jazz/",
        },
      });
    </script>

    <JazzSvelteProvider {client}>
      {#snippet children()}
        <YourApp />
      {/snippet}
    </JazzSvelteProvider>
    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts title="app.ts"
    const db = await createDb({
      appId: "my-app",
      serverUrl: "https://my-jazz-server.example.com",
      runtimeSources: {
        wasmUrl: "/static/jazz/jazz_wasm_bg.wasm",
        workerUrl: "/static/jazz/worker/jazz-worker.js",
      },
    });
    ```
  </Tab>
</Tabs>

Use `baseUrl` when both assets live together under one public directory. Use explicit `wasmUrl` / `workerUrl` when your app serves them from different places.

Edge-style Wasm setup [#edge-style-wasm-setup]

Some runtimes (like Cloudflare Workers) don't load Jazz the same way a browser does. In those cases, provide the Wasm module or bytes directly.

```ts title="worker.ts"
const db = await createDb({
  appId: "my-app",
  runtimeSources: {
    wasmModule: jazzWasmModule,
  },
});
```

If your platform hands you raw bytes instead of a compiled module, use `runtimeSources.wasmSource`:

```ts title="worker.ts"
const db = await createDb({
  appId: "my-app",
  runtimeSources: {
    wasmSource: wasmBytes,
  },
});
```

See the [Cloudflare Wrangler example](https://github.com/garden-co/jazz2/tree/main/examples/cloudflare-worker-runtime-ts) for a complete `workerd` setup.


# Server Setup



import { Callout } from "fumadocs-ui/components/callout";

Hosted database server [#hosted-database-server]

Your app ID namespaces your data for storage and sync. Click below to generate one on the Jazz hosted cloud — you'll also get the secrets you need to deploy permissions later.

<GenerateAppId />

Self-hosted database server [#self-hosted-database-server]

```bash title="Terminal"
export JAZZ_APP_ID="replace-with-your-app-id"
export JAZZ_ADMIN_SECRET="replace-with-admin-secret"

npx jazz-tools@alpha server "$JAZZ_APP_ID" \
  --port 1625 \
  --data-dir ./data \
  --admin-secret "$JAZZ_ADMIN_SECRET"
```

`jazz-tools@alpha server <APP_ID>` currently supports:

| Option                              | Purpose                                                                                                                                                      | Environment variable          | Default                   |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------- | ------------------------- |
| `<APP_ID>` (positional)             | App namespace identifier (required)                                                                                                                          | -                             | -                         |
| `-p, --port <PORT>`                 | Listen port                                                                                                                                                  | -                             | `1625`                    |
| `-d, --data-dir <DATA_DIR>`         | Persistent storage directory                                                                                                                                 | -                             | `./data`                  |
| `--in-memory`                       | Use in-memory storage instead of files; data is lost when the process exits                                                                                  | -                             | off                       |
| `--jwks-url <JWKS_URL>`             | JWKS endpoint for external JWT validation                                                                                                                    | `JAZZ_JWKS_URL`               | unset                     |
| `--jwt-public-key <JWT_PUBLIC_KEY>` | Single JWK JSON object or PEM public key for external JWT validation. Accepts inline contents or a path to a key file.                                       | `JAZZ_JWT_PUBLIC_KEY`         | unset                     |
| `--allow-local-first-auth`          | Allow local-first auth (`Authorization: Bearer <self-signed Jazz JWT>`)                                                                                      | `JAZZ_ALLOW_LOCAL_FIRST_AUTH` | see `NODE_ENV` note below |
| `--backend-secret <BACKEND_SECRET>` | Enable backend session impersonation                                                                                                                         | `JAZZ_BACKEND_SECRET`         | unset                     |
| `--admin-secret <ADMIN_SECRET>`     | Required for `deploy`, `migrations push`, schema catalogue reads, and edge upstream sync. In development mode, structural schema auto-sync works without it. | `JAZZ_ADMIN_SECRET`           | unset                     |
| `--upstream-url <UPSTREAM_URL>`     | Run as an edge server connected to the upstream core server. Requires `--admin-secret`.                                                                      | `JAZZ_UPSTREAM_URL`           | unset                     |

Local-first auth is enabled by default in development and requires `--allow-local-first-auth` in production. External JWT auth requires either `--jwks-url` or `--jwt-public-key`, but not both.
Edge mode is enabled by `--upstream-url`; when set, provide `--admin-secret` or `JAZZ_ADMIN_SECRET`. The edge uses that admin secret for its upstream WebSocket connection.

There is no separate Jazz server CLI flag for cookie-based auth. The sync server validates bearer
JWTs, local-first bearer JWTs, or backend impersonation headers. If your app uses an HttpOnly cookie,
resolve that cookie in your own app server and either exchange it for a bearer JWT before creating
the Jazz client, or pass a mirrored `cookieSession` to the browser client while the cookie remains
the transport credential.

Backend context setup [#backend-context-setup]

Every backend needs an app ID, typed schema, and usually a permissions bundle. If the backend syncs through a Jazz server, also pass `serverUrl` and the relevant secrets. For auth modes and upgrade/link flow, see [Authentication](/docs/auth/authentication). For frontend/browser context creation, see [Client Setup](/docs/getting-started/client-setup).

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    const context = createJazzContext({
      appId,
      app: schemaApp,
      permissions,
      driver: { type: "persistent", dataPath: dbPath },
      serverUrl,
      backendSecret,
      adminSecret,
      jwksUrl,
      jwtPublicKey,
      allowLocalFirstAuth,
      env: "dev",
      userBranch: "main",
    });
    const db = context.asBackend();
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    let context = AppContext {
        app_id: AppId::from_name(&app_id),
        client_id: None,
        schema,
        server_url,
        data_dir: PathBuf::from(data_dir),
        storage: ClientStorage::Persistent,
        jwt_token: None,
        backend_secret: None,
        admin_secret: None,
        sync_tracer: None,
    };
    ```
  </Tab>
</Tabs>

Backend identity pattern [#backend-identity-pattern]

On the client side, queries automatically run as the logged-in user. On a server-connected backend, create the context once at startup, then choose whether work should run as the backend itself or as the caller.

* `context.asBackend()` returns a `Db` that uses `backendSecret` for sync/auth when this process talks to a Jazz server. Writes from this handle are authored as `jazz:system` unless you add explicit attribution.
* `await context.forRequest(req)` returns a `Db` that runs queries as the authenticated user who made the request, so permission policies apply to them rather than the process itself. Writes are also attributed to that user.
* `context.forSession(session)` is the same idea when you already have a resolved Jazz session.
* `context.withAttribution(principalId)` keeps backend permissions, but stamps new writes and updates as `principalId`.
* `context.withAttributionForSession(session)` and `await context.withAttributionForRequest(req)` derive that authorship from a resolved session or authenticated request without impersonating the user for permission checks.
* `context.db()` gives you a high-level `Db` using the context's configured runtime/auth. On server-connected backends, prefer `context.asBackend()` or `await context.forRequest(req)`; on embedded or local-only setups, `context.db()` is the right choice.

In TypeScript backends, create the context once with both `app` and `permissions`, then use `context.asBackend()` for server-owned work and `await context.forRequest(req)` when you need a bearer-JWT-scoped high-level `Db`.

`forRequest` reads authentication from standard HTTP headers, so `req` can be any object that exposes them: Express, Hono, Fastify, or a Web Fetch API `Request` all work. Configure `jwksUrl` or `jwtPublicKey` on `createJazzContext(...)` if those bearer tokens come from an external IdP, but do not set both at once. Without either one, backend `forRequest()` only accepts Jazz self-signed tokens; set `allowLocalFirstAuth: false` to disable those too.

For cookie-based auth on your own backend, resolve the cookie with your framework or auth provider
first, then call `context.forSession(session)` once you have a Jazz `Session`. `forRequest(...)`
does not parse application cookies by itself.

<Callout type="info">
  For embedded or local-only runtime setups where your backend is not connected to a server, use
  `context.db()` to get an unscoped, unauthenticated handle. This allows read/write access to the
  database directly, without permissions being evaluated.
</Callout>

Attribution without impersonation [#attribution-without-impersonation]

Use `withAttribution(...)`, `withAttributionForSession(...)`, or `await context.withAttributionForRequest(...)` when backend code should keep backend-level access but stamp row edit metadata with a user's identity. See [Sessions > Attribution without impersonation](/docs/auth/sessions#attribution-without-impersonation) for details and examples.

Per-request user-scoped client [#per-request-user-scoped-client]

Pass `req` to run queries as the authenticated user, with all permission policies applied.

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    export async function listTodosForRequester(req: Request, res: Response): Promise<void> {
      try {
        const requester = await context.forRequest(req, schemaApp);
        const rows = await requester.all(schemaApp.todos.where({ done: true }));
        res.json(rows);
      } catch {
        sendQueryError(res);
      }
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    pub async fn list_todos_for_request(
        headers: &HeaderMap,
        client: &JazzClient,
    ) -> Result<usize, StatusCode> {
        let user_client = client.for_session(requester_session_from_headers(headers)?);
        let query = QueryBuilder::new("todos").build();
        let rows = user_client
            .query(query, None)
            .await
            .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
        Ok(rows.len())
    }
    ```
  </Tab>
</Tabs>


# Client



import { Callout } from "fumadocs-ui/components/callout";
import { Accordion, Accordions } from "fumadocs-ui/components/accordion";
import QuickstartSchemaTypesSummary from "../../partials/schema-types-summary.mdx";

Create a project [#create-a-project]

Start with a fresh app. If you already have one, skip to [Install](#install).

<Tabs groupId="jazz-framework" items={["React", "Vue", "Svelte", "Expo", "TypeScript"]} persist updateAnchor>
  <Tab value="React">
    ```bash title="Terminal"
    pnpm create vite my-jazz-app --template react-ts
    cd my-jazz-app
    pnpm install
    ```
  </Tab>

  <Tab value="Vue">
    ```bash title="Terminal"
    pnpm create vite my-jazz-app --template vue-ts
    cd my-jazz-app
    pnpm install
    ```
  </Tab>

  <Tab value="Svelte">
    ```bash title="Terminal"
    pnpm create vite my-jazz-app --template svelte-ts
    cd my-jazz-app
    pnpm install
    ```

    <Callout type="info">
      This quickstart uses Svelte 5 features (`$state`, `$props`, `$derived`, snippets). Make sure you're on Svelte 5 or later.
    </Callout>
  </Tab>

  <Tab value="Expo">
    ```bash title="Terminal"
    npx create-expo-app my-jazz-app --template blank-typescript
    cd my-jazz-app
    ```
  </Tab>

  <Tab value="TypeScript">
    ```bash title="Terminal"
    mkdir my-jazz-app && cd my-jazz-app
    pnpm init
    pnpm add vite typescript
    ```
  </Tab>
</Tabs>

Install [#install]

<Tabs groupId="jazz-framework" items={["React", "Vue", "Svelte", "Expo", "TypeScript"]} persist updateAnchor>
  <Tab value="React">
    ```bash title="Terminal"
    pnpm add jazz-tools@alpha
    ```
  </Tab>

  <Tab value="Vue">
    ```bash title="Terminal"
    pnpm add jazz-tools@alpha
    ```
  </Tab>

  <Tab value="Svelte">
    ```bash title="Terminal"
    pnpm add jazz-tools@alpha
    ```
  </Tab>

  <Tab value="Expo">
    ```bash title="Terminal"
    pnpm add jazz-tools@alpha jazz-rn@alpha expo
    pnpm add -D @babel/plugin-transform-flow-strip-types
    ```

    `jazz-rn` is mandatory in Expo / React Native environments and must be installed alongside `jazz-tools`.

    <Accordions type="single">
      <Accordion title="Expo setup: entry point, Babel, and Metro config">
        Create `index.js` at the project root, and set `"main": "./index.js"` in your `package.json`. Import polyfills as the very first line, before any other code:

        ```js title="index.js"
        import "jazz-tools/expo/polyfills";
        import { registerRootComponent } from "expo";
        import { App } from "./App";
        registerRootComponent(App);
        ```

        Keep this import first in the app entry point. It installs the React Native `fetch`, stream, and
        related web API shims Jazz needs before the RN runtime is loaded.

        Update `babel.config.js` to enable the `import.meta` transform and strip Flow types:

        ```js title="babel.config.js"
        module.exports = function (api) {
          api.cache(true);
          return {
            presets: [["babel-preset-expo", { unstable_transformImportMeta: true }]],
            plugins: [
              ["@babel/plugin-transform-flow-strip-types", { allowDeclareFields: true }],
            ],
          };
        };
        ```

        Replace `metro.config.js` with `metro.config.mjs` so Metro can top-level `await` the Jazz dev server (it injects `EXPO_PUBLIC_JAZZ_*` env vars that Metro inlines into your bundle):

        ```js title="metro.config.mjs"
        import path from "node:path";
        import { fileURLToPath } from "node:url";
        import { createRequire } from "node:module";
        import { withJazz } from "jazz-tools/dev/expo";

        const require = createRequire(import.meta.url);
        const { getDefaultConfig } = require("expo/metro-config");

        const projectRoot = path.dirname(fileURLToPath(import.meta.url));

        const config = getDefaultConfig(projectRoot);

        // pnpm uses symlinks for hoisted packages
        config.resolver.unstable_enableSymlinks = true;

        // Start the Jazz dev server and inject EXPO_PUBLIC_JAZZ_* env vars for Metro to inline.
        await withJazz({}, { schemaDir: projectRoot });

        export default config;
        ```
      </Accordion>
    </Accordions>
  </Tab>

  <Tab value="TypeScript">
    ```bash title="Terminal"
    pnpm add jazz-tools@alpha
    ```
  </Tab>
</Tabs>

Get an app ID [#get-an-app-id]

Your app ID namespaces your data for storage and sync. Click below to generate one on [Jazz Cloud](https://v2.dashboard.jazz.tools) (sync URL: `https://v2.sync.jazz.tools/`) — you'll also get the secrets you need to deploy permissions later.

<GenerateAppId />

Define your schema [#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.

```ts title="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;
```

<QuickstartSchemaTypesSummary />

[Learn more about schemas, optional validation, and migrations](/docs/schemas/defining-tables).

Set up your app [#set-up-your-app]

Create the basic structure for your app with Jazz and a to-do list.

<Tabs groupId="jazz-framework" items={["React", "Vue", "Svelte", "Expo", "TypeScript"]} persist updateAnchor>
  <Tab value="React">
    ```tsx title="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>
      );
    }
    ```
  </Tab>

  <Tab value="Vue">
    ```vue title="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>

    ```
  </Tab>

  <Tab value="Svelte">
    ```svelte title="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>
    ```
  </Tab>

  <Tab value="Expo">
    ```tsx title="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>
      );
    }
    ```
  </Tab>

  <Tab value="TypeScript">
    ```html title="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:

    ```ts title="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
    ```
  </Tab>
</Tabs>

<Accordions type="single">
  <Accordion title="Client Config">
    `appId` identifies your app for storage and sync. Use the UUID you generated
    above — not a human-readable name — so your local data is already
    correctly namespaced when you add a server. For all client config options and runtime source
    overrides, see [Client Setup](/docs/getting-started/client-setup#client-config-options).
  </Accordion>
</Accordions>

Add a to-do [#add-a-to-do]

Use `db.insert` to create a new row.

<Tabs groupId="jazz-framework" items={["React", "Vue", "Svelte", "Expo", "TypeScript"]} persist updateAnchor>
  <Tab value="React">
    ```tsx title="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>
      );
    }

    ```
  </Tab>

  <Tab value="Vue">
    ```vue title="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>

    ```
  </Tab>

  <Tab value="Svelte">
    ```svelte title="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>

    ```
  </Tab>

  <Tab value="Expo">
    ```tsx title="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 handleAdd = () => {
        const trimmed = title.trim();
        if (!trimmed || !session?.user_id) return;
        db.insert(app.todos, { title: trimmed, done: false, owner_id: session.user_id });
        setTitle("");
      };

      return (
        <View style={{ flexDirection: "row", gap: 8 }}>
          <TextInput
            value={title}
            onChangeText={setTitle}
            placeholder="New todo"
            style={{ flex: 1, borderWidth: 1, borderColor: "#ccc", padding: 8, borderRadius: 8 }}
          />
          <Pressable onPress={handleAdd}>
            <Text>Add</Text>
          </Pressable>
        </View>
      );
    }

    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts title="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);
    ```
  </Tab>
</Tabs>

Display and edit a to-do [#display-and-edit-a-to-do]

Display a to-do with toggle and delete controls.

<Tabs groupId="jazz-framework" items={["React", "Vue", "Svelte", "Expo", "TypeScript"]} persist updateAnchor>
  <Tab value="React">
    ```tsx title="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>
      );
    }

    ```
  </Tab>

  <Tab value="Vue">
    ```vue title="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>

    ```
  </Tab>

  <Tab value="Svelte">
    ```svelte title="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}

    ```
  </Tab>

  <Tab value="Expo">
    ```tsx title="src/TodoItem.tsx"
    import { Pressable, 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 style={{ flexDirection: "row", alignItems: "center", gap: 8, paddingVertical: 4 }}>
          <Pressable onPress={() => db.update(app.todos, id, { done: !todo.done })}>
            <Text>{todo.done ? "☑" : "☐"}</Text>
          </Pressable>
          <Text style={{ flex: 1, textDecorationLine: todo.done ? "line-through" : "none" }}>
            {todo.title}
          </Text>
          <Pressable onPress={() => db.delete(app.todos, id)}>
            <Text>✕</Text>
          </Pressable>
        </View>
      );
    }

    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts title="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;
    }

    ```
  </Tab>
</Tabs>

Full mutation API: [Writing Data](/docs/writing/writing-data).

<Accordions type="single">
  <Accordion title="Local writes">
    Jazz persists all writes locally, so your data is saved and your UI updates
    immediately — even while offline.
  </Accordion>
</Accordions>

List to-dos [#list-to-dos]

Subscribe to a query to get real-time updates as data changes, regardless of where the change originates.

<Tabs groupId="jazz-framework" items={["React", "Vue", "Svelte", "Expo", "TypeScript"]} persist updateAnchor>
  <Tab value="React">
    ```tsx title="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 />
        </>
      );
    }

    ```
  </Tab>

  <Tab value="Vue">
    ```vue title="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>

    ```
  </Tab>

  <Tab value="Svelte">
    ```svelte title="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 />


    ```
  </Tab>

  <Tab value="Expo">
    ```tsx title="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>
      );
    }

    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts title="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).
  </Tab>
</Tabs>

Framework hooks return `undefined` while loading, then an array of matching rows. For filtering, sorting, and pagination, see [Queries](/docs/reading/queries).

Enable sync [#enable-sync]

So far, your to-do list only works locally. To sync across devices, you need permissions published to the server.

<Accordions type="single">
  <Accordion title="Didn't generate your app ID above? Generate it now!">
    <GenerateAppId />
  </Accordion>
</Accordions>

Add permissions [#add-permissions]

The server rejects all reads and writes unless you define [permissions](/docs/auth/permissions). For this quickstart, allow everything:

```ts title="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:

<DeployCommand />

Connect the client [#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:

<CloudConfig />

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](/docs/schemas/migrations) 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](/docs/getting-started/server-setup).

Next steps [#next-steps]

* [Authentication](/docs/auth/authentication) — local-first and external JWT auth
* [Permissions](/docs/auth/permissions) — row-level access policies
* [Queries](/docs/reading/queries) — filtering, sorting, pagination, and relations
* [Durability tiers](/docs/writing/writing-data#write-durability-tiers) — control when writes are confirmed

Example apps [#example-apps]

* [Todo app (React)](https://github.com/garden-co/jazz2/tree/main/examples/docs/todo-client-localfirst-react) — the app you just built, as a complete project
* [File upload (React)](https://github.com/garden-co/jazz2/tree/main/examples/file-upload-react) — image upload and rendering with Jazz
* [Wequencer (Svelte)](https://github.com/garden-co/jazz2/tree/main/examples/wequencer) — collaborative real-time music sequencer


# TypeScript Server



import { Callout } from "fumadocs-ui/components/callout";
import { Accordion, Accordions } from "fumadocs-ui/components/accordion";
import SchemaSetup from "../../partials/schema-setup.mdx";
import ServerAuthConfig from "../../partials/server-auth-config.mdx";
import QuickstartSchemaTypesSummary from "../../partials/schema-types-summary.mdx";

Create a project [#create-a-project]

Start with a fresh project. If you already have one, skip to [Install](#install).

```bash title="Terminal"
mkdir my-jazz-app && cd my-jazz-app
pnpm init
```

Install [#install]

`jazz-napi` is the native runtime for Jazz on Node.js. It bundles the query engine, storage, and sync layer as a Rust binary via NAPI. `jazz-tools` detects it automatically at runtime, but it must be listed as an explicit dependency.

```bash title="Terminal"
pnpm add jazz-tools@alpha jazz-napi@alpha hono @hono/node-server
pnpm add -D typescript tsx
```

Define your schema [#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.

```ts title="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(),
    owner_id: s.string(),
  }),
};

type AppSchema = s.Schema<typeof schema>;
export const app: s.App<AppSchema> = s.defineApp(schema);

```

<QuickstartSchemaTypesSummary />

Validate schema [#validate-schema]

<SchemaSetup />

[Learn more about schemas, optional validation, and migrations](/docs/schemas/defining-tables).

Add permissions [#add-permissions]

The server rejects all reads and writes unless you define [permissions](/docs/auth/permissions). For this quickstart, allow everything:

```ts title="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();
});
```

Set up your server [#set-up-your-server]

Generate an app ID:

```bash title="Terminal"
pnpm dlx jazz-tools@alpha create app
# outputs a UUID like: 019d0ba1-519a-7e01-b0eb-0059ee898e4d
```

Create `src/index.ts`. This quickstart puts everything in one file; a real app would split across multiple modules.

```ts title="src/index.ts"
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import { createJazzContext } from "jazz-tools/backend";
import { app as schemaApp } from "../schema.js";
import permissions from "../permissions.js";

const context = createJazzContext({
  appId: "todo-server-ts",
  app: schemaApp,
  permissions,
  driver: { type: "persistent", dataPath: "./data/jazz.db" },
  serverUrl: process.env.JAZZ_SERVER_URL,
  backendSecret: process.env.JAZZ_BACKEND_SECRET,
  jwksUrl: process.env.JAZZ_JWKS_URL,
  jwtPublicKey: process.env.JAZZ_JWT_PUBLIC_KEY,
  allowLocalFirstAuth: process.env.JAZZ_ALLOW_LOCAL_FIRST_AUTH !== "false",
});

const api = new Hono();
```

* `appId` identifies the app namespace for storage and sync.
* `app` is your typed schema export.
* `permissions` is the server-side policy bundle.
* `serverUrl` + `backendSecret` let request-scoped handles sync through a Jazz server.
* `jwksUrl` verifies external JWTs inside `await context.forRequest(req)`. Without it, the backend only accepts Jazz self-signed tokens unless you set `allowLocalFirstAuth: false`.
* `dataPath` controls where local server state persists.

Each route handler awaits `context.forRequest(c.req)` to get a database handle with [permissions](/docs/auth/permissions) scoped to the request.
For server-owned work, use `context.asBackend()`. For embedded or local-only setups without a
server, `context.db()` gives you an unscoped local `Db`. [Server Setup](/docs/getting-started/server-setup)
covers those patterns in more detail.

<Accordions type="single">
  <Accordion title="What does the driver do?">
    The `persistent` driver stores data on disk through the native `jazz-napi` runtime. In the
    current Node.js setup that means SQLite-backed local persistence. `dataPath` is the directory
    where that local database lives.

    There is also a `memory` driver, which does not persist data. To use it, set a `serverUrl` pointing to an upstream peer that *can* persist the data.
  </Accordion>
</Accordions>

Add a to-do [#add-a-to-do]

Add each of the following snippets to `src/index.ts`, below the setup code.

Use `db.insert` to create a new row.

```ts title="src/index.ts"
api.post("/api/todos", async (c) => {
  const db = await context.forRequest(c.req);
  const { title } = await c.req.json();

  const { value: todo } = db.insert(schemaApp.todos, {
    title,
    done: false,
    owner_id: "system",
  });

  return c.json(todo, 201);
});
```

Update and delete to-dos [#update-and-delete-to-dos]

Use `db.update` and `db.delete` to modify existing rows.

```ts title="src/index.ts"
api.patch("/api/todos/:id", async (c) => {
  const db = await context.forRequest(c.req);
  const { id } = c.req.param();
  const { done } = await c.req.json();
  db.update(schemaApp.todos, id, { done });
  return c.json({ ok: true });
});

api.delete("/api/todos/:id", async (c) => {
  const db = await context.forRequest(c.req);
  const { id } = c.req.param();
  db.delete(schemaApp.todos, id);
  return c.json({ ok: true });
});
```

Full mutation API: [Writing Data](/docs/writing/writing-data).

List to-dos [#list-to-dos]

Use `db.all` to query rows. The query builder supports filtering, sorting, and pagination.

```ts title="src/index.ts"
api.get("/api/todos", async (c) => {
  const db = await context.forRequest(c.req);
  const todos = await db.all(
    schemaApp.todos.where({ done: false }).orderBy("title", "asc").limit(100),
  );
  return c.json(todos);
});
```

Full query API: [Reading Data](/docs/reading/queries).

Run it [#run-it]

Start the server at the bottom of `src/index.ts`:

```ts title="src/index.ts"
serve({ fetch: api.fetch, port: 3000 }, (info) => {
  console.log(`Server running on http://localhost:${info.port}`);
});
```

```bash title="Terminal"
npx tsx src/index.ts
```

Try it out with a self-signed dev token:

```bash title="Terminal"
TOKEN=$(node -e 'const { mintLocalFirstToken } = require("jazz-napi"); const seed = Buffer.alloc(32, 7).toString("base64url"); console.log(mintLocalFirstToken(seed, "todo-server-ts", 3600));')

curl -X POST http://localhost:3000/api/todos \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"title": "Buy milk"}'

curl http://localhost:3000/api/todos \
  -H "Authorization: Bearer $TOKEN"

curl -X PATCH http://localhost:3000/api/todos/<id> \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"done": true}'

curl -X DELETE http://localhost:3000/api/todos/<id> \
  -H "Authorization: Bearer $TOKEN"
```

<Callout type="info">
  `forRequest` verifies the caller's bearer token inside the backend context. The token above is a
  Jazz self-signed dev token, which works because `allowLocalFirstAuth` defaults to `true`. If you
  want to accept external JWTs from your auth provider, also set `jwksUrl` so the backend can verify
  them via JWKS.
</Callout>

Authentication [#authentication]

<ServerAuthConfig />

Next steps [#next-steps]

* [Authentication](/docs/auth/authentication) — identity providers, JWKS, and session resolution
* [Permissions](/docs/auth/permissions) — row-level access policies
* [Queries](/docs/reading/queries) — filtering, sorting, pagination, and relations
* [Server Setup](/docs/getting-started/server-setup) — hosting, sync, and deployment


# Filters, Sorting & Pagination



import WhereOperatorsTable from "../../partials/where-operators-table.mdx";

Filters [#filters]

Use `where(...)` to filter rows. Pass the columns you want to match on — all conditions are combined with `AND`. OR filters are not supported in queries; use multiple queries if you need disjoint result sets.

<WhereOperatorsTable />

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    export async function readTodosWithFilters(db: Db) {
      return db.all(app.todos.where({ done: false, title: { contains: "docs" } }));
    }
    ```

    ```ts
    export async function readTodosWithWhereOperators(db: Db) {
      await db.all(app.todos.where({ done: false }));
      await db.all(app.todos.where({ title: { contains: "milk" } }));
      await db.all(app.todos.where({ projectId: { ne: EXAMPLE_PROJECT_ID } }));
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    pub async fn read_todos_with_filters(client: &JazzClient) -> jazz_tools::Result<usize> {
        let query = QueryBuilder::new("todos")
            .filter_eq("done", Value::Boolean(false))
            .build();

        let rows = client.query(query, None).await?;
        Ok(rows.len())
    }
    ```
  </Tab>
</Tabs>

Sorting [#sorting]

Sort results with `orderBy(...)`. Pass a column name and optionally `"asc"` or `"desc"`.

<Callout type="warn">
  Always sort **before** paginating. Unsorted items may not appear on the same page across all
  queries.
</Callout>

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    export async function readTodosSortedByTitle(db: Db) {
      return db.all(app.todos.where({ done: false }).orderBy("title", "asc"));
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    pub async fn read_todos_sorted(client: &JazzClient) -> jazz_tools::Result<usize> {
        let query = QueryBuilder::new("todos")
            .filter_eq("done", Value::Boolean(false))
            .order_by("title")
            .build();

        let rows = client.query(query, None).await?;
        Ok(rows.len())
    }
    ```
  </Tab>
</Tabs>

Pagination [#pagination]

`limit(n)` caps the number of rows returned; `offset(n)` skips that many rows. Combine with a deterministic `orderBy(...)` so page boundaries are stable.

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    export async function readTodoPage(db: Db, page: number, pageSize = 20) {
      const offset = Math.max(0, (page - 1) * pageSize);
      return db.all(
        app.todos.where({ done: false }).orderBy("title", "asc").limit(pageSize).offset(offset),
      );
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    pub async fn read_todo_page(
        client: &JazzClient,
        page_size: usize,
        page: usize,
    ) -> jazz_tools::Result<usize> {
        let offset = page.saturating_sub(1) * page_size;
        let query = QueryBuilder::new("todos")
            .filter_eq("done", Value::Boolean(false))
            .order_by("title")
            .limit(page_size)
            .offset(offset)
            .build();

        let rows = client.query(query, None).await?;
        Ok(rows.len())
    }
    ```
  </Tab>
</Tabs>


# Includes & Relations



import { Accordion, Accordions } from "fumadocs-ui/components/accordion";

Includes [#includes]

Use `include(...)` to load related rows as nested objects in a single query result. Pass `true` to resolve a relation, or a nested object to follow multi-hop references. In Rust, use `join()` with `on()` for equivalent results.

Including a relation adds the resolved object alongside the foreign-key column — it does not replace it. For example, `include({ project: true })` gives you both `projectId` (the FK string) and `project` (the resolved row).

```ts title="schema.ts"
projects: s.table({
  name: s.string(),
}),
todos: s.table({
  title: s.string(),
  done: s.boolean(),
  priority: s.int().optional(),
  description: s.string().optional(),
  owner_id: s.string().optional(),
  parentId: s.ref("todos").optional(),
  projectId: s.ref("projects").optional(),
}),
```

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    export async function readTodosWithIncludes(db: Db) {
      return db.all(
        app.todos.where({ done: false }).include({ project: true, parent: { project: true } }),
      );
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    pub async fn read_todos_with_project(client: &JazzClient) -> jazz_tools::Result<usize> {
        let query = QueryBuilder::new("todos")
            .filter_eq("done", Value::Boolean(false))
            .join("projects")
            .on("todos.project_id", "projects._id")
            .build();

        let rows = client.query(query, None).await?;
        Ok(rows.len())
    }
    ```
  </Tab>
</Tabs>

Reverse relations [#reverse-relations]

When table A has a ref column pointing to table B, Jazz auto-derives a reverse relation on B that returns all matching A rows as an array. The naming convention is `{sourceTable}Via{RelationName}` — for example, if `todos` has `projectId: s.ref("projects")`,
then `projects` gets a `todosViaProject` reverse relation.

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    export async function readProjectsWithTodos(db: Db) {
      return db.all(app.projects.include({ todosViaProject: app.todos.where({ done: false }) }));
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    pub fn build_projects_with_todos_query() -> jazz_tools::Query {
        QueryBuilder::new("projects")
            .with_array("todos_via_project", |sub| {
                sub.from("todos")
                    .correlate("project_id", "_id")
                    .filter_eq("done", Value::Boolean(false))
            })
            .build()
    }
    ```
  </Tab>
</Tabs>

You can chain `.where()`, `.select()`, `.orderBy()` and other query methods on the included reverse relation to filter or shape the nested results.

Select [#select]

Use `select(...)` to narrow a row to `id` plus the columns you pick. You can combine it with `include(...)`, and select within included rows too.

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    export async function readTodoTitlesWithSelectedProject(db: Db) {
      return db.all(
        app.todos
          .select("title")
          .where({ done: false })
          .include({ project: app.projects.select("name") }),
      );
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    pub async fn read_todo_titles(client: &JazzClient) -> jazz_tools::Result<usize> {
        let query = QueryBuilder::new("todos")
            .select(&["title", "done"])
            .build();

        let rows = client.query(query, None).await?;
        Ok(rows.len())
    }
    ```
  </Tab>
</Tabs>

Missing references [#missing-references]

Jazz is distributed and supports offline edits, so a referenced row won't always be available locally — it might not have synced yet, or another peer might have deleted it.

When you don't load a reference, the FK column contains its raw value (the UUID string) and the row always appears in results.

When you do load a reference (TypeScript `include`, Rust `join`/`on`), Jazz uses inner-join semantics. If the FK is set but the target row can't be resolved — not yet synced, deleted, or hidden by permissions — **the row is filtered out**. If the FK column is `null` (nullable and unset), the row is still returned with the included field set to `null`.

Requiring includes [#requiring-includes]

To also filter out rows where the FK is `null`, use `.requireIncludes()`. This drops rows where any included forward reference is missing — whether the FK is unset or the target can't be resolved.

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    export async function readTodosWithRequiredProject(db: Db) {
      return db.all(app.todos.where({ done: false }).include({ project: true }).requireIncludes());
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    pub fn build_todos_with_required_project() -> jazz_tools::Query {
        QueryBuilder::new("todos")
            .filter_eq("done", Value::Boolean(false))
            .with_array("project", |sub| {
                sub.from("projects")
                    .correlate("_id", "project_id")
                    .require_result()
            })
            .build()
    }
    ```
  </Tab>
</Tabs>

`requireIncludes()` only filters forward references (FK → row). Reverse relations
(`todosViaOwner`, etc.) are unaffected. When used inside a nested `include`, it applies at that
nesting level only.

Magic columns [#magic-columns]

Jazz exposes computed columns for permission introspection (`$canRead`, `$canEdit`, `$canDelete`) and edit metadata (`$createdBy`, `$createdAt`, `$updatedBy`, `$updatedAt`). See [Magic columns](/docs/reading/queries#magic-columns) for details and examples.

Recursive queries with gather and hopTo [#recursive-queries-with-gather-and-hopto]

If your data has self-referencing relations (e.g. a todo with a `parent` that points to another
todo), use `gather(...)` to walk the graph recursively and collect all reachable rows in a single
query.

`gather` takes three options:

| Option     | Description                                                                                                                                                 |
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `start`    | A `where` filter selecting the root rows to begin traversal from                                                                                            |
| `step`     | A callback that receives `{ current }` (a token for the row being visited) and returns a query with one `.hopTo()` call specifying which relation to follow |
| `maxDepth` | Maximum recursion depth (default: `10`)                                                                                                                     |

Inside the `step` callback, call `hopTo(relation)` to tell Jazz which reference to follow at each level.

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    export function buildTodoLineageQuery() {
      return app.todos.gather({
        start: { done: false },
        step: ({ current }) => app.todos.where({ parentId: current }).hopTo("parent"),
        maxDepth: 10,
      });
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    pub fn build_todo_lineage_query() -> jazz_tools::Query {
        QueryBuilder::new("todos")
            .filter_eq("done", Value::Boolean(false))
            .with_recursive(|r| {
                r.from("todos")
                    .correlate("id", "parent_id")
                    .hop("todos", "parent_id")
                    .max_depth(10)
            })
            .build()
    }
    ```
  </Tab>
</Tabs>

This starts from all incomplete todos, then follows each todo's `parentId` → `parent` relation up to 10 levels deep, returning the full lineage.


# Queries



import DurabilityTiersTable from "../../partials/durability-tiers-table.mdx";

One-shot queries [#one-shot-queries]

A one-shot query runs once against the database without subscribing to changes.

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    export async function readTodosOneshot(db: Db) {
      return db.all(app.todos.where({ done: false }));
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    pub async fn read_todos_oneshot(client: &JazzClient) -> jazz_tools::Result<usize> {
        let query = QueryBuilder::new("todos").build();
        let rows = client.query(query, None).await?;
        Ok(rows.len())
    }
    ```
  </Tab>
</Tabs>

Subscriptions [#subscriptions]

Subscribe to a query to receive updates whenever the underlying data changes.

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    export function subscribeTodos(db: Db, onCount: (count: number) => void) {
      return db.subscribeAll(app.todos.where({ done: false }), ({ all }) => onCount(all.length));
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    pub async fn subscribe_todos(
        client: &JazzClient,
    ) -> jazz_tools::Result<jazz_tools::SubscriptionStream> {
        let query = QueryBuilder::new("todos").build();
        client.subscribe(query).await
    }
    ```
  </Tab>
</Tabs>

Composing queries [#composing-queries]

Queries are immutable and chainable. Each method returns a new query, so you can store a base and reuse it for different views without side effects.

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    // Store a base query and reuse it for different views.
    const openTodos = app.todos.where({ done: false });

    const byNewest = openTodos.orderBy("id", "desc");
    const byTitle = openTodos.orderBy("title", "asc").limit(20);
    const urgent = openTodos.where({ title: { contains: "urgent" } });
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    pub fn composing_queries() {
        // Build two views from the same base conditions.
        let by_title = QueryBuilder::new("todos")
            .filter_eq("done", Value::Boolean(false))
            .order_by("title")
            .limit(20)
            .build();
        let by_newest = QueryBuilder::new("todos")
            .filter_eq("done", Value::Boolean(false))
            .order_by_desc("id")
            .build();

        let _ = (by_title, by_newest);
    }
    ```
  </Tab>
</Tabs>

See [Filters & Sorting](/docs/reading/filters-and-sorting) for the full list of `where` operators, `orderBy`, `limit`, and `offset`.

Read durability [#read-durability]

Queries and subscriptions return results as soon as they are available locally by default. Local reads are effectively instant, and remote updates stream in as they arrive, which is normally a good default. When you need a stronger guarantee for the first result, pass a `tier` option to fetch data from a different tier before returning.

<DurabilityTiersTable />

For background on how data flows between tiers, see [How Sync Works](/docs/concepts/how-sync-works#infrastructure-tiers).

Choosing a tier [#choosing-a-tier]

Pass a tier when you need the first result to be authoritative at a specific level — for example, when a user has just navigated to a page and a stale local snapshot would mislead them.

* `"local"` — Local storage only. The default on browsers and clients. Fastest, but reflects only what this device has already synced.
* `"edge"` — Wait for the nearest sync server to respond. The default on backends and servers. A good middle ground when you want confirmation that the query has fetched data from the network.
* `"global"` — Wait for the central server. Use when you need a globally-consistent snapshot, accepting the extra round-trip latency.

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    export async function readTodosAtEdgeDurability(db: Db) {
      return db.all(app.todos.where({ done: false }), { tier: "edge", localUpdates: "immediate" });
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    pub async fn read_todos_at_edge_durability(client: &JazzClient) -> jazz_tools::Result<usize> {
        let query = QueryBuilder::new("todos").build();
        let rows = client
            .query(query, Some(DurabilityTier::EdgeServer))
            .await?;
        Ok(rows.len())
    }
    ```
  </Tab>
</Tabs>

<Callout type="warn" title="Subscriptions only gate the first result">
  The read tier gates the **first** delivery of a subscription only. After the initial snapshot
  arrives at the requested tier, subsequent updates are delivered as they reach the local node,
  regardless of which tier they've propagated to. For example, a subscription with `tier: "global"`
  guarantees a globally-consistent initial snapshot, but later incremental updates from other
  clients may arrive through edge tiers before being globally available **even if the durability of
  the write is set to `"global"`**.
</Callout>

Local updates and propagation [#local-updates-and-propagation]

Two additional options control how the subscription interacts with local writes and upstream servers:

* `localUpdates` (`"immediate"` | `"deferred"`, default `"immediate"`) — With `"immediate"`, your own local writes appear in the subscription while it's still waiting for the tier to confirm the initial snapshot (only once the subscription has settled at least once). With `"deferred"`, all delivery is held until the tier confirms.
* `propagation` (`"full"` | `"local-only"`, default `"full"`) — With `"full"`, the subscription is sent to upstream servers, which push matching data back. With `"local-only"`, only local storage is queried and no server communication happens.

See [Durability Tiers](/docs/reference/durability-tiers) for the full reference, including which APIs accept these options and how they compose.

Magic columns [#magic-columns]

You can select and filter on Jazz's magic columns just like other columns. They are omitted from
`select("*")`, so opt in explicitly when you want them.

Permission introspection columns [#permission-introspection-columns]

* `$canRead` — whether the current session can read the row
* `$canEdit` — whether the current session can update the row
* `$canDelete` — whether the current session can delete the row
* Without a session, all three return `null`

Edit metadata columns [#edit-metadata-columns]

* `$createdBy` — the Jazz principal that created the row
* `$createdAt` — when the row was first created
* `$updatedBy` — the Jazz principal that last updated the row
* `$updatedAt` — when the row was last updated

These are useful for showing authorship, when a row last changed, or building "my created items"
views without storing duplicate ownership fields.

```ts
export async function readTodoEditMetadata(db: Db, currentUserId: string, updatedSinceMs: number) {
  return db.all(
    app.todos
      .where({
        $createdBy: currentUserId,
        $updatedAt: { gt: updatedSinceMs },
      })
      .select("title", "$createdBy", "$createdAt", "$updatedBy", "$updatedAt"),
  );
}
```

See [Permissions](/docs/auth/permissions) for policy examples using `$createdBy` and the other
magic columns.

Framework hooks [#framework-hooks]

Each framework has a reactive binding that re-renders when query results change. See [Framework Patterns](/docs/reference/framework-patterns#query-subscriptions) for side-by-side examples.

| Framework  | API                            | Notes                                               |
| ---------- | ------------------------------ | --------------------------------------------------- |
| React/Expo | `useAll(query)`                | Returns `T[] \| undefined`                          |
| Vue        | `useAll(query)`                | Returns `{ data, error, loading }` refs             |
| Svelte     | `new QuerySubscription(query)` | Exposes reactive `.current` / `.loading` / `.error` |

The undefined loading state [#the-undefined-loading-state]

`useAll` (its `data` ref in Vue) and `QuerySubscription` return `undefined` until the first server response arrives. After that, the value is an array — empty (`[]`) if no rows match, or populated with the requested data.

<Tabs groupId="jazz-framework" items={["React", "Vue", "Svelte"]} persist updateAnchor>
  <Tab value="React">
    ```tsx
    const allTodos = useAll(app.todos);
    // allTodos is undefined while connecting, [] when loaded but empty
    ```
  </Tab>

  <Tab value="Vue">
    ```vue
    <script setup lang="ts">
    import { useAll } from "jazz-tools/vue";
    import { app } from "../schema.js";

    const { data: todos } = useAll(app.todos);
    // undefined = not yet connected; [] = connected, no rows; [...] = rows present
    </script>

    <template>
      <p v-if="todos === undefined">Connecting…</p>
      <ul v-else>
        <li v-for="todo in todos" :key="todo.id">{{ todo.title }}</li>
      </ul>
    </template>
    ```
  </Tab>

  <Tab value="Svelte">
    ```svelte
    <script lang="ts">
      import { QuerySubscription } from 'jazz-tools/svelte';
      import { app } from '../schema.js';

      const todos = new QuerySubscription(app.todos);
      // .current: undefined = not yet connected; [] = connected, no rows; [...] = rows present
    </script>

    {#if todos.current === undefined}
      <p>Connecting…</p>
    {:else}
      <ul>
        {#each todos.current as todo}
          <li>{todo.title}</li>
        {/each}
      </ul>
    {/if}
    ```
  </Tab>
</Tabs>

<Callout type="info">
  You're unlikely to see `undefined` in practice unless you're awaiting a more [durable
  tier](#read-durability). Local storage reads are effectively instant and resolve to `[]` if no
  data exists yet.
</Callout>

Fine-grained updates [#fine-grained-updates]

Vue's `useAll` and Svelte's `QuerySubscription` reconcile new query results into the existing reactive array in place rather than swapping the reference. When an upstream change touches a single row, only that row's affected fields write into the reactive proxy, so `$effect` (Svelte) and `watch` / template bindings (Vue) only re-fire for components that depend on the fields that actually changed.

In practice this means:

* Adding, removing, or reordering rows updates the array structure only — untouched row objects keep their identity, so `{#each items as item (item.id)}` and `<TransitionGroup>` keyed renders are stable.
* Editing a single field on one row writes only that field — sibling rows do not re-render, and per-row components that read other fields stay quiet.
* Vue's `useAll` returns `{ data, error, loading }`, where `data` is a deep `Ref` (not a `shallowRef`), so per-field reactivity composes with the rest of your component tree without manual unwrapping.

React's `useAll` follows React's own re-render model and returns a fresh array each tick; the granularity benefit there comes from React's diffing, not from in-place reconciliation.

Conditional queries [#conditional-queries]

Pass `undefined` instead of a query to skip evaluation. This is useful when building dynamic queries.

<Tabs groupId="jazz-framework" items={["React", "Vue", "Svelte"]} persist updateAnchor>
  <Tab value="React">
    ```tsx
    const [filter, setFilter] = useState<string | null>(null);
    const filtered = useAll(filter ? app.todos.where({ title: { contains: filter } }) : undefined);
    ```
  </Tab>

  <Tab value="Vue">
    ```vue
    <script setup lang="ts">
    import { ref, computed } from "vue";
    import { useAll } from "jazz-tools/vue";
    import { app } from "../schema.js";

    const filter = ref<string | null>(null);
    const query = computed(() =>
      filter.value ? app.todos.where({ title: { contains: filter.value } }) : undefined,
    );
    const { data: filtered } = useAll(query);
    </script>

    <template>
      <input v-model="filter" placeholder="Filter by title" />
      <ul v-if="filtered">
        <li v-for="todo in filtered" :key="todo.id">{{ todo.title }}</li>
      </ul>
    </template>
    ```
  </Tab>

  <Tab value="Svelte">
    ```svelte
    <script lang="ts">
      import { QuerySubscription } from 'jazz-tools/svelte';
      import { app } from '../schema.js';

      let filter = $state<string | null>(null);

      const filtered = new QuerySubscription(
        () => filter ? app.todos.where({ title: { contains: filter } }) : undefined,
      );
    </script>

    <input bind:value={filter} placeholder="Filter by title" />
    {#if filtered.current}
      <ul>
        {#each filtered.current as todo}
          <li>{todo.title}</li>
        {/each}
      </ul>
    {/if}
    ```
  </Tab>
</Tabs>

React Suspense and Transitions [#react-suspense-and-transitions]

`useAllSuspense` is a React-specific variant that suspends instead of returning `undefined`, keeping the previous result visible while the next one loads.

```tsx title="App.tsx"
export function ConcurrentTodoList() {
  const db = useDb();
  const [title, setTitle] = useState("");
  const [filterTitle, setFilterTitle] = useState("");
  const [showDoneOnly, setShowDoneOnly] = useState(false);
  const [page, setPage] = useState(0);
  const [isPending, startTransition] = useTransition();
  const deferredFilterTitle = useDeferredValue(filterTitle);

  let query = app.todos
    .orderBy("id", "desc")
    .limit(25)
    .offset(page * 25);

  if (deferredFilterTitle.trim()) {
    query = query.where({ title: { contains: deferredFilterTitle.trim() } });
  }
  if (showDoneOnly) {
    query = query.where({ done: true });
  }

  const isLoading = isPending || deferredFilterTitle !== filterTitle;

  function updatePage(nextPage: number) {
    startTransition(() => {
      setPage(nextPage);
    });
  }

  function handleFilterChange(e: React.ChangeEvent<HTMLInputElement>) {
    setFilterTitle(e.target.value);
    startTransition(() => {
      setPage(0);
    });
  }

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const trimmedTitle = title.trim();

    if (!trimmedTitle) {
      return;
    }

    await db.insert(app.todos, { title: trimmedTitle, done: false });
    setTitle("");
  }

  return (
    <>
      <form onSubmit={(e) => void handleSubmit(e)}>
        <input
          type="text"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          placeholder="What needs to be done?"
          required
        />
        <button type="submit">Add</button>
      </form>

      <div>
        <input
          type="text"
          value={filterTitle}
          onChange={handleFilterChange}
          placeholder="Filter by title (contains)"
          aria-label="Filter by title"
        />
        <label>
          <input
            type="checkbox"
            checked={showDoneOnly}
            onChange={(e) => setShowDoneOnly(e.target.checked)}
          />
          Done only
        </label>
      </div>

      <Suspense fallback={<p>Loading todos...</p>}>
        <div style={{ opacity: isLoading ? 0.5 : 1, transition: "opacity 0.2s" }}>
          <ConcurrentTodoResults query={query} page={page} onPageChange={updatePage} />
        </div>
      </Suspense>
    </>
  );
}
```


# Durability Tiers



import DurabilityTiersTable from "../../partials/durability-tiers-table.mdx";

<DurabilityTiersTable />

For background on how data flows between tiers, see [How Sync Works](/docs/concepts/how-sync-works#infrastructure-tiers).

Write tiers [#write-tiers]

`insert`, `update`, and `delete` apply locally with no durability guarantee, and return a write handle. Call `.wait({ tier: ... })` on those handles when you need confirmation that the write reached a specific durability tier.

See [Writing Data](/docs/writing/writing-data#write-durability-tiers) for detailed guidance on which tier to use and code examples.

Read tiers [#read-tiers]

Read durability controls when the **first result** of a query or subscription is delivered.

| Option         | Values                              | Default                                                | What it does                                                                                                                                                                                                                                                                     |
| -------------- | ----------------------------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tier`         | `"local"` \| `"edge"` \| `"global"` | `"local"` (browser/client) / `"edge"` (backend/server) | How far the first result must come from before the query delivers.                                                                                                                                                                                                               |
| `localUpdates` | `"immediate"` \| `"deferred"`       | `"immediate"`                                          | With `"immediate"`, your own local writes appear in the subscription while still waiting for the tier to confirm the initial snapshot. This only takes effect after the subscription has settled at least once. With `"deferred"`, all delivery is held until the tier confirms. |
| `propagation`  | `"full"` \| `"local-only"`          | `"full"`                                               | With `"full"`, the subscription is sent to upstream servers, which push matching data back. With `"local-only"`, only local storage is queried — no server communication.                                                                                                        |

These options apply to:

* `db.all(query, options?)`
* `db.one(query, options?)`
* `db.subscribeAll(query, callback, options?)`
* `useAll(query, options?)` / `useAllSuspense(query, options?)` (React/Expo)
* `new QuerySubscription(query, options?)` (Svelte)
* `useAll(query, options?)` (Vue)

<Tabs groupId="jazz-framework" items={["React", "Vue", "Svelte", "Expo", "TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="React">
    ```tsx title="App.tsx"
    const todosAtEdgeDurability = useAll(app.todos, { tier: "edge" });
    ```
  </Tab>

  <Tab value="Vue">
    ```ts title="App.vue"
    export function subscribeTodosAtEdge(db: Db, onCount: (count: number) => void) {
      return db.subscribeAll(app.todos.where({ done: false }), ({ all }) => onCount(all.length), {
        tier: "edge",
        localUpdates: "immediate",
      });
    }
    ```
  </Tab>

  <Tab value="Svelte">
    ```svelte title="App.svelte"
    const todosAtEdgeDurability = new QuerySubscription(app.todos, { tier: 'edge' });
    ```
  </Tab>

  <Tab value="Expo">
    ```ts title="App.tsx"
    export function subscribeTodosAtEdge(db: Db, onCount: (count: number) => void) {
      return db.subscribeAll(app.todos.where({ done: false }), ({ all }) => onCount(all.length), {
        tier: "edge",
        localUpdates: "immediate",
      });
    }
    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts title="app.ts"
    export async function readTodosAtEdgeDurability(db: Db) {
      return db.all(app.todos.where({ done: false }), { tier: "edge", localUpdates: "immediate" });
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs title="main.rs"
    pub async fn read_todos_at_edge_durability(client: &JazzClient) -> jazz_tools::Result<usize> {
        let query = QueryBuilder::new("todos").build();
        let rows = client
            .query(query, Some(DurabilityTier::EdgeServer))
            .await?;
        Ok(rows.len())
    }
    ```
  </Tab>
</Tabs>

For most queries and subscriptions, omitting a tier is the right choice: Jazz delivers results from local storage immediately and streams in remote updates as they arrive. Reserve explicit tiers for cases where eventual consistency is not acceptable.

<Callout type="warn">
  The read tier gates the **first** delivery of a subscription only. After the initial snapshot
  arrives at the requested tier, subsequent updates are delivered as they reach the local node,
  regardless of which tier they've propagated to. For example, a subscription with `tier: "global"`
  guarantees a globally-consistent initial snapshot, but later incremental updates from other
  clients may arrive through edge tiers before being globally available **even if the durability of
  the write is set to 'global'**.
</Callout>


# Examples



The `examples/` folder in the Jazz monorepo contains runnable apps that each
highlight a different facet of Jazz — auth strategies, runtimes,
framework bindings, server-side usage, or a specific product pattern. Use this
table to jump straight to the example that covers the technique you need.

<Callout type="info">
  If you just want a bare-bones skeleton to build on, the [`starters/`](https://github.com/garden-co/jazz/tree/main/starters) folder ships minimal templates for Next.js, React, and SvelteKit, each in `localfirst`, `betterauth`, and `hybrid` flavours. You can also scaffold a new app from one of these templates with:

  ```bash
  npm create jazz
  ```

  Reach for the `examples/` apps below when you want to see a specific pattern worked through end-to-end.
</Callout>

At a glance [#at-a-glance]

| Example                                                                                                             | Stack                      | Uniquely demonstrates                                                                                                                                                                                                                                                                   |
| ------------------------------------------------------------------------------------------------------------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [auth-betterauth-chat](https://github.com/garden-co/jazz/tree/main/examples/auth-betterauth-chat)                   | Next.js + Better Auth      | Better Auth tables stored in Jazz via `jazz-tools/better-auth-adapter`; Better Auth's `jwt` plugin issues the ES256 tokens and JWKS that the sync server verifies; demonstrates exposing user attributes (e.g. the `admin` plugin's `role`) as JWT claims that drive Jazz authorization |
| [auth-simple-chat](https://github.com/garden-co/jazz/tree/main/examples/auth-simple-chat)                           | React + Vite + Express     | Bring-your-own JWT auth server: local Express issuing ES256 tokens, JWKS verified by the sync server, `session.claims.role` driving UI gating                                                                                                                                           |
| [auth-workos-chat](https://github.com/garden-co/jazz/tree/main/examples/auth-workos-chat)                           | React + Vite + WorkOS      | Hosted OAuth/SSO with no local auth server — sync server points at the WorkOS JWKS, `getAccessToken()` is passed straight to `JazzProvider`                                                                                                                                             |
| [chat-react](https://github.com/garden-co/jazz/tree/main/examples/chat-react)                                       | React + Vite               | Public vs private rooms with invite links via ephemeral `session.claims.join_code`; emoji reactions; collaborative drawing canvases; chunked file attachments                                                                                                                           |
| [cloudflare-worker-runtime-ts](https://github.com/garden-co/jazz/tree/main/examples/cloudflare-worker-runtime-ts)   | Cloudflare Workers         | Booting Jazz inside Workers by passing a precompiled `WebAssembly.Module` via `runtimeSources.wasmModule` — no browser asset URLs                                                                                                                                                       |
| [file-upload-react](https://github.com/garden-co/jazz/tree/main/examples/file-upload-react)                         | React + Vite               | Uploading and rendering images using the `files` / `file_parts` chunked-binary pattern                                                                                                                                                                                                  |
| [moon-lander-react](https://github.com/garden-co/jazz/tree/main/examples/moon-lander-react)                         | React + Vite               | Multiplayer game state — player positions, fuel deposits, inventory, and chat — synced through Jazz with no custom networking code                                                                                                                                                      |
| [nextjs-csr-ssr](https://github.com/garden-co/jazz/tree/main/examples/nextjs-csr-ssr)                               | Next.js (App Router)       | The canonical SSR/RSC story: a Server Component reading via `jazz-tools/backend` alongside a Client Component using `jazz-tools/react` hooks, wired by `withJazz`                                                                                                                       |
| [todo-client-localfirst-expo](https://github.com/garden-co/jazz/tree/main/examples/todo-client-localfirst-expo)     | Expo (React Native)        | The React Native / Expo bindings end-to-end, with native Fjall storage and `ExpoAuthSecretStore` (backed by `expo-secure-store`) for local-first identity                                                                                                                               |
| [todo-client-localfirst-react](https://github.com/garden-co/jazz/tree/main/examples/todo-client-localfirst-react)   | React + Vite               | Fully client-side React app with local-first (anonymous, locally generated secret) auth; `useAll` with composable `where()`, `useDb` writes, OPFS persistence, `attachDevTools`                                                                                                         |
| [todo-client-localfirst-svelte](https://github.com/garden-co/jazz/tree/main/examples/todo-client-localfirst-svelte) | Svelte 5 + Vite            | The Svelte bindings: `JazzSvelteProvider`, `QuerySubscription` live queries, `getDb` writes                                                                                                                                                                                             |
| [todo-client-localfirst-ts](https://github.com/garden-co/jazz/tree/main/examples/todo-client-localfirst-ts)         | Vanilla TypeScript + Vite  | The low-level client API with no framework bindings: `createDb`, `db.subscribeAll`, `db.onAuthChanged`, `BrowserAuthSecretStore`                                                                                                                                                        |
| [todo-server-rs](https://github.com/garden-co/jazz/tree/main/examples/todo-server-rs)                               | Rust (axum) + `jazz-tools` | Using Jazz directly from Rust as the embedded database for an axum REST + SSE service — no browser, no WASM, no Node bindings                                                                                                                                                           |
| [todo-server-ts](https://github.com/garden-co/jazz/tree/main/examples/todo-server-ts)                               | Node + Express             | Jazz as a server-side backend via `jazz-tools/backend` and NAPI Fjall storage; `context.forSession(session)` for per-user policy; SSE live snapshots; `wait({ tier })` durability                                                                                                       |
| [wequencer](https://github.com/garden-co/jazz/tree/main/examples/wequencer)                                         | React + Vite + Tone.js     | Clock-synchronised collaborative playback — `ClockSync` aligns peers to server epoch time, BPM nudged on drift; instrument samples stored as Jazz `files`                                                                                                                               |
| [world-tour](https://github.com/garden-co/jazz/tree/main/examples/world-tour)                                       | Vue + Vite + MapLibre GL   | The Vue bindings end-to-end in a non-trivial app (tour management with maps)                                                                                                                                                                                                            |

How the examples group together [#how-the-examples-group-together]

* **The `todo-client-localfirst-*` family** (`-react`, `-svelte`, `-ts`, `-expo`)
  all implement the same schema and feature set, so the diff between them is the
  framework binding. They cover the local-first baseline: anonymous identity,
  reactive queries, synchronous writes, OPFS persistence, optional server sync.
  See also [Framework Patterns](/docs/reference/framework-patterns).
* **The `auth-*-chat` family** (`auth-betterauth-chat`, `auth-simple-chat`,
  `auth-workos-chat`) all implement a role-gated chat against three different
  JWT-issuing auth setups, so the diff between them is the auth integration.
* **The server-side examples** (`todo-server-ts`, `todo-server-rs`,
  `nextjs-csr-ssr`, `cloudflare-worker-runtime-ts`) show Jazz used as the
  database from a server runtime — Node, Rust, Next.js App Router, and
  Cloudflare Workers respectively.
* **The product-shaped examples** (`chat-react`, `moon-lander-react`,
  `wequencer`, `world-tour`, `file-upload-react`) are full apps that lean on
  Jazz for a specific real-world pattern: real-time chat with invites and
  canvases, multiplayer game state, clock-synchronised collaborative playback,
  Vue + maps, and chunked binary uploads.


# Framework Patterns



Jazz provides framework-specific bindings for React/Expo, Vue, and Svelte. This page
is a side-by-side reference — see [Reading Data](/docs/reading/queries),
[Writing Data](/docs/writing/writing-data), and [Sessions](/docs/auth/sessions) for
full details.

API equivalents [#api-equivalents]

| Concept            | React / Expo                     | Vue                               | Svelte                          |
| ------------------ | -------------------------------- | --------------------------------- | ------------------------------- |
| Provider           | `<JazzProvider config={config}>` | `<JazzProvider :client="client">` | `<JazzSvelteProvider {client}>` |
| Query subscription | `useAll(query)`                  | `useAll(query)`                   | `new QuerySubscription(query)`  |
| DB access          | `useDb()`                        | `useDb()`                         | `getDb()`                       |
| Session            | `useSession()`                   | `useSession()`                    | `getSession()`                  |
| Client creation    | `createJazzClient(config)`       | `createJazzClient(config)`        | `createJazzClient(config)`      |

Provider setup [#provider-setup]

Wrap your app in a provider to make the database available to every component.

<Tabs groupId="jazz-framework" items={["React", "Vue", "Svelte"]} persist updateAnchor>
  <Tab value="React">
    ```tsx title="App.tsx"
    export function ProviderExample() {
      return (
        <JazzProvider
          config={{
            appId: "my-app",
          }}
          fallback={<p>Loading...</p>}
        >
          <YourApp />
        </JazzProvider>
      );
    }
    ```
  </Tab>

  <Tab value="Vue">
    ```vue title="App.vue"
    <script setup lang="ts">
    import { createJazzClient, JazzProvider } from "jazz-tools/vue";

    const client = createJazzClient({
      appId: "my-app",
    });
    </script>

    <template>
      <JazzProvider :client="client">
        <slot />
        <template #fallback>
          <p>Loading...</p>
        </template>
      </JazzProvider>
    </template>

    ```
  </Tab>

  <Tab value="Svelte">
    ```svelte title="App.svelte"
    <script lang="ts">
      import { createJazzClient, JazzSvelteProvider } from "jazz-tools/svelte";

      const client = createJazzClient({
        appId: "my-app",
      });
    </script>

    <JazzSvelteProvider {client}>
      {#snippet children({ db })}
        <YourApp />
      {/snippet}
      {#snippet fallback()}
        <p>Loading...</p>
      {/snippet}
    </JazzSvelteProvider>
    ```
  </Tab>
</Tabs>

Query subscriptions [#query-subscriptions]

Subscribe to query results reactively. See [Reading Data](/docs/reading/queries) for the full API.

<Tabs groupId="jazz-framework" items={["React", "Vue", "Svelte"]} persist updateAnchor>
  <Tab value="React">
    ```tsx title="App.tsx"
    export function LiveQueryExample() {
      const todos = useAll(app.todos.where({ done: false }));

      // undefined = not yet connected; [] = connected, no rows; [...] = rows present
      if (todos === undefined) return <p>Loading...</p>;

      return (
        <ul>
          {todos.map((todo) => (
            <li key={todo.id}>{todo.title}</li>
          ))}
        </ul>
      );
    }
    ```
  </Tab>

  <Tab value="Vue">
    ```vue title="App.vue"
    <script setup lang="ts">
    import { useAll } from "jazz-tools/vue";
    import { app } from "../schema.js";

    const { data: todos } = useAll(app.todos.where({ done: false }));
    </script>

    <template>
      <li v-for="todo in todos ?? []" :key="todo.id">{{ todo.title }}</li>
    </template>
    ```
  </Tab>

  <Tab value="Svelte">
    ```svelte title="App.svelte"
    <script lang="ts">
      import { QuerySubscription } from 'jazz-tools/svelte';
      import { app } from '../schema.js';

      const todos = new QuerySubscription(
        app.todos.where({ done: false }),
      );
    </script>

    {#each todos.current ?? [] as todo}
      <li>{todo.title}</li>
    {/each}
    ```
  </Tab>
</Tabs>

The `?? []` guard handles the `undefined` (not yet connected) case. See
[Reading Data: The undefined loading state](/docs/reading/queries#the-undefined-loading-state) for patterns that depend on this signal.

In Vue and Svelte, the binding reconciles updates into the existing reactive array in place, so only fields that actually changed trigger re-renders. See [Fine-grained updates](/docs/reading/queries#fine-grained-updates) for the full behaviour.

Accessing the database for writes [#accessing-the-database-for-writes]

Get a handle to the database for inserts, updates, and deletes. See [Writing Data](/docs/writing/writing-data) for the full API.

<Tabs groupId="jazz-framework" items={["React", "Vue", "Svelte"]} persist updateAnchor>
  <Tab value="React">
    ```tsx
    export function DbAccessExample() {
      // Must be called at component top level (rules of hooks)
      const db = useDb();

      async function addTodo(title: string) {
        await db.insert(app.todos, { title, done: false });
      }

      void addTodo;
      return null;
    }
    ```
  </Tab>

  <Tab value="Vue">
    ```ts
    // Must be called inside setup() or <script setup>
    const db = useDb();

    async function addTodo(title: string) {
      await db.insert(app.todos, { title, done: false });
    }
    ```
  </Tab>

  <Tab value="Svelte">
    ```ts
    // Callable anywhere — component, store, or utility module
    const db = getDb();

    async function addTodo(title: string) {
      await db.insert(app.todos, { title, done: false });
    }
    ```
  </Tab>
</Tabs>

Session/user identity [#sessionuser-identity]

Access the current user's session. See [Sessions](/docs/auth/sessions) for details on authentication modes and identity linking.

<Tabs groupId="jazz-framework" items={["React", "Vue", "Svelte"]} persist updateAnchor>
  <Tab value="React">
    ```tsx
    export function SessionExample() {
      const session = useSession(); // { user_id: string } | null

      void session;
      return null;
    }
    ```
  </Tab>

  <Tab value="Vue">
    ```ts
    const session = useSession(); // ComputedRef<Session | null>
    ```
  </Tab>

  <Tab value="Svelte">
    ```ts
    const session = getSession(); // { current: Session | null }
    ```
  </Tab>
</Tabs>


# Inspector



The [Jazz Inspector](https://v2.inspector.jazz.tools/) is a standalone client for inspecting a Jazz
app through any reachable sync server. It is not tied to Jazz Cloud: if you have a server URL, app
ID, and admin credential, you can connect to that app's sync server directly.

<Callout type="warn" title="Admin access only">
  The inspector uses `adminSecret`, not an end-user session token. On the sync connection, that
  authenticates the client as the backend, so normal permission policies are bypassed. Treat it like
  production infrastructure access and never expose it to end users.
</Callout>

Connecting [#connecting]

Open [v2.inspector.jazz.tools](https://v2.inspector.jazz.tools/) and enter:

* **Server URL** — base URL of the sync server
* **App ID** — Jazz app namespace to inspect
* **Admin secret** — app admin credential
* **Env** — logical environment such as `dev`, `staging`, or `prod`
* **Branch** — logical branch, usually `main`

After connecting, choose the published schema hash you want to inspect. The inspector stores the
connection locally so you can reopen it without re-entering everything each time.

Features [#features]

Data Explorer [#data-explorer]

Browse every table in the selected schema, inspect rows reactively, sort columns, and add typed
filters. Relation cells link to the referenced table, so you can follow relations without manually
rebuilding the query.

The explorer also supports admin writes: edit cells inline, open a row sidebar for full-row edits,
insert new rows, and delete existing ones.

Schema and permissions view [#schema-and-permissions-view]

Each table includes a schema view showing the stored structural schema for that table. In standalone
mode, the same page also shows the currently published sync-server permissions for that table, which
is useful when you want to confirm what policy bundle the server is enforcing.

Live Query [#live-query]

The Live Query tab shows active server-managed subscriptions grouped by query on the connected sync
server. You can inspect the table, propagation mode, branches, and compiled query JSON, then jump
straight into the matching table view in Data Explorer.

Schema switching [#schema-switching]

If an app has multiple published schema hashes, the inspector lets you switch between them without
reconnecting. This is useful when checking migrations, comparing stored shapes, or debugging data
that still lives on older schema branches.

DevTools panel [#devtools-panel]

The same inspector UI also exists as a browser DevTools panel for local debugging. The hosted
client at [v2.inspector.jazz.tools](https://v2.inspector.jazz.tools/) is the easiest way to inspect
a remote or self-hosted sync server directly.


# Advanced Internals



This page describes Jazz's internal architecture. You do not need any of this to use Jazz, but it
is helpful if you are debugging, reasoning about performance, or understanding why the system
behaves the way it does.

Data model [#data-model]

Raw tables plus engine-managed fields [#raw-tables-plus-engine-managed-fields]

Jazz stays table-first all the way down.

Your schema defines normal application columns such as `title`, `done`, and `projectId`. Under the
hood, the engine also tracks a small set of reserved `_jazz_*` columns that explain how each row
behaves over time, such as:

* a stable row id
* the branch view the row belongs to
* the current row-version id
* ancestry pointers to earlier row versions
* visibility state
* confirmed durability tier
* delete markers
* engine/user metadata

The important physical fact is that Jazz stores one flat `row_format` row containing both the user
columns and the reserved engine columns. Some Rust types still expose the user-column slice
separately for convenience, but that is just a decoded view rather than a different storage model.

Visible entries and row histories [#visible-entries-and-row-histories]

Each logical row has two important storage shapes behind it:

* a **visible entry** for current reads
* a **row history** containing every stored row version

Ordinary queries read the visible entry first. History is what makes replay, reconnect, branching,
and future historical queries possible.

The simplest picture is:

```text
todos
  visible: (branch, row_id) -> current winner for that branch view
  history: (row_id, version_id) -> row versions over time
```

This is why Jazz can feel like "just tables" at the app layer while still keeping rich local-first
history underneath.

Both storage shapes are flat rows:

* history rows use reserved `_jazz_*` columns plus the user columns
* visible rows use a slightly larger `_jazz_*` prefix plus the same user columns

Indexing [#indexing]

By default, **every column on every table is indexed**. This keeps `where`, `orderBy`, and
join lookups fast on any column without you having to think about it, at the cost of one
index entry per column per row. The `_id` index for each table doubles as the authoritative
row manifest, so discovering all rows in a table is just an `_id` index scan.

Sometimes you'll want to optimise for write performance or storage cost instead. Use
`indexOnly()` to specify which columns in the table need indexes; only the columns you
specify will be indexed.

<Callout type="warn" title="Avoid overuse">
  `table.indexOnly(["title", "done"])` does **not** mean "add indexes on `title` and `done`". It
  means "**drop** the indexes on every other column on this table". Read it as *"index only these"*.
  `indexOnly` is an optimisation that you're unlikely to need to begin with, and if not used
  correctly, can significantly impact the performance of your app, especially on reads.
</Callout>

```ts title="schema.ts"
import { schema as s } from "jazz-tools";

const schema = {
  todos: s
    .table({
      title: s.string(),
      done: s.boolean(),
      description: s.string().optional(),
      activityLog: s.string().optional(),
    })
    .indexOnly(["title", "done"]),
};
```

In this example, `where({ title: ... })` and `where({ done: ... })` are still index-backed.
A query that filters on `description` or `activityLog` works, but falls back to scanning
every row in the table.

Use `indexOnly` when you're seeing slow writes and:

* The column holds large or rarely-queried data (long text, serialised metadata, audit logs).
* The table is write-heavy and the per-column index cost is showing up in your performance data.

Deletion [#deletion]

The user-facing `delete` API performs a **soft delete**. The row is preserved in
history, but it disappears from ordinary live queries.

Internally, the current visible state leaves the live `_id` index and can still be addressed
through deleted-row paths such as `_id_deleted`.

A **hard delete** mode also exists at the storage layer, but it is not currently exposed as the
normal app-facing API.

Row history and truncation [#row-history-and-truncation]

Row history is append-only by default. Every write creates a new row version and keeps older
versions available for replay and reconciliation.

There is a low-level truncation path that can drop older ancestry while preserving the current
visible state, but it is not a normal application-facing feature yet.

Monotonic direct-write ordering [#monotonic-direct-write-ordering]

Each runtime instance maintains a small monotonic clock for direct writes. New row versions created
by that runtime get strictly increasing local timestamps, which makes deterministic last-writer-wins
ordering straightforward within a single device or process.

Merge strategies [#merge-strategies]

By default, Jazz adopts a per-column last-writer-wins strategy to resolve concurrent edits. This means that if
two clients update the same column of the same row simultaneously, the one with the later timestamp overwrites the earlier one.

This is a sensible default, but can cause some unexpected behaviour with certain types of data. For example,
imagine a voting app. Alice and Bob both read the current `voteCount` value as 2. They each want to increment
the value. If they simultaneously write 3, then the new value will be 3, even though they actually each wanted
to increment by one.

Use `.merge("counter")` on the column to keep deltas additive instead:

```ts title="schema.ts"
import { schema as s } from "jazz-tools";

const schema = {
  votes: s.table({
    proposal: s.string(),
    voteCount: s.int().merge("counter"),
  }),
};
```

With `merge("counter")`, every `update(...)` on the column is recorded as a delta from the
value the writer was looking at. When concurrent edits meet, the deltas are summed: Alice's
`+1` and Bob's `+1` both apply, and the `voteCount` correctly lands on `4`.

Counter merges are useful for things like:

* A shared score or vote tally.
* An inventory level being incremented and decremented from multiple devices.
* Any other counter where you care about preserving every increment rather than which device
  wrote last.

<Callout type="warn">
  `merge("counter")` is only valid on **non-nullable integer columns**. Calling it on a string, on a
  nullable integer (`s.int().optional()`), or on any other type throws at schema construction time.
  `"counter"` is currently the only non-default merge strategy, but more merge strategies are
  planned in future.
</Callout>

Cold start [#cold-start]

On startup, Jazz loads indices first rather than eagerly decoding every row. Row content is then
loaded on demand as queries reference it.

The result is that cold-start cost is much closer to "index size" than "total stored data size."

Browser architecture [#browser-architecture]

Dual-runtime model [#dual-runtime-model]

In the browser, Jazz runs two runtime instances:

* **Main thread** — an in-memory runtime that serves UI-facing reads and writes immediately
* **Dedicated worker** — a persistent runtime backed by OPFS (Origin Private File System) that owns
  durable storage and upstream server sync

The main thread treats the worker as its upstream peer. Writes apply to the in-memory runtime
immediately, then sync to the worker via `postMessage`. The worker persists them to OPFS and
forwards them to the next sync tier. Incoming server updates flow the reverse path: server ->
worker -> main thread -> your UI callback.

```text
Main thread (in-memory runtime)
  ↕ postMessage
Dedicated worker (persistent OPFS runtime)
  ↕ HTTP/SSE
Edge/global server runtime
```

With `driver: { type: "memory" }`, the worker and OPFS are skipped entirely, and the main-thread
runtime syncs directly with the server.

OPFS crash safety [#opfs-crash-safety]

The OPFS storage engine uses checkpoint-based persistence with two superblock slots (A/B). Each
checkpoint writes dirty pages, flushes, then swaps the active superblock. On reopen after a crash
or torn write, the highest valid superblock generation wins, recovering to the last complete
checkpoint.

Tab coordination [#tab-coordination]

Jazz uses the Web Locks API (`navigator.locks`) to elect a single tab as the storage leader; other
tabs route through it via `BroadcastChannel`. When a leader tab closes, the browser releases the
lock and the first follower to acquire it becomes the new leader.

React Native [#react-native]

React Native uses a separate native runtime adapter with no web worker or OPFS path. It still uses
the same table-first runtime model, but local persistence is provided by the native embedded backend
rather than by browser APIs.

Query engine [#query-engine]

Execution pipeline [#execution-pipeline]

Queries compile into a graph of processing nodes:

```text
IndexScan → [Union] → Materialize → [PolicyFilter]
  → [ArraySubquery] → [Filter] → [Sort] → [LimitOffset]
  → [Project] → Output
```

Nodes in brackets are only present when the query requires them. The graph processes deltas
incrementally, which means that when data changes, only dirty nodes re-evaluate. That is what makes
live subscriptions efficient: a single row change does not require re-running the whole query.

Materialization [#materialization]

`Materialize` is where candidate row ids turn back into rows.

It typically:

1. looks up the visible entry for the relevant branch
2. falls back to row history only when the query needs an older settled winner
3. decodes or reprojects the flat row, dropping the reserved engine columns before returning app-facing values
4. emits row-level deltas to the downstream graph

This is why the visible region matters so much: most current reads never need to reconstruct a row
from full history.

One-shot queries [#one-shot-queries]

`db.all()` and `db.one()` are implemented as "create a temporary subscription, wait for the first
durability-qualified snapshot, then auto-unsubscribe." They share the same reactive machinery as
live subscriptions, which is why they participate in durability-tier gating and lens transforms.

Include performance [#include-performance]

Each outer row in an `include()` / array subquery gets its own compiled sub-graph. With 1,000 outer
rows, that is 1,000 sub-graphs. Any change to the inner table re-settles all instances. This is
correct and simple, but worth remembering when including across very large result sets.

Sync protocol [#sync-protocol]

Transport [#transport]

Jazz uses a single WebSocket sync transport plus a small HTTP surface for health and admin reads.

* **Sync**: `GET /apps/<appId>/ws` upgrades to a WebSocket carrying the typed sync protocol.
* **Admin**: `GET /apps/<appId>/schemas`, `GET /apps/<appId>/schema/:hash`, and
  `POST /apps/<appId>/admin/...` handle schema and permissions publication/read flows.
* **Health**: `GET /health`.

Client identity [#client-identity]

Each client generates and persists a stable `ClientId`. On reconnect with the same id, the server
can treat it as the same logical peer rather than as a brand-new client with no prior state.

Reconnection [#reconnection]

The TypeScript client uses exponential backoff with jitter. On reconnect, active query
subscriptions are replayed as anti-entropy: the server re-evaluates them and resends any rows the
client still needs.

Trust model and client roles [#trust-model-and-client-roles]

Sync is asymmetric:

* **Upward** (client -> server): row versions, row-state changes, and catalogue updates are pushed
  toward trusted servers
* **Downward** (server -> client): only rows matching the client's active query subscriptions are
  sent

Each client connection has a **role** that determines how writes are routed:

| Role    | Write handling                                                    |
| ------- | ----------------------------------------------------------------- |
| `User`  | Writes queued for permission policy evaluation before apply       |
| `Admin` | Writes applied directly, no permission check                      |
| `Peer`  | Writes applied directly, used for trusted runtime-to-runtime sync |

Frontend clients usually authenticate as `User`. Backend services with a backend secret authenticate
as `Admin` or `Peer`.

Schema evolution [#schema-evolution]

Lenses [#lenses]

Migrations in Jazz produce **lenses** — bidirectional transformations between schema versions. When
`jazz-tools migrations create` diffs two schemas, it generates a lens with declarative operations
such as adding, removing, or renaming columns and tables.

At query time, Jazz can use lens paths to read older stored data through the current schema. At
write time, updates to older rows are written back into the current schema branch via copy-on-write.

Catalogue sync [#catalogue-sync]

Schemas and lenses travel through a separate catalogue lane, not through the normal user-row
history path. Clients publish catalogue entries, servers discover them lazily, and query execution
uses that catalogue state to resolve schema context on demand.

Durability signals [#durability-signals]

Jazz separates two durability questions:

| Signal                  | Gates                                | Question it answers                        |
| ----------------------- | ------------------------------------ | ------------------------------------------ |
| `QuerySettled`          | First read delivery                  | "Has the query result settled at tier T?"  |
| Write tier confirmation | `.wait({ tier })` promise completion | "Has this write been confirmed at tier T?" |

Both use the same tier lattice (`local` \< `edge` \< `global`), but they answer different
questions. A query's first callback is held until `QuerySettled` reaches the requested tier. A
`.wait({ tier })` promise resolves when the requested tier confirms the write.

<Callout type="warn">
  The read durability tier only gates the **first** delivery of a subscription. After the initial
  snapshot arrives at the requested tier, later updates are delivered as they reach the local node.
  That means `tier: "global"` gives you a globally settled first snapshot, not globally gated
  delivery forever after.
</Callout>


# Local-first auth internals



This page explains how [local-first auth](/docs/auth/local-first-auth) works under the hood. You do
not need any of this to use it. Read this if you are debugging, building a custom client, or
reasoning about what the design actually guarantees.

The core idea [#the-core-idea]

An account, in local-first auth, is a secret: a single 32-byte seed.

There is no registry mapping "user X → public key Y" sitting on a server somewhere. The seed
deterministically produces a keypair, and the user ID is a deterministic function of the resulting
public key, so holding the secret is the same thing as being the account: it lets you regenerate
the keypair on demand and self-certify as that identity. Signing in is reconstructing the keypair
from the stored secret; signing up is generating a new one.

This is why the design doesn't need a server-side user registry: the public key embedded in a token, paired with its signature, is everything the server needs to confirm the claimed account. Verification reuses the JWT-checking path the server already runs for external-auth tokens — only the source of the signing key differs (embedded in the token for local-first, fetched from a JWKS endpoint for external auth).

Identity derivation [#identity-derivation]

The account is built from a single 32-byte secret the client stores locally.

1. The 32-byte **seed** is hashed with SHA-512 to produce an Ed25519 **signing key**.
2. The corresponding **public key** (32 bytes) is extracted from the signing key.
3. A deterministic **user ID** is derived from the public key using UUIDv5 with the namespace
   `jazz-auth-key-v1`.

Every step is deterministic, so the same seed always produces the same user ID, on any device, at
any time. Backing up the seed is backing up the identity.

The self-signed JWT [#the-self-signed-jwt]

When the client talks to a server, it mints a JWT and signs it with the Ed25519 key derived above.
The key claims are:

| Claim          | Value                    | Purpose                                              |
| -------------- | ------------------------ | ---------------------------------------------------- |
| `alg`          | `"EdDSA"`                | Ed25519 signature algorithm                          |
| `iss`          | `"urn:jazz:local-first"` | Marks the token as local-first (not provider-issued) |
| `sub`          | User ID (UUIDv5)         | The claimed account ID                               |
| `jazz_pub_key` | Public key (base64url)   | Embedded so the server can verify without a lookup   |
| `aud`          | App ID                   | Scopes the token to a specific app                   |

The public key travels inside the JWT and the whole JWT is signed with the matching private key, so the server doesn't need a separate key-distribution step or a shared secret with the client.

Server verification [#server-verification]

When the server receives a local-first JWT (as identified by the `iss` field), it:

1. Reads `jazz_pub_key` from the token
2. Uses it to verify the signature
3. Recomputes the UUIDv5 from that public key (same namespace, same algorithm)
4. Confirms the recomputed ID matches `sub`

A token where `sub` and `jazz_pub_key` disagree is rejected outright, because either would require
forging the other. A token where the signature does not verify is rejected because the holder did
not prove possession of the private key.

Upgrading to a provider account [#upgrading-to-a-provider-account]

When a local-first account upgrades to a real account via an external provider (BetterAuth,
WorkOS, etc.), the user should keep the same Jazz account ID so their existing data carries over
unchanged. This works because the account ID is a deterministic function of the keypair: if the
client can prove (to the provider) that it holds the key behind account ID X, the provider can
safely record X as the Jazz ID of the new user. Any JWT the provider issues afterwards uses X as
its subject, and the user's existing data is already owned by X.

The proof takes the form of a **proof token** — a short-lived JWT signed by the same key the
client uses for self-signed auth. Structurally it is the same as a local-first JWT, but scoped to
a specific sign-up flow:

* `aud` is an application-defined string like `"my-app-signup"` (the client and server must agree).
* `exp` is short — 60 seconds is usually enough for a sign-up round-trip.
* `sub` and `jazz_pub_key` still pin the token to a specific account.

The provider's endpoint verifies the proof token the same way any Jazz server verifies a
local-first JWT, then records the proven Jazz user ID against the newly created provider account.

<Sequence
  eyebrow="Local-first sign-up"
  description="Converting a local-first identity into a provider account."
  participants={[
  { id: "client", label: "Client" },
  { id: "provider", label: "Provider server" },
]}
  steps={[
  { kind: "note", over: "client", text: "User starts with local-first auth" },
  { kind: "message", from: "client", to: "client", text: "db.getLocalFirstIdentityProof()" },
  { kind: "message", from: "client", to: "provider", text: "Sign up (email + proofToken)" },
  {
    kind: "message",
    from: "provider",
    to: "provider",
    text: "Verify proof, create user with same Jazz ID",
  },
  { kind: "message", from: "provider", to: "client", text: "Session + JWT", line: "dashed" },
  { kind: "note", over: "client", text: "Same Jazz ID, now with a provider account" },
]}
/>

See [Signing up with BetterAuth](/docs/auth/local-first-auth#signing-up-with-betterauth) for the
practical, BetterAuth-specific flow.


# MCP Server



The `jazz-tools` package ships with a built-in [MCP](https://modelcontextprotocol.io) server that
exposes the Jazz docs as tools any MCP-compatible AI assistant can call during a conversation.

The docs served are always matched to the version of `jazz-tools` you install, so your
assistant is reading documentation for the exact API you have available. To use a different
version's docs, specify a version tag instead (e.g. `pnpm dlx jazz-tools@2.0.0 mcp`).

<Callout type="warn">
  Node.js 22.12 or later is required. Older runtimes (and any runtime without `node:sqlite`) fall
  back to a basic keyword search that is deprecated and will be removed in a future release.
</Callout>

Installation [#installation]

<Tabs groupId="jazz-mcp-client" items={["Claude Code", "Gemini CLI", "Codex", "OpenCode"]} persist updateAnchor>
  <Tab value="Claude Code">
    ```bash title="Terminal"
    claude mcp add jazz-docs -- npx jazz-tools@alpha mcp
    ```
  </Tab>

  <Tab value="Gemini CLI">
    ```bash title="Terminal"
    gemini mcp add jazz-docs npx jazz-tools@alpha mcp
    ```
  </Tab>

  <Tab value="Codex">
    ```bash title="Terminal"
    codex mcp add jazz-docs -- npx jazz-tools@alpha mcp
    ```
  </Tab>

  <Tab value="OpenCode">
    ```bash title="Terminal"
    opencode mcp add jazz-docs -- npx jazz-tools@alpha mcp
    ```
  </Tab>
</Tabs>

Restart your assistant after running the command. The server starts automatically on launch.

<Callout type="info" title="Tip">
  To ensure your assistant uses the Jazz docs proactively, add a line to your project context file (e.g. `CLAUDE.md`):

  `Jazz docs are available via the jazz-docs MCP server. Use search_docs and get_doc to look up APIs before answering Jazz questions.`
</Callout>

Tools [#tools]

list_pages [#list_pages]

Returns a list of all documentation pages with their title, slug, and description.
Useful for orientation — ask your assistant to list pages when you want an overview of what's covered.

search_docs [#search_docs]

Full-text search across all pages and sections.

```ts
search_docs(query: string, limit?: number)
```

Accepts plain keywords or FTS5 query syntax (`AND`, `OR`, `"exact phrase"`, `prefix*`).
Results are ranked by relevance and include a snippet of the matching section.

get_doc [#get_doc]

Retrieves the full content of a single page by slug.

```ts
get_doc(slug: string)
```

Slugs match the URL path under `/docs/` — for example `schemas/defining-tables`, `reading/queries`, or
`install/client`. Use `list_pages` to discover available slugs.


# WHERE Operators



import WhereOperatorsTable from "../../partials/where-operators-table.mdx";

Operator reference by column type [#operator-reference-by-column-type]

<WhereOperatorsTable />

Examples [#examples]

Equality and inequality [#equality-and-inequality]

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    // Exact match (shorthand — no operator object needed)
    const incompleteTodos = await db.all(app.todos.where({ done: false }));

    // Not equal
    const nonDraftTodos = await db.all(app.todos.where({ title: { ne: "Draft" } }));

    // One of a set
    const selectedTodos = await db.all(app.todos.where({ id: { in: [todoIdA, todoIdB] } }));
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    // Exact match
    let query = QueryBuilder::new("todos")
        .filter_eq("done", Value::Boolean(false))
        .build();
    let incomplete_todos = client.query(query, None).await?;

    // Not equal
    let query = QueryBuilder::new("todos")
        .filter_ne("title", Value::Text("Draft".into()))
        .build();
    let non_draft_todos = client.query(query, None).await?;
    ```
  </Tab>
</Tabs>

Numeric comparisons [#numeric-comparisons]

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    const oneWeekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;

    const recentTodos = await db.all(app.todos.where({ $createdAt: { gt: oneWeekAgo } }));
    const highPriority = await db.all(app.todos.where({ priority: { gte: 3 } }));
    const lowPriority = await db.all(app.todos.where({ priority: { lt: 10 } }));
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    let now_ms = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_millis() as u64;
    let one_week_ago = Value::Timestamp(now_ms - 7 * 24 * 60 * 60 * 1000);

    let query = QueryBuilder::new("todos")
        .filter_gt("$createdAt", one_week_ago)
        .build();
    let recent_todos = client.query(query, None).await?;

    let query = QueryBuilder::new("todos")
        .filter_ge("priority", Value::Integer(3))
        .build();
    let high_priority = client.query(query, None).await?;

    let query = QueryBuilder::new("todos")
        .filter_lt("priority", Value::Integer(10))
        .build();
    let low_priority = client.query(query, None).await?;
    ```
  </Tab>
</Tabs>

String contains [#string-contains]

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    // Substring match (case-sensitive)
    const matches = await db.all(app.todos.where({ title: { contains: searchTerm } }));
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    // Substring match (case-sensitive)
    let query = QueryBuilder::new("todos")
        .filter_contains("title", Value::Text(search_term.into()))
        .build();
    let matches = client.query(query, None).await?;
    ```
  </Tab>
</Tabs>

Null checks on optional references [#null-checks-on-optional-references]

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    // Rows where the optional ref is not set
    const unlinkedTodos = await db.all(app.todos.where({ parentId: { isNull: true } }));

    // Rows where it is set
    const linkedTodos = await db.all(app.todos.where({ parentId: { isNull: false } }));
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    // Rows where the optional ref is not set
    let query = QueryBuilder::new("todos").filter_is_null("parent").build();
    let unlinked_todos = client.query(query, None).await?;

    // Rows where it is set
    let query = QueryBuilder::new("todos")
        .filter_is_not_null("parent")
        .build();
    let linked_todos = client.query(query, None).await?;
    ```
  </Tab>
</Tabs>

Multiple conditions (AND) [#multiple-conditions-and]

All predicates passed to `where(...)` / chained `filter_*` calls are AND-combined:

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    // done AND assigned to a project
    const doneWithProject = await db.all(
      app.todos.where({
        done: true,
        projectId: { isNull: false },
      }),
    );
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    // Multiple filter calls are AND-combined
    let query = QueryBuilder::new("todos")
        .filter_eq("done", Value::Boolean(true))
        .filter_is_not_null("project")
        .build();
    let done_with_project = client.query(query, None).await?;
    ```
  </Tab>
</Tabs>

Combining with ordering and limits [#combining-with-ordering-and-limits]

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    const recentIncomplete = await db.all(
      app.todos.where({ done: false }).orderBy("$createdAt", "asc").limit(50),
    );
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    let query = QueryBuilder::new("todos")
        .filter_eq("done", Value::Boolean(false))
        .order_by("$createdAt")
        .limit(50)
        .build();
    let recent_incomplete = client.query(query, None).await?;
    ```
  </Tab>
</Tabs>

Live subscriptions with WHERE [#live-subscriptions-with-where]

`useAll` and query subscriptions accept the same query builders as `db.all`. The subscription stays active and updates whenever any row enters or exits the filter:

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    export function subscribeOpenTodos(db: Db, onChange: (todos: unknown[]) => void) {
      return db.subscribeAll(app.todos.where({ done: false }), ({ all }) => onChange(all));
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    let query = QueryBuilder::new("todos")
        .filter_eq("done", Value::Boolean(false))
        .build();
    let pending = client.subscribe(query).await?;
    ```
  </Tab>
</Tabs>

For reactive framework bindings (`useAll` in React/Vue, `QuerySubscription` in Svelte), see [Framework Patterns](/docs/reference/framework-patterns#query-subscriptions).


# Column Types



import AvailableColumnTypes from "../../partials/available-column-types.mdx";

Any column can be made nullable by chaining `.optional()`. For binary data like images and file uploads, use the [Files & Blobs](/docs/writing/files-and-blobs) pattern rather than `s.bytes()` directly.

<AvailableColumnTypes />

Transformed Columns [#transformed-columns]

> **Experimental:** Transformed columns are an early TypeScript API and may change before the stable release.

Any normal column definer can be transformed with `.transform({ from, to })`. The database still stores the column using the underlying SQL type, while the TypeScript API exposes the transformed type on rows, inserts, and updates.

Use `from` to convert stored values into the value your app reads. Use `to` to convert app values back into the stored column value before inserts and updates.

```ts
import { schema as s } from "jazz-tools";

type Priority = "low" | "medium" | "high";

const schema = {
  tasks: s.table({
    title: s.string(),
    priority: s.int().transform<Priority>({
      from: (score) => (score >= 8 ? "high" : score >= 4 ? "medium" : "low"),
      to: (priority) => ({ low: 1, medium: 5, high: 10 })[priority],
    }),
  }),
};
```

With this schema, `priority` is stored as an `INTEGER`, but TypeScript treats it as `Priority` when reading and writing rows:

```ts
db.insert(app.tasks, {
  title: "Write launch notes",
  priority: "high",
});

db.update(app.tasks, task.id, {
  priority: "medium",
});

const task = await db.one(app.tasks.where({ id: task.id }));
task?.priority; // "low" | "medium" | "high"
```

Filters still use the stored column value, because arbitrary transforms cannot be translated into SQL predicates:

```ts
await db.all(app.tasks.where({ priority: { gte: 8 } }));
```

Transforms are TypeScript-client behavior. They do not change the generated SQL schema, migrations, permissions, indexes, or values stored on disk.


# Defining Tables



import SchemaSetup from "../../partials/schema-setup.mdx";

Project layout [#project-layout]

A Jazz project has a small set of files at the app root:

```text
app-root/
├── schema.ts            # Structural schema — tables and columns
├── permissions.ts       # Optional row-level policies
└── migrations/          # Reviewed migration edges
    └── 20260331-add-description-aaa-bbb.ts
```

`schema.ts` is the source of truth for your data model. `permissions.ts` is optional and must be a
separate file. The `migrations/` directory holds reviewed migration stubs — see
[Migrations](/docs/schemas/migrations) for the full workflow.

Table definitions [#table-definitions]

Tables are defined in `schema.ts` using the Jazz DSL. Each `s.table(...)` call registers a table and `s.ref(...)` defines typed relations between them.

```ts title="schema.ts"
projects: s.table({
  name: s.string(),
}),
todos: s.table({
  title: s.string(),
  done: s.boolean(),
  priority: s.int().optional(),
  description: s.string().optional(),
  owner_id: s.string().optional(),
  parentId: s.ref("todos").optional(),
  projectId: s.ref("projects").optional(),
}),
```

<Callout type="warn">
  `s.ref()` columns must be named with an `Id` or `_id` suffix (for example `projectId` or
  `owner_id`). For `s.array(s.ref())`, use an `Ids` or `_ids` suffix instead. The runtime enforces
  this convention and will throw if a ref column name does not match.
</Callout>

Validate locally [#validate-locally]

<SchemaSetup />

When you change your schema on a shared app, create and push a migration. See [Migrations](/docs/schemas/migrations) for details.

If you need to clear local browser data after a schema change, see [Auth Lifecycle](/docs/auth/lifecycle#storage-reset).

Exporting the app [#exporting-the-app]

`s.defineApp(schema)` converts your schema definition into a typed `app` object. This is what you
pass to queries, mutations, and subscriptions throughout your application code.

```ts title="schema.ts"
type AppSchema = s.Schema<typeof schema>;
export const app: s.App<AppSchema> = s.defineApp(schema);

export type Todo = s.RowOf<typeof app.todos>;
```

The `app` object has one typed table handle per table (e.g. `app.todos`, `app.projects`). Table handles are query builders — you chain `.where()`, `.include()`, `.orderBy()` and other methods directly on them.

Type helpers [#type-helpers]

Extract precise TypeScript types from any table handle:

| Helper                         | Returns                                                     |
| ------------------------------ | ----------------------------------------------------------- |
| `s.RowOf<typeof app.todos>`    | The row type (all columns, `id` included)                   |
| `s.InsertOf<typeof app.todos>` | The insert shape (no `id`, respects optionals and defaults) |
| `s.WhereOf<typeof app.todos>`  | The `where(...)` input shape for that table                 |

Very Large Schemas [#very-large-schemas]

For most apps, `s.defineApp(schema)` is the right export: it gives you one typed table handle for
each table in the schema, and Jazz uses the same schema for runtime validation, migrations, query
planning, and TypeScript inference.

Very large apps can hit a different tradeoff. The runtime schema may need to contain hundreds of
tables, while a given feature area only works with a much smaller subset. Because typed relations
and reverse relations are derived from the app schema, asking TypeScript to understand the whole
graph can make editor and build performance worse than the code you are writing actually needs.

Use `s.defineSliceableApp(schema)` when you want one complete runtime schema but smaller typed app
surfaces:

```ts title="schema.ts"
import { schema as s } from "jazz-tools";

const schema = {
  accounts: s.table({
    name: s.string(),
  }),
  workspaces: s.table({
    name: s.string(),
    accountId: s.ref("accounts"),
  }),
  catalog_items: s.table({
    title: s.string(),
    workspaceId: s.ref("workspaces"),
  }),
  orders: s.table({
    number: s.string(),
    catalogItemId: s.ref("catalog_items"),
    buyerId: s.ref("users"),
  }),
  shipments: s.table({
    trackingCode: s.string(),
    orderId: s.ref("orders"),
  }),
  users: s.table({
    name: s.string(),
  }),
  support_tickets: s.table({
    workspaceId: s.ref("workspaces"),
    requesterId: s.ref("users"),
  }),
};

const sliceableApp = s.defineSliceableApp(schema);

export const commerceApp = sliceableApp.slice(
  "accounts",
  "workspaces",
  "catalog_items",
  "orders",
  "shipments",
);
export const supportApp = sliceableApp.slice("accounts", "workspaces", "support_tickets");
```

Each slice returns a normal typed `App` surface for only the selected tables:

```ts
await db.all(commerceApp.orders.include({ catalogItem: true }));
await db.all(commerceApp.catalog_items.include({ ordersViaCatalogItem: true }));
```

Refs to tables inside the slice become typed relations and includes. Refs to tables outside the slice
remain valid scalar ID columns:

```ts
type Order = s.RowOf<typeof commerceApp.orders>;
// Order["catalogItemId"] is string, and commerceApp.orders.include({ catalogItem: true }) is typed.
// Order["buyerId"] is string, but there is no typed `buyer` include unless `users` is in the slice.
```

Reverse relations are also derived only from the current slice. In the example above,
`commerceApp.catalog_items` has `ordersViaCatalogItem`, while `supportApp.workspaces` has
`support_ticketsViaWorkspace`.

All slices share the complete runtime schema:

```ts
commerceApp.wasmSchema === sliceableApp.wasmSchema;
supportApp.wasmSchema === sliceableApp.wasmSchema;
```

That means schema hashing, migrations, permissions, runtime validation, query planning, inserts,
updates, and row transforms still see the full schema. The slice only limits the TypeScript app
graph you ask the compiler to expand.


# Migrations



<Callout type="info" title="You can skip this first">
  If you are trying to get your first app running, you can skip this page and return later.
</Callout>

Why Jazz migrations are different [#why-jazz-migrations-are-different]

Most migration systems are one-way: rewrite every row to the new shape, then cut over. That assumes you can stop the world long enough to upgrade — which doesn't hold when your clients are local-first, frequently offline, and updating their app on their own schedule.

Jazz keeps every schema version addressable by hash and translates rows between them on read and write. Clients on different versions stay interoperable, and nothing on disk is rewritten when you ship a new schema.

Schemas, lenses and branches [#schemas-lenses-and-branches]

Every unique version of your `schema.ts` has a hash which can be used to refer to it. When creating migrations, you describe the changes required to move between two schema versions. This is known as a 'lens'. Fetching all intermediate lenses allows clients with any published schema version to read data created with any other published schema version.

<LensDiagram />

Storage is partitioned by schema hash: rows written under a given schema live in
that schema's own branch (`env-{hash}-userBranch`) and stay there unchanged.
Non-adjacent reads compose lenses in sequence to bridge multiple schema versions.

In practice, this lets you:

* Ship a schema change without waiting for every user to update their app.
* Roll out platform-by-platform (mobile, desktop, web) on independent cadences.
* Accept writes from clients that have been offline since before the new schema landed.

Workflow [#workflow]

1. If this is the first migration you are creating, run:

   ```bash
   pnpm dlx jazz-tools@alpha migrations create
   ```

   This creates an initial snapshot of your schema in `migrations/snapshots/`. No migration file is created yet because there is no previous schema to diff against.

2. **Edit `schema.ts`** — change the data shape as needed.

3. **Validate locally** — optionally run `pnpm dlx jazz-tools@alpha validate` to surface
   any policy diagnostics without publishing. `deploy` runs the same checks; `validate` is most
   useful as a fast pre-publish sanity check or in CI.

4. **Create a migration stub for the updated schema** — run:

   ```bash
   pnpm dlx jazz-tools@alpha migrations create --name <your-migration-name>
   ```

   By default, Jazz diffs the latest committed snapshot in `migrations/snapshots/` against the
   current schema and writes a stub migration file into `migrations/`. It also saves a snapshot of the generated schema.

5. **Review and customise** — the migration, if needed (see below).

6. **Publish** — push the migration to the server:

   ```bash
   pnpm dlx jazz-tools@alpha migrations push <appId> <fromHash> <toHash>
   ```

   If you also want to publish the current schema, the migration and permissions in one step, you can run:

   ```bash
   pnpm dlx jazz-tools@alpha deploy <appId>
   ```

   `deploy` walks through the publish pipeline in one go:

   1. Publishes the current schema if the server does not already have it.
   2. If your previous `permissions.ts` was tied to an older schema hash, asks the server whether
      it already has a migration path between the two hashes. If not, pushes the local migration
      file that closes the gap (and fails with a helpful message if you haven't created one yet).
   3. Publishes the current permissions, attached to the current schema hash.

Permission-only changes in `permissions.ts` don't need a migration but still need to be deployed:
`pnpm dlx jazz-tools@alpha deploy <appId>`. See [Permissions](/docs/auth/permissions) for details.

The migration file [#the-migration-file]

The generated stub describes the diff as declarative operations which carry enough information to run in either direction. That
is how older clients can still read data written under a newer schema: the same operations replay
in reverse.

<Callout type="warn">
  If the diff contains ambiguities (e.g. a column was removed and a same-typed column was added,
  which could be a rename), the generated lens is marked as a **draft**. Draft lenses will fail at
  startup if they are in the path to a live schema. You need to review the draft lens and resolve
  the ambiguity before publishing.
</Callout>

Generated stub [#generated-stub]

Here's a generated stub for adding a `description` column:

```ts title="migrations/20260318-unnamed-a01f5c72ec47-311995e9a178.ts"
import { schema as s } from "jazz-tools";

export default s.defineMigration({
  migrate: {
    todos: {
      description: s.add.string({ default: null }),
    },
  },
  fromHash: "a01f5c72ec47",
  toHash: "311995e9a178",
  from: {
    todos: s.table({
      title: s.string(),
      done: s.boolean(),
      parentId: s.ref("todos").optional(),
      projectId: s.ref("projects").optional(),
      owner_id: s.string(),
    }),
  },
  to: {
    todos: s.table({
      title: s.string(),
      done: s.boolean(),
      description: s.string().optional(),
      parentId: s.ref("todos").optional(),
      projectId: s.ref("projects").optional(),
      owner_id: s.string(),
    }),
  },
});

```

Customising defaults [#customising-defaults]

Review generated defaults before you publish. For example, you might replace a nullable default with a domain-specific value:

```ts title="migrations/20260318-add-description-a01f5c72ec47-311995e9a178.ts"
import { schema as s } from "jazz-tools";

// Example of editing a generated migration stub.
export default s.defineMigration({
  migrate: {
    todos: {
      description: s.add.string({ default: "No description" }),
    },
  },
  fromHash: "a01f5c72ec47",
  toHash: "311995e9a178",
  from: {
    todos: s.table({
      title: s.string(),
      done: s.boolean(),
      parentId: s.ref("todos").optional(),
      projectId: s.ref("projects").optional(),
      owner_id: s.string(),
    }),
  },
  to: {
    todos: s.table({
      title: s.string(),
      done: s.boolean(),
      description: s.string().optional(),
      parentId: s.ref("todos").optional(),
      projectId: s.ref("projects").optional(),
      owner_id: s.string(),
    }),
  },
});

```

Backwards defaults [#backwards-defaults]

When a newer schema drops a column that older clients still expect, define a backwards default so the lens can supply a value for those clients:

```ts title="migrations/20260318-drop-legacy-priority-311995e9a178-73b65d082ab8.ts"
import { schema as s } from "jazz-tools";

// Example: dropping a column with a backwards default.
// Clients still on the older schema continue seeing legacy_priority.
export default s.defineMigration({
  migrate: {
    todos: {
      legacy_priority: s.drop.int({ backwardsDefault: 0 }),
    },
  },
  fromHash: "311995e9a178",
  toHash: "73b65d082ab8",
  from: {
    todos: s.table({
      title: s.string(),
      done: s.boolean(),
      description: s.string().optional(),
      parentId: s.ref("todos").optional(),
      projectId: s.ref("projects").optional(),
      owner_id: s.string(),
      legacy_priority: s.int(),
    }),
  },
  to: {
    todos: s.table({
      title: s.string(),
      done: s.boolean(),
      description: s.string().optional(),
      parentId: s.ref("todos").optional(),
      projectId: s.ref("projects").optional(),
      owner_id: s.string(),
    }),
  },
});

```

Migrating historical schemas [#migrating-historical-schemas]

Jazz does not require you to create a migration for every schema change. You can just use the app and create new data.
Existing data will still be stored in the database, but you will not be able to read it until you create a migration.

This is particularly useful when you are iterating on a feature that is not yet ready to be released.

The Jazz server will detect when there are rows that are not reachable from the current schema. It will log a warning and suggest you create a migration.

To do so, you'll need to **create the migration using explicit to/from schema hashes**:

```bash
pnpm dlx jazz-tools@alpha migrations create <appId> --fromHash <fromHash>
pnpm dlx jazz-tools@alpha migrations create <appId> --fromHash <fromHash> --toHash <toHash>
```

`--toHash` defaults to the current local schema. When a requested hash is not already saved locally, Jazz resolves it from the
server and saves a snapshot in `migrations/snapshots/`.

Inspecting the local schema hash [#inspecting-the-local-schema-hash]

`pnpm dlx jazz-tools@alpha schema hash` prints the hash of the local `schema.ts` without contacting a server or writing a snapshot, giving you a simple way to compare your local schema against what's deployed.

```bash
pnpm dlx jazz-tools@alpha schema hash
```

Exporting the compiled schema [#exporting-the-compiled-schema]

`pnpm dlx jazz-tools@alpha schema export` prints the compiled structural schema as JSON to stdout. It
also saves a snapshot of the schema in the local snapshot directory.

```bash
pnpm dlx jazz-tools@alpha schema export
pnpm dlx jazz-tools@alpha schema export --schema-dir ./packages/app
pnpm dlx jazz-tools@alpha schema export <appId> --schema-hash <hash> --server-url http://localhost:4200 --admin-secret <secret>
```

Without `--schema-hash`, Jazz exports the current local `schema.ts`. With `--schema-hash`, it
loads the schema from the local snapshot folder or, if missing, from the server.
`--schema-dir` and `--schema-hash` are mutually exclusive.

Server-backed commands require the app id so Jazz can resolve app-scoped routes like
`/apps/<appId>/schema/:hash`.

| Flag                   | Default             | Description                                                     |
| ---------------------- | ------------------- | --------------------------------------------------------------- |
| `--schema-dir <path>`  | current directory   | Path to app root containing `schema.ts`                         |
| `--schema-hash <hash>` | none                | Export a stored structural schema by hash                       |
| `--migrations-dir <p>` | `./migrations`      | Path to migrations directory and snapshot folder                |
| `--server-url <url>`   | `JAZZ_SERVER_URL`   | Server URL used when `--schema-hash` is not available locally   |
| `--admin-secret <sec>` | `JAZZ_ADMIN_SECRET` | Admin secret used when `--schema-hash` is not available locally |

Migration flags [#migration-flags]

`migrations create` uses flags rather than positional hashes. When it needs to resolve missing
schema hashes from a server, pass `<appId>` as the leading positional argument.

| Flag                   | Default             | Description                                      |
| ---------------------- | ------------------- | ------------------------------------------------ |
| `--schema-dir <path>`  | current directory   | Path to app root containing `schema.ts`          |
| `--migrations-dir <p>` | `./migrations`      | Path to migrations directory and snapshot folder |
| `--server-url <url>`   | `JAZZ_SERVER_URL`   | Server URL used when resolving missing schema    |
| `--admin-secret <sec>` | `JAZZ_ADMIN_SECRET` | Admin secret used when resolving missing schema  |
| `--fromHash <hash>`    | latest snapshot     | Optional source schema hash                      |
| `--toHash <hash>`      | current schema      | Optional target schema hash                      |
| `--name <name>`        | `unnamed`           | Optional migration filename label                |

Next steps [#next-steps]

* [Defining Tables](/docs/schemas/defining-tables) — table and column definitions
* [Column Types](/docs/schemas/column-types) — full list of available column types


# Files & Blobs



import { Callout } from "fumadocs-ui/components/callout";

Use plain `s.bytes()` when the binary value is small and should always load with its row.
For larger files, use the chunked storage described below.

Add the conventional tables [#add-the-conventional-tables]

`db.createFileFromBlob`, `db.createFileFromStream`, `db.loadFileAsBlob`, and
`db.loadFileAsStream` expect these exact table and column names on `app`:

```ts
file_parts: s.table({
  data: s.bytes(),
}),
files: s.table({
  name: s.string().optional(),
  mimeType: s.string(),
  partIds: s.array(s.ref("file_parts")),
  partSizes: s.array(s.int()),
}),
uploads: s.table({
  owner_id: s.string(),
  label: s.string(),
  fileId: s.ref("files"),
}),
```

`file_parts.data` stores the raw chunk bytes. `files.partIds` keeps the ordered chunk ids, and
`files.partSizes` stores each chunk's byte length in the same order.

In the example above, `uploads.file` is your app-owned reference to a stored file. `files.name`
is optional metadata; `createFileFromBlob(...)` fills it from `File.name` when available.

Add permissions [#add-permissions]

```ts
export const fileBlobPermissions = s.definePermissions(app, ({ policy, allowedTo, session }) => {
  policy.uploads.allowRead.where({ owner_id: session.user_id });
  policy.uploads.allowInsert.where({ owner_id: session.user_id });
  policy.uploads.allowUpdate.where({ owner_id: session.user_id });
  policy.uploads.allowDelete.where({ owner_id: session.user_id });

  // Files are created before the parent upload row exists, so inserts are direct for now.
  policy.files.allowInsert.where({});
  policy.file_parts.allowInsert.where({});

  policy.files.allowRead.where(allowedTo.readReferencing(policy.uploads, "fileId"));
  policy.file_parts.allowRead.where(allowedTo.readReferencing(policy.files, "partIds"));

  policy.files.allowDelete.where(allowedTo.deleteReferencing(policy.uploads, "fileId"));
  policy.file_parts.allowDelete.where(allowedTo.deleteReferencing(policy.files, "partIds"));
});
```

`uploads` owns access. `files` inherit read and delete access from `uploads.file`, and
`file_parts` inherit from `files.partIds`.

<Callout type="warn" title="Insert permissions are direct for now">
  File parts and files are created before the parent upload row exists, so insert inheritance does
  not naturally apply yet. Currently, grant direct insert access to the clients that may upload, or
  perform uploads in a trusted backend context.
</Callout>

Files are write-once, so `files` and `file_parts` normally do not need update
policies.

Create from a blob [#create-from-a-blob]

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    export async function createUploadFromBlob(db: Db, blob: Blob | File) {
      const file = await db.createFileFromBlob(app, blob, { tier: "edge" });

      return db
        .insert(app.uploads, {
          owner_id: EXAMPLE_OWNER_ID,
          label: "Profile photo",
          fileId: file.id,
        })
        .wait({ tier: "edge" });
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    pub async fn create_file_from_bytes(
        client: &JazzClient,
        data: &[u8],
        name: Option<&str>,
        mime_type: &str,
    ) -> jazz_tools::Result<ObjectId> {
        let mut part_ids = Vec::new();
        let mut part_sizes = Vec::new();

        for chunk in data.chunks(CHUNK_SIZE) {
            let (part_id, _, _) = client.insert(
                "file_parts",
                jazz_tools::row_input!("data" => chunk.to_vec()),
            )?;
            part_ids.push(Value::Uuid(part_id));
            part_sizes.push(Value::Integer(chunk.len() as i32));
        }

        let mut file_values = jazz_tools::row_input!(
            "mimeType" => mime_type,
            "partIds" => part_ids,
            "partSizes" => part_sizes,
        );
        if let Some(name) = name {
            file_values.insert("name".to_string(), name.into());
        }

        let (file_id, _, _) = client.insert("files", file_values)?;
        Ok(file_id)
    }
    ```

    ```rs
    pub async fn create_upload_from_bytes(
        client: &JazzClient,
        data: &[u8],
        owner_id: &str,
    ) -> jazz_tools::Result<ObjectId> {
        let file_id = create_file_from_bytes(client, data, Some("photo.jpg"), "image/jpeg").await?;

        let (upload_id, _, _) = client.insert(
            "uploads",
            jazz_tools::row_input!(
                "owner_id" => owner_id,
                "label" => "Profile photo",
                "fileId" => file_id,
            ),
        )?;

        Ok(upload_id)
    }
    ```
  </Tab>
</Tabs>

Returns the file row; store its id on your own table.

Create from a stream [#create-from-a-stream]

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    export async function createUploadFromStream(db: Db, stream: ReadableStream<Uint8Array>) {
      const file = await db.createFileFromStream(app, stream, {
        tier: "edge",
        name: "camera.raw",
        mimeType: "application/octet-stream",
      });

      return db
        .insert(app.uploads, {
          owner_id: EXAMPLE_OWNER_ID,
          label: "Camera import",
          fileId: file.id,
        })
        .wait({ tier: "edge" });
    }
    ```
  </Tab>

  <Tab value="Rust">
    Rust does not have a separate stream helper. Read your stream into chunks and create
    `file_parts` rows as they arrive, using the same approach shown in [Create from a
    blob](#create-from-a-blob) above.
  </Tab>
</Tabs>

Load as a blob [#load-as-a-blob]

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    export async function loadUploadBlob(db: Db, uploadId: string) {
      const upload = await db.one(app.uploads.where({ id: uploadId }), { tier: "edge" });
      if (!upload) {
        return null;
      }

      const blob = await db.loadFileAsBlob(app, upload.fileId, { tier: "edge" });
      return blob;
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    pub async fn load_file_bytes(
        client: &JazzClient,
        upload_id: ObjectId,
    ) -> jazz_tools::Result<Option<Vec<u8>>> {
        let uploads = client
            .query(
                QueryBuilder::new("uploads")
                    .select(&["fileId"])
                    .filter_eq("_id", Value::Uuid(upload_id))
                    .build(),
                Some(DurabilityTier::EdgeServer),
            )
            .await?;

        let Some((_, row)) = uploads.first() else {
            return Ok(None);
        };
        let Value::Uuid(file_id) = &row[0] else {
            return Ok(None);
        };

        let files = client
            .query(
                QueryBuilder::new("files")
                    .select(&["partIds"])
                    .filter_eq("_id", Value::Uuid(*file_id))
                    .build(),
                Some(DurabilityTier::EdgeServer),
            )
            .await?;

        let Some((_, row)) = files.first() else {
            return Ok(None);
        };
        let Value::Array(part_ids) = &row[0] else {
            return Ok(None);
        };

        let mut data = Vec::new();
        for part_ref in part_ids {
            let Value::Uuid(part_id) = part_ref else {
                continue;
            };
            let parts = client
                .query(
                    QueryBuilder::new("file_parts")
                        .select(&["data"])
                        .filter_eq("_id", Value::Uuid(*part_id))
                        .build(),
                    Some(DurabilityTier::EdgeServer),
                )
                .await?;
            if let Some((_, row)) = parts.first()
                && let Value::Bytea(chunk) = &row[0]
            {
                data.extend_from_slice(chunk);
            }
        }

        Ok(Some(data))
    }
    ```
  </Tab>
</Tabs>

Load as a stream [#load-as-a-stream]

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    export async function loadUploadStream(db: Db, uploadId: string) {
      const upload = await db.one(app.uploads.where({ id: uploadId }), { tier: "edge" });
      if (!upload) {
        return null;
      }

      const stream = await db.loadFileAsStream(app, upload.fileId, { tier: "edge" });
      return stream;
    }
    ```
  </Tab>

  <Tab value="Rust">
    To stream chunks rather than collecting all bytes, walk `files.partIds` and query each
    `file_parts` row individually, yielding chunks as they arrive instead of concatenating
    into a single `Vec<u8>`.
  </Tab>
</Tabs>

Both approaches fetch each chunk sequentially rather than eager-loading every part in one query.

Durability and incomplete local data [#durability-and-incomplete-local-data]

The file helpers forward normal Jazz durability options:

* **TypeScript:** pass `tier` to `createFileFromBlob(...)` / `createFileFromStream(...)`, or query options like `{ tier: "edge" }` to `loadFileAsBlob(...)` / `loadFileAsStream(...)`.
* **Rust:** pass `Some(DurabilityTier::EdgeServer)` or `Some(DurabilityTier::GlobalServer)` to `client.query(...)`.

If the requested tier does not have the full file yet, the load fails when it reaches the missing
part. Use `edge` or `global` when you need the read to wait for a more complete remote snapshot
than the local store currently has.

No automatic cascade yet [#no-automatic-cascade-yet]

Until file cascade delete lands, delete the chunk rows and the `files` row before deleting your
parent app row so the [inherited delete policies](/docs/auth/permissions#inherited-access-allowedto) still match:

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    export async function deleteUploadWithFile(db: Db, uploadId: string) {
      const upload = await db.one(app.uploads.where({ id: uploadId }), { tier: "edge" });
      if (!upload) {
        return;
      }

      const file = await db.one(app.files.where({ id: upload.fileId }), { tier: "edge" });

      if (file) {
        // Delete chunks and the file while the parent upload row still exists.
        for (const partId of file.partIds) {
          db.delete(app.file_parts, partId);
        }
        db.delete(app.files, file.id);
      }

      db.delete(app.uploads, uploadId);
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    pub async fn delete_upload_with_file(
        client: &JazzClient,
        upload_id: ObjectId,
    ) -> jazz_tools::Result<()> {
        let uploads = client
            .query(
                QueryBuilder::new("uploads")
                    .select(&["fileId"])
                    .filter_eq("_id", Value::Uuid(upload_id))
                    .build(),
                Some(DurabilityTier::EdgeServer),
            )
            .await?;

        let Some((_, row)) = uploads.first() else {
            return Ok(());
        };
        let Value::Uuid(file_id) = &row[0] else {
            return Ok(());
        };

        let files = client
            .query(
                QueryBuilder::new("files")
                    .select(&["partIds"])
                    .filter_eq("_id", Value::Uuid(*file_id))
                    .build(),
                Some(DurabilityTier::EdgeServer),
            )
            .await?;

        if let Some((file_row_id, row)) = files.first() {
            if let Value::Array(part_ids) = &row[0] {
                // Delete chunks while the parent file row still exists.
                for part_ref in part_ids {
                    if let Value::Uuid(part_id) = part_ref {
                        client.delete(*part_id)?;
                    }
                }
            }
            client.delete(*file_row_id)?;
        }

        client.delete(upload_id)?;
        Ok(())
    }
    ```
  </Tab>
</Tabs>

Example app [#example-app]

See the [file upload example](https://github.com/garden-co/jazz2/tree/main/examples/file-upload-react) for a complete React app with image upload, rendering, and deletion.


# Writing Data



Local-first writes [#local-first-writes]

All writes execute against the local database first. `insert`, `update`, and `delete` return write handles immediately. Call `.wait({ tier: ... })` on those handles when you need confirmation that the write reached a specific [durability tier](#write-durability-tiers).

Jazz also allows grouping writes together using [batches and transactions](#transactions-and-batches).
Writes made through an open transaction or batch are not individually waitable. Call `.wait({ tier })` on the returned write result instead.

<Callout type="info">
  For browser `Blob`, `File`, or `ReadableStream` uploads, use `db.createFileFromBlob(...)`
  or `db.createFileFromStream(...)` and store the returned file ID on your own row.

  See [Files & Blobs](/docs/writing/files-and-blobs) for more details.
</Callout>

Getting a Db handle [#getting-a-db-handle]

Every framework provides a hook to access the database handle. In plain TypeScript, use the `Db` returned by `createDb` directly.

<Tabs groupId="jazz-framework" items={["React", "Vue", "Svelte", "TypeScript"]} persist updateAnchor>
  <Tab value="React">
    ```tsx
    import { useDb } from "jazz-tools/react";

    const db = useDb();
    ```
  </Tab>

  <Tab value="Vue">
    ```vue
    <script setup lang="ts">
    import { useDb } from "jazz-tools/vue";

    const db = useDb();
    </script>
    ```
  </Tab>

  <Tab value="Svelte">
    ```svelte
    <script lang="ts">
      import { getDb } from 'jazz-tools/svelte';

      const db = getDb();
    </script>
    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts
    import { createDb } from "jazz-tools";

    const db = await createDb({
      appId: "my-app",
      env: "dev",
      userBranch: "main",
    });
    ```
  </Tab>
</Tabs>

Insert, update, delete [#insert-update-delete]

Mutations are methods on the database handle. All three take a table as their first argument.

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    export async function writeTodoCrud(db: Db, todoId: string) {
      db.insert(app.todos, {
        title: "Write docs",
        done: false,
        owner_id: EXAMPLE_OWNER_ID,
        projectId: EXAMPLE_PROJECT_ID,
      });
      db.update(app.todos, todoId, { done: true });
      db.delete(app.todos, todoId);
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    pub async fn write_todo_crud(client: &JazzClient, existing_id: ObjectId) -> jazz_tools::Result<()> {
        let values = todo_values("Write docs", "");

        let _new_row = client.insert("todos", values)?;
        client.update(
            existing_id,
            vec![("done".to_string(), Value::Boolean(true))],
        )?;
        client.delete(existing_id)?;
        Ok(())
    }
    ```
  </Tab>
</Tabs>

Upsert with a known ID [#upsert-with-a-known-id]

Use `upsert(...)` when your app already knows the row ID and wants to create that row if it does
not exist, or update it if it does. Like `insert`, `update`, and `delete`, it applies locally first
and returns a write handle that can be awaited for durability.

```ts
const write = db.upsert(
  app.todos,
  {
    title: "Imported task",
    done: false,
  },
  { id: importedTodoId },
);

await write.wait({ tier: "edge" });
```

Restore a deleted row [#restore-a-deleted-row]

Trying to insert, update or delete an already deleted row will fail. Use `restore(...)` to make a soft-deleted row visible again.
`restore` requires providing new data for the restored row (missing fields will use schema defaults, if they exist).

```ts
export function restoreDeletedTodo(db: Db, todoId: string) {
  db.delete(app.todos, todoId);

  const { value: restored } = db.restore(app.todos, todoId, {
    title: "Restored task",
    done: false,
    owner_id: EXAMPLE_OWNER_ID,
    projectId: EXAMPLE_PROJECT_ID,
  });

  return restored;
}
```

Partial updates and nullable fields [#partial-updates-and-nullable-fields]

`update(...)` only modifies the keys you pass.
Omitted fields are left unchanged; explicitly passing `undefined` also leaves a field unchanged.
To clear a nullable column in TypeScript, pass `null`.
Required fields cannot be set to `null`.

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    export function clearNullableTodoFields(db: Db, todoId: string) {
      db.update(app.todos, todoId, { owner_id: null }); // clears the nullable FK
      db.update(app.todos, todoId, { description: undefined }); // leaves the field unchanged
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    pub async fn clear_nullable_fields(
        client: &JazzClient,
        todo_id: ObjectId,
    ) -> jazz_tools::Result<()> {
        // Set a nullable column to null
        client.update(todo_id, vec![("owner_id".to_string(), Value::Null)])?;

        // Only the specified columns are changed; omitted columns are left as-is.
        Ok(())
    }
    ```
  </Tab>
</Tabs>

Write durability tiers [#write-durability-tiers]

When using `insert(...).wait({ tier })`, `update(...).wait({ tier })`, or `delete(...).wait({ tier })`, the tier controls how far the mutation must propagate before the promise resolves: locally on the client (`local`), the nearest edge server (`edge`), or the global core (`global`).

| Tier     | Resolves when                       | Default for    |
| -------- | ----------------------------------- | -------------- |
| `local`  | Persisted to local OPFS             | Browser/client |
| `edge`   | Acknowledged by nearest sync server | Backend/server |
| `global` | Propagated to global core           | —              |

<WriteTierDiagram />

Offline, only `local` resolves; `edge` and `global` waits stay pending until the device reconnects and the queued write propagates upstream. The write itself is durable locally either way, so picking a higher tier is purely about *when the promise resolves*, never about whether the data is safe.

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    export async function writeTodoWithDurabilityTiers(db: Db) {
      const { id } = await db
        .insert(app.todos, {
          title: "Write docs with durability tier",
          done: false,
          owner_id: EXAMPLE_OWNER_ID,
          projectId: EXAMPLE_PROJECT_ID,
        })
        .wait({ tier: "edge" });

      await db.update(app.todos, id, { done: true }).wait({ tier: "global" });
      await db.delete(app.todos, id).wait({ tier: "global" });
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    pub async fn write_todo_with_default_durability(
        client: &JazzClient,
    ) -> jazz_tools::Result<ObjectId> {
        let (id, _row_values, _batch_id) = client.insert(
            "todos",
            todo_values("Write docs with default durability behavior", ""),
        )?;

        // Rust currently does not expose per-write durability tier arguments.
        // Writes apply locally first, then sync asynchronously to higher tiers.
        Ok(id)
    }
    ```
  </Tab>
</Tabs>

See [Durability Tiers](/docs/reference/durability-tiers) for the full reference, including read durability, data flow between tiers, and consistency semantics.

Need to clear local data during development? See [Auth Lifecycle](/docs/auth/lifecycle#storage-reset).

`wait({ tier })` resolves when the batch reaches the requested durability tier. If the batch is rejected, it rejects with `PersistedWriteRejectedError` instead of hanging.

```ts
const pending = db.insert(app.todos, {
  title: "Ship review fixes",
  done: false,
});

console.log(pending.batchId);

try {
  const row = await pending.wait({ tier: "global" });
  console.log(row.id);
} catch (error) {
  if (error instanceof PersistedWriteRejectedError) {
    console.error(error.code, error.reason);
  }
}
```

You can also set a global `onMutationError` handler that will be notified anytime a write that was not explicitly awaited fails:

```ts
db.onMutationError((error) => {
  console.error("DB mutation failed: ", error);
});
```

Transactions and batches [#transactions-and-batches]

Use a transaction when several writes should settle together after an authority validates the sealed batch:

```ts
const result = await db.transaction(async (tx) => {
  tx.insert(app.todos, { title: "Draft copy", done: false });
  const stagedDrafts = await tx.all(app.todos.where({ done: false }));
  tx.update(app.todos, todoId, { done: true });
});
await result.wait({ tier: "edge" });
console.log(result.batchId);
```

The changes made as part of the transaction are scoped to it, and will only be
globally visible once it's committed and accepted by the authority.

You can also use batches when you want to group several ordinary writes and commit them together:

```ts
const result = db.batch((batch) => {
  batch.insert(app.todos, { title: "Grouped write", done: false });
  batch.update(app.todos, todoId, { done: true });
});
await result.wait({ tier: "edge" });
console.log(result.batchId);
```

Transactions and batches can read its own local writes through `all(...)` and `one(...)` before commit.

When using `transaction` and `batch`, changes are automatically committed once the callback finishes running and,
if an error is thrown inside the callback, the open transaction or batch is rolled back instead of committed.

Alternatively, you can use `beginTransaction` and `beginBatch` to get a transaction or batch object, respectively.
This can be useful when making writes across multiple contexts. In this case however, you need to commit or rollback
the transaction/batch explicitly.

```ts
let transaction: DbTransaction;
beforeEach(() => {
  transaction = db.beginTransaction();
});

afterEach(() => {
  // Writes performed in tests will be rolled back at the end,
  // preventing tests from interfering with each other
  transaction.rollback();
});

it("tasks have a title", () => {
  const task = transaction.insert(app.todos, { title: "Test task", done: false });
  expect(task.title).toEqual("Test task");
});
```


# Group permissions



This recipe shows how to build a workspace where users have different levels of access depending on their role. The same pattern applies to any group-like concept — teams, projects, channels, organisations.

Roles at a glance [#roles-at-a-glance]

| Role          | Read | Create | Edit own | Edit any | Manage members |
| ------------- | ---- | ------ | -------- | -------- | -------------- |
| `reader`      | ✓    |        |          |          |                |
| `contributor` | ✓    | ✓      | ✓        |          |                |
| `writer`      | ✓    | ✓      | ✓        | ✓        |                |
| `admin`       | ✓    | ✓      | ✓        | ✓        | ✓              |

Schema [#schema]

Three tables: the workspace itself, a members join table that records each user's role, and the documents that belong to the workspace.

```ts title="schema.ts"
const schema = {
  workspaces: s.table({
    name: s.string(),
  }),
  workspaceMembers: s.table({
    workspaceId: s.ref("workspaces"),
    user_id: s.string(),
    role: s.enum("reader", "writer", "contributor", "admin"),
  }),
  documents: s.table({
    title: s.string(),
    content: s.string(),
    workspaceId: s.ref("workspaces"),
  }),
};

type AppSchema = s.Schema<typeof schema>;
export const app: s.App<AppSchema> = s.defineApp(schema);
```

Permissions [#permissions]

```ts title="permissions.ts"
type Role = "reader" | "writer" | "contributor" | "admin";

s.definePermissions(app, ({ policy, session, anyOf, allOf }) => {
  // Re-usable helpers to improve readability.
  const isMember = (workspaceId: RowRefValue) =>
    policy.workspaceMembers.exists.where({ workspaceId, user_id: session.user_id });

  const hasRole = (workspaceId: RowRefValue, role: Role) =>
    policy.workspaceMembers.exists.where({ workspaceId, user_id: session.user_id, role });

  const isAdmin = (workspaceId: RowRefValue) => hasRole(workspaceId, "admin");

  // --- documents ---

  policy.documents.allowRead.where((doc) => isMember(doc.workspaceId));

  policy.documents.allowInsert.where((doc) =>
    anyOf([
      hasRole(doc.workspaceId, "writer"),
      hasRole(doc.workspaceId, "contributor"),
      hasRole(doc.workspaceId, "admin"),
    ]),
  );

  // Writers and admins can edit any document; contributors can only edit their own
  policy.documents.allowUpdate.where((doc) =>
    anyOf([
      hasRole(doc.workspaceId, "writer"),
      hasRole(doc.workspaceId, "admin"),
      allOf([{ $createdBy: session.user_id }, hasRole(doc.workspaceId, "contributor")]),
    ]),
  );

  // Writers and admins can delete any document; contributors can delete their own
  policy.documents.allowDelete.where((doc) =>
    anyOf([
      hasRole(doc.workspaceId, "writer"),
      isAdmin(doc.workspaceId),
      allOf([{ $createdBy: session.user_id }, hasRole(doc.workspaceId, "contributor")]),
    ]),
  );

  // --- workspaces ---

  policy.workspaces.allowRead.where((workspace) => isMember(workspace.id));
  policy.workspaces.allowInsert.always();
  policy.workspaces.allowUpdate.where((workspace) => isAdmin(workspace.id));
  policy.workspaces.allowDelete.where((workspace) => isAdmin(workspace.id));

  // --- workspaceMembers ---

  policy.workspaceMembers.allowRead.where((member) => isMember(member.workspaceId));

  // Admins can add members; workspace creators can bootstrap themselves as the first admin
  policy.workspaceMembers.allowInsert.where((member) =>
    anyOf([
      isAdmin(member.workspaceId),
      allOf([
        { user_id: session.user_id, role: "admin" },
        policy.workspaces.exists.where({ id: member.workspaceId, $createdBy: session.user_id }),
      ]),
    ]),
  );

  policy.workspaceMembers.allowUpdate.where((member) => isAdmin(member.workspaceId));

  // Admins can remove any member; members can leave on their own
  policy.workspaceMembers.allowDelete.where((member) =>
    anyOf([isAdmin(member.workspaceId), { user_id: session.user_id }]),
  );
});
```

* **Contributor** edit access uses `allOf` to require both `$createdBy: session.user_id` (the row belongs to this user) and a matching contributor membership. Writers and admins bypass the creator check entirely.
* **Bootstrap insert**: the second branch of `allowInsert` lets the workspace creator add themselves as the first admin — otherwise `isAdmin` would block everyone, since there are no members yet.
* **Leave on your own**: members can delete their own membership row regardless of role. Admins can remove anyone.

See [Permissions](/docs/auth/permissions) for more on `exists.where`, `anyOf`, `allOf`, and `$createdBy`.

Creating a workspace [#creating-a-workspace]

```ts
export function createWorkspace(db: ReturnType<typeof useDb>, name: string, creatorId: string) {
  const { value: workspace } = db.insert(app.workspaces, { name });
  // Add the creator as admin immediately so they can manage the workspace
  db.insert(app.workspaceMembers, {
    workspaceId: workspace.id,
    user_id: creatorId,
    role: "admin",
  });
  return workspace;
}
```

Managing members [#managing-members]

Adding a member [#adding-a-member]

```ts
export function addMember(
  db: ReturnType<typeof useDb>,
  workspaceId: string,
  userId: string,
  role: "reader" | "writer" | "contributor" | "admin",
) {
  db.insert(app.workspaceMembers, { workspaceId, user_id: userId, role });
}
```

Listing members [#listing-members]

```tsx title="WorkspaceMembers.tsx"
export function WorkspaceMembers({ workspaceId }: { workspaceId: string }) {
  const members = useAll(app.workspaceMembers.where({ workspaceId }));

  if (!members) return <p>Loading…</p>;

  return (
    <ul>
      {members.map((member) => (
        <li key={member.id}>
          {member.user_id} — {member.role}
        </li>
      ))}
    </ul>
  );
}
```

Changing a member's role [#changing-a-members-role]

```ts
export function changeRole(
  db: ReturnType<typeof useDb>,
  memberId: string,
  newRole: "reader" | "contributor" | "writer" | "admin",
) {
  db.update(app.workspaceMembers, memberId, { role: newRole });
}
```

Removing a member [#removing-a-member]

```ts
export async function removeMember(
  db: ReturnType<typeof useDb>,
  workspaceId: string,
  userId: string,
) {
  const member = await db.one(app.workspaceMembers.where({ workspaceId, user_id: userId }));
  if (member) db.delete(app.workspaceMembers, member.id);
}
```

Querying documents [#querying-documents]

```tsx title="WorkspaceDocuments.tsx"
export function WorkspaceDocuments({ workspaceId }: { workspaceId: string }) {
  const docs = useAll(app.documents.where({ workspaceId }));

  if (!docs) return <p>Loading…</p>;

  return (
    <ul>
      {docs.map((doc) => (
        <li key={doc.id}>{doc.title}</li>
      ))}
    </ul>
  );
}
```


# Shared access between users



After [user-owned data](/docs/recipes/access-control/user-owned-data), the next pattern you're likely to need is sharing. This recipe shows how to let one user grant another access to specific rows using a shares table.

Schema [#schema]

Add a `todoShares` table that records which user has access to which todo.

```ts title="schema.ts"
const schema = {
  todos: s.table({
    title: s.string(),
    done: s.boolean(),
  }),
  todoShares: s.table({
    todoId: s.ref("todos"),
    user_id: s.string(),
    can_edit: s.boolean(),
  }),
};

type AppSchema = s.Schema<typeof schema>;
export const app: s.App<AppSchema> = s.defineApp(schema);
```

Permissions [#permissions]

The creator can do everything. Share recipients get read access, and optionally edit access via `can_edit`.

```ts title="permissions.ts"
s.definePermissions(app, ({ policy, anyOf, session }) => {
  policy.todos.allowRead.where((todo) =>
    anyOf([
      { $createdBy: session.user_id },
      policy.todoShares.exists.where({
        todoId: todo.id,
        user_id: session.user_id,
      }),
    ]),
  );

  policy.todos.allowInsert.always();

  policy.todos.allowUpdate.where((todo) =>
    anyOf([
      { $createdBy: session.user_id },
      policy.todoShares.exists.where({
        todoId: todo.id,
        user_id: session.user_id,
        can_edit: true,
      }),
    ]),
  );

  policy.todos.allowDelete.where({ $createdBy: session.user_id });

  // Only the todo creator can manage shares
  policy.todoShares.allowInsert.where((share) =>
    policy.todos.exists.where({
      id: share.todoId,
      $createdBy: session.user_id,
    }),
  );
  policy.todoShares.allowRead.where({ user_id: session.user_id });
  policy.todoShares.allowDelete.where((share) =>
    policy.todos.exists.where({
      id: share.todoId,
      $createdBy: session.user_id,
    }),
  );
});
```

See [Permissions](/docs/auth/permissions) for more on `exists.where`, `anyOf`, and `allOf`.

Granting access [#granting-access]

To share a todo, insert a row into `todoShares`.

```ts
export function shareTodo(db: ReturnType<typeof useDb>, todoId: string, recipientUserId: string) {
  db.insert(app.todoShares, {
    todoId,
    user_id: recipientUserId,
    can_edit: false,
  });
}
```

Querying shared items [#querying-shared-items]

```tsx title="SharedWithMe.tsx"
export function SharedWithMe() {
  const session = useSession();
  const shares = useAll(
    app.todoShares.where({ user_id: session!.user_id }).include({ todo: true }),
  );

  if (!shares) return <p>Loading…</p>;

  return (
    <ul>
      {shares.map((share) =>
        share.todo ? (
          <li key={share.id}>
            {share.todo.title}
            {share.can_edit ? " (can edit)" : " (read-only)"}
          </li>
        ) : null,
      )}
    </ul>
  );
}
```

Revoking access [#revoking-access]

Delete the share row to revoke access. The server will stop syncing the todo to the former recipient.

```ts
export function unshareTodo(db: ReturnType<typeof useDb>, shareId: string) {
  db.delete(app.todoShares, shareId);
}
```


# User-owned data



import { Accordion, Accordions } from "fumadocs-ui/components/accordion";

This recipe covers building an app where users own their own data and no-one else can see it, covering schema, permissions, querying, and inserting.

Schema [#schema]

There's no need to add an explicit owner column needed — Jazz tracks who created each row automatically via `$createdBy`.

```ts title="schema.ts"
const schema = {
  todos: s.table({
    title: s.string(),
    done: s.boolean(),
  }),
};

type AppSchema = s.Schema<typeof schema>;
export const app: s.App<AppSchema> = s.defineApp(schema);
```

See [Defining tables](/docs/schemas/defining-tables) for the full schema DSL.

Permissions [#permissions]

Match `$createdBy` to the `session.user_id` to validate whether the current user is the one who created the data. Because `$createdBy` is set automatically, we can declare insert explicitly with `.always()`.

```ts title="permissions.ts"
s.definePermissions(app, ({ policy, session }) => {
  policy.todos.allowRead.where({ $createdBy: session.user_id });
  policy.todos.allowInsert.always();
  policy.todos.allowUpdate.where({ $createdBy: session.user_id });
  policy.todos.allowDelete.where({ $createdBy: session.user_id });
});
```

These rules are enforced on the server. See [Permissions](/docs/auth/permissions) for combinators, `allowedTo`, and more complex options.

Querying [#querying]

The table's permissions already scope results to the current user, so queries don't need a separate owner filter.

```tsx title="MyTodos.tsx"
export function MyTodos() {
  const todos = useAll(app.todos.where({ done: false }));

  if (!todos) return <p>Loading…</p>;

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}
```

See [Queries](/docs/reading/queries) for subscriptions, one-shot queries, and durability tiers.

Inserting [#inserting]

`$createdBy` is set automatically on insert, so we don't need to set an owner.

```tsx title="AddTodo.tsx"
export function AddTodo() {
  const db = useDb();

  function handleAdd(title: string) {
    db.insert(app.todos, { title, done: false });
  }

  return <button onClick={() => handleAdd("Buy milk")}>Add</button>;
}
```

<Accordions type="single">
  <Accordion title="Durable inserts">
    Use `db.insert(...).wait({ tier: "..." })` if you need confirmation that the write reached a specific [durability tier](/docs/reference/durability-tiers).
  </Accordion>

  <Accordion title="Explicit owners for transferable ownership">
    If ownership can be transferred after creation, use an explicit `owner_id` column instead of `$createdBy`.

    ```ts title="schema.ts"
    const schemaExplicit = {
      todos: s.table({
        title: s.string(),
        done: s.boolean(),
        owner_id: s.string(),
      }),
    };

    type ExplicitAppSchema = s.Schema<typeof schemaExplicit>;
    export const explicitApp: s.App<ExplicitAppSchema> = s.defineApp(schemaExplicit);
    ```

    ```ts title="permissions.ts"
    s.definePermissions(explicitApp, ({ policy, session }) => {
      policy.todos.allowRead.where({ owner_id: session.user_id });
      policy.todos.allowInsert.always();
      policy.todos.allowUpdate.whereOld({ owner_id: session.user_id });
      policy.todos.allowDelete.where({ owner_id: session.user_id });
    });
    ```

    `allowUpdate.whereOld(...)` checks the row before the update, so the current owner can rewrite `owner_id` to transfer the row. Using `.where(...)` instead would also enforce the condition on the post-update row and block transfers. See [Permissions](/docs/auth/permissions) for `whereOld`/`whereNew` semantics.

    `allowInsert.always()` lets any user insert a row with any `owner_id`, including someone else's. That's the right default if you want users to be able to assign rows to others on creation; otherwise, narrow it to `.where({ owner_id: session.user_id })` so clients can only create rows they own.
  </Accordion>
</Accordions>


# Auth provider integration



Jazz supports external JWT-based authentication for production use. This recipe walks through connecting a provider to Jazz, with examples for [Better Auth](https://www.better-auth.com/) (self-hosted) and [WorkOS](https://workos.com/) (managed service).

How it works [#how-it-works]

<Sequence
  eyebrow="Provider sign-in"
  description="An external JWT provider connecting to a Jazz server."
  participants={[
  { id: "browser", label: "Browser" },
  { id: "auth", label: "Auth provider" },
  { id: "jazz", label: "Jazz server", createAtStep: 2 },
]}
  steps={[
  { kind: "message", from: "browser", to: "auth", text: "Sign in" },
  { kind: "message", from: "auth", to: "browser", text: "JWT token", line: "dashed" },
  { kind: "message", from: "browser", to: "jazz", text: "Connect with JWT" },
  { kind: "message", from: "jazz", to: "auth", text: "Fetch JWKS" },
  { kind: "message", from: "auth", to: "jazz", text: "Public keys", line: "dashed" },
  { kind: "message", from: "jazz", to: "jazz", text: "Verify JWT" },
  { kind: "message", from: "jazz", to: "browser", text: "Session ready", line: "dashed" },
]}
/>

1. The user signs in with your auth provider and gets a JWT.
2. Your client passes that JWT to Jazz.
3. The Jazz server validates the JWT signature against the provider's JWKS endpoint.
4. On success, Jazz uses the JWT's `sub` claim as `session.user_id`.

If you're unfamiliar with JWTs and JWKS, see the [explainer on the Authentication page](/docs/auth/authentication#external-auth-for-production).

Provider setup [#provider-setup]

<Tabs groupId="auth-provider" persist updateAnchor items={["Better Auth", "WorkOS"]}>
  <Tab value="Better Auth">
    [Better Auth](https://www.better-auth.com/) is a self-hosted auth framework. You run the server yourself and enable the `jwt` plugin, which exposes a JWKS endpoint and issues signed JWTs.

    Server [#server]

    ```ts title="src/lib/auth.ts"
    export const auth = betterAuth({
      // your database, email config, etc.
      plugins: [
        jwt({
          jwks: {
            keyPairConfig: { alg: "ES256" },
          },
          jwt: {
            issuer: "https://your-app.example.com",
            definePayload: ({ user }) => ({
              claims: { role: (user as { role?: string }).role ?? "" },
            }),
          },
        }),
      ],
    });
    ```

    This exposes a JWKS endpoint at `/api/auth/jwks` (or wherever you mount Better Auth's handler).

    Client [#client]

    Create the auth client with the `jwtClient` plugin so you can request JWT tokens.

    ```ts title="src/lib/auth-client.ts"
    export const authClient = createAuthClient({
      plugins: [jwtClient()],
    });
    ```

    Connecting to Jazz [#connecting-to-jazz]

    Get the JWT from Better Auth and pass it to Jazz via the `config` prop.

    ```tsx title="App.tsx"
    export function App() {
      const { data: session, isPending } = authClient.useSession();
      const [token, setToken] = useState<string | undefined>();

      useEffect(() => {
        if (isPending || !session?.session) {
          setToken(undefined);
          return;
        }

        authClient.token().then((res) => {
          if (res.error) return;
          setToken(res.data.token);
        });
      }, [isPending, session?.session?.id]);

      return (
        <JazzProvider
          config={{
            appId: "my-app",
            serverUrl: "wss://your-jazz-server.example.com",
            jwtToken: token,
          }}
        >
          <YourApp />
        </JazzProvider>
      );
    }
    ```

    Jazz server configuration [#jazz-server-configuration]

    Point the Jazz server at your Better Auth JWKS endpoint:

    ```bash
    pnpm dlx jazz-tools@alpha server <APP_ID> --jwks-url https://your-app.example.com/api/auth/jwks
    ```

    For a full working example, see the [Better Auth chat example](https://github.com/garden-co/jazz2/tree/main/examples/auth-betterauth-chat).

    <Callout type="info">
      If your users start unauthenticated and sign up later, see [Local-first auth](/docs/auth/local-first-auth#signing-up-with-betterauth) for how to preserve their identity across the transition.
    </Callout>
  </Tab>

  <Tab value="WorkOS">
    [WorkOS](https://workos.com/) is a managed auth service — no server-side auth code needed. Wrap your app with `AuthKitProvider`, get the access token, and pass it to Jazz.

    ```tsx title="App.tsx"
    function JazzWithWorkOS() {
      const { user, getAccessToken } = useAuth();
      const [token, setToken] = useState<string | undefined>();

      useEffect(() => {
        if (!user) {
          setToken(undefined);
          return;
        }

        getAccessToken().then((accessToken) => {
          setToken(accessToken ?? undefined);
        });
      }, [getAccessToken, user]);

      return (
        <JazzProvider
          config={{
            appId: "my-app",
            serverUrl: "wss://your-jazz-server.example.com",
            jwtToken: token,
          }}
        >
          <YourApp />
        </JazzProvider>
      );
    }

    export function App() {
      return (
        <AuthKitProvider clientId="client_01ABC...">
          <JazzWithWorkOS />
        </AuthKitProvider>
      );
    }
    ```

    Jazz server configuration [#jazz-server-configuration-1]

    Point the Jazz server at the WorkOS JWKS endpoint:

    ```bash
    pnpm dlx jazz-tools@alpha server <APP_ID> --jwks-url https://api.workos.com/sso/jwks/client_01ABC...
    ```

    For a full working example, see the [WorkOS chat example](https://github.com/garden-co/jazz2/tree/main/examples/auth-workos-chat).
  </Tab>
</Tabs>

See [Server setup](/docs/getting-started/server-setup) for the full set of server flags.

Using JWT claims in permissions [#using-jwt-claims-in-permissions]

Your auth provider's JWT may include custom claims (roles, organisation IDs, etc.). Access them in permissions via `session.where(...)`.

```ts title="permissions.ts"
s.definePermissions(exampleApp, ({ policy, anyOf, session }) => {
  policy.todos.allowRead.where(
    anyOf([{ owner_id: session.user_id }, session.where({ "claims.role": "manager" })]),
  );
});
```

See [Permissions](/docs/auth/permissions) for the full claims API.

Other providers [#other-providers]

Any provider that issues JWTs and exposes a JWKS endpoint will work. The key pattern is always the same: get a JWT from your provider, pass it to Jazz.

| Provider    | JWKS endpoint                                                                               | `sub` claim format |
| ----------- | ------------------------------------------------------------------------------------------- | ------------------ |
| Better Auth | `<baseURL>/api/auth/jwks`                                                                   | User ID            |
| WorkOS      | `https://api.workos.com/sso/jwks/<clientId>`                                                | `user_<id>`        |
| Clerk       | `https://<app>.clerk.accounts.dev/.well-known/jwks.json`                                    | `user_<id>`        |
| Auth0       | `https://<tenant>.auth0.com/.well-known/jwks.json`                                          | `auth0\|<id>`      |
| Firebase    | `https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com` | Firebase UID       |

Jazz uses the JWT `sub` claim as `session.user_id`. If your provider keeps `sub` fixed to its own user ID, mint the JWT with `sub` set to the Jazz user ID yourself — either via a custom `getSubject` hook on the provider or by issuing the JWT directly from your server.

Full working examples: [Better Auth chat](https://github.com/garden-co/jazz2/tree/main/examples/auth-betterauth-chat), [WorkOS chat](https://github.com/garden-co/jazz2/tree/main/examples/auth-workos-chat).


# Better Auth Adapter



The Jazz Better Auth adapter lets Better Auth store its tables in Jazz. For general Better Auth setup (route handlers, client, plugins), see the [Better Auth documentation](https://www.better-auth.com/docs).

Schema Workflow [#schema-workflow]

Better Auth tables live in a generated `schema-better-auth/` module that your app schema spreads into its own table map. This keeps Better Auth's tables in the same Jazz app as your own data, so a single backend context handles both.

```bash
# Generate the Better Auth schema module into schema-better-auth/
npx @better-auth/cli@latest generate \
  --config ./src/lib/auth.ts \
  --output ./schema-better-auth/schema.ts

# Validate the generated schema source alongside your own
pnpm dlx jazz-tools@alpha validate
```

Import the generated tables in your app's `schema.ts` and merge them into the app definition:

```ts title="schema.ts"
import { schema as s } from "jazz-tools";
import { schema as betterauthSchema } from "./schema-better-auth/schema";

const schema = {
  ...betterauthSchema,
  messages: s.table({
    author_name: s.string(),
    chat_id: s.string(),
    text: s.string(),
    sent_at: s.timestamp(),
  }),
  // ...your own tables
};

type AppSchema = s.Schema<typeof schema>;
export const app: s.App<AppSchema> = s.defineApp(schema);
```

The generated `schema-better-auth/schema.ts` also ships a `permissions` export that denies all operations on the Better Auth tables. Merge it with your own permissions so regular client sessions can't read or write Better Auth rows — the adapter itself uses `context.asBackend()`, which authenticates with `backendSecret` and bypasses permission checks entirely.

Database Adapter [#database-adapter]

Create a server-side [Jazz context](/docs/getting-started/server-setup#backend-context-setup), then pass it to `jazzAdapter(...)` as the `database` in your Better Auth config. Point both `db` and `schema` at the merged `app` — not at the generated module directly — so Better Auth and your own tables share a single Jazz app.

```ts title="src/lib/auth.ts"
import { betterAuth } from "better-auth";
import { createJazzContext } from "jazz-tools/backend";
import { jazzAdapter } from "jazz-tools/better-auth-adapter";
import { app } from "../../schema";

const jazzContext = createJazzContext({
  appId: process.env.APP_ID!,
  driver: { type: "memory" },
  serverUrl: process.env.SYNC_SERVER_URL!,
  env: process.env.NODE_ENV === "production" ? "prod" : "dev",
  userBranch: "main",
  backendSecret: process.env.BACKEND_SECRET!,
});

export const auth = betterAuth({
  database: jazzAdapter({
    db: () => jazzContext.asBackend(app),
    schema: app.wasmSchema,
  }),
  // ...your Better Auth config
});
```

| Option      | Description                                                                                            |
| ----------- | ------------------------------------------------------------------------------------------------------ |
| `db`        | A function returning a `Db` handle via `context.asBackend(app)`. Use the merged app, not `authSchema`. |
| `schema`    | Typically `app.wasmSchema` from your merged schema. A raw `s.App` is also accepted.                    |
| `debugLogs` | Better Auth adapter debug logging controls.                                                            |
| `usePlural` | Whether Better Auth model names should use plural table names.                                         |
| `prefix`    | Table name prefix (defaults to `"better_auth_"`).                                                      |

<Callout title="Leave Better Auth joins disabled">
  The Jazz adapter does not support Better Auth experimental joins yet, so do not enable
  `experimental.joins`.
</Callout>

Publishing to a sync server [#publishing-to-a-sync-server]

Because Better Auth tables are merged into your app schema, they ride on the same deploy as everything else — no separate push for `schema-better-auth/`. Publish schema, permissions, and migrations through the normal workflow:

```bash
pnpm dlx jazz-tools@alpha deploy <appId>
```

See [Migrations](/docs/schemas/migrations) for the full deploy flow and how schema changes produce migration edges.

Compatibility [#compatibility]

The adapter is currently aligned with Better Auth `1.5.5`.

| Plugin/Feature        | Compatibility |
| --------------------- | :-----------: |
| Email & Password auth |       ✅       |
| Social Provider auth  |       ✅       |
| Email OTP             |       ✅       |

<Callout title="Compatibility scope">
  This section reflects the adapter behavior currently covered by this repo's Better Auth
  integration and tests. If you add plugins that introduce extra tables or custom schema fields,
  regenerate `schema-better-auth/schema.ts` and re-run the Jazz schema workflow.
</Callout>


# Nested data with permission inheritance



This recipe shows how to model a simple hierarchy, inherit permissions from parent rows, and query/insert at each level.

Schema [#schema]

A simple schema with three tables linked by foreign keys. A project has tasks, and tasks have comments.

```ts title="schema.ts"
const schema = {
  projects: s.table({
    name: s.string(),
  }),
  tasks: s.table({
    title: s.string(),
    done: s.boolean(),
    projectId: s.ref("projects"),
  }),
  comments: s.table({
    body: s.string(),
    taskId: s.ref("tasks"),
  }),
};

type AppSchema = s.Schema<typeof schema>;
export const app: s.App<AppSchema> = s.defineApp(schema);
```

Inherited permissions [#inherited-permissions]

Use `allowedTo` to inherit access from the parent row. If you can read a project, you can read its tasks, and if you can read a task, you can read its comments.

```ts title="permissions.ts"
s.definePermissions(app, ({ policy, allowedTo, session }) => {
  // Projects: only the creator
  policy.projects.allowRead.where({ $createdBy: session.user_id });
  policy.projects.allowInsert.always();
  policy.projects.allowUpdate.where({ $createdBy: session.user_id });
  policy.projects.allowDelete.where({ $createdBy: session.user_id });

  // Tasks: inherit from project
  policy.tasks.allowRead.where(allowedTo.read("projectId"));
  policy.tasks.allowInsert.where(allowedTo.read("projectId"));
  policy.tasks.allowUpdate.where(allowedTo.update("projectId"));
  policy.tasks.allowDelete.where(allowedTo.delete("projectId"));

  // Comments: inherit from task
  policy.comments.allowRead.where(allowedTo.read("taskId"));
  policy.comments.allowInsert.where(allowedTo.read("taskId"));
  policy.comments.allowUpdate.where({ $createdBy: session.user_id });
  policy.comments.allowDelete.where({ $createdBy: session.user_id });
});
```

The `allowedTo.read("projectId")` argument is the FK column name. See [Permissions](/docs/auth/permissions) for `maxDepth` and recursive inheritance.

<Callout type="info">
  Projects use `$createdBy` instead of an explicit `owner_id` column. Jazz tracks who created each
  row automatically, so you can reference it in permissions without adding a column to your schema.
  Anyone can insert a `project`, and it will automatically be created with the appropriate
  `$createdBy` information.
</Callout>

Querying [#querying]

```tsx title="ProjectTasks.tsx"
export function ProjectTasks({ projectId }: { projectId: string }) {
  const tasks = useAll(app.tasks.where({ projectId }).orderBy("$createdAt", "desc"));

  if (!tasks) return <p>Loading…</p>;

  return (
    <ul>
      {tasks.map((task) => (
        <li key={task.id}>{task.title}</li>
      ))}
    </ul>
  );
}
```

See [Includes and relations](/docs/reading/includes-and-relations) for `include()`, reverse relations, and `select()`.

Inserting [#inserting]

Insert from the top down — create the project first, then tasks referencing it.

```tsx title="CreateProject.tsx"
export function CreateProject() {
  const db = useDb();
  const session = useSession();

  async function handleCreate() {
    const { value: project } = db.insert(app.projects, {
      name: "Website redesign",
    });

    db.insert(app.tasks, {
      title: "Design homepage",
      done: false,
      projectId: project.id,
    });
  }

  return <button onClick={handleCreate}>New project</button>;
}
```

Each insert executes locally and syncs in the background. Because permissions inherit downward, anyone who can access the project automatically gets access to its tasks and comments.


# Real-time collaborative list



import { Accordion, Accordions } from "fumadocs-ui/components/accordion";

Every client subscribes to the same data and sees changes as they happen. This recipe shows the pattern end-to-end.

Schema [#schema]

A shared project with collaboratively-edited tasks.

```ts title="schema.ts"
const schema = {
  projects: s.table({
    name: s.string(),
  }),
  tasks: s.table({
    title: s.string(),
    done: s.boolean(),
    assignee_id: s.string().optional(),
    projectId: s.ref("projects"),
  }),
  projectMembers: s.table({
    projectId: s.ref("projects"),
    user_id: s.string(),
  }),
};

type AppSchema = s.Schema<typeof schema>;
export const app: s.App<AppSchema> = s.defineApp(schema);
```

Permissions [#permissions]

Project members can read and write tasks. The creator can manage membership. Tasks inherit access from their project via `allowedTo`.

```ts title="permissions.ts"
s.definePermissions(app, ({ policy, anyOf, allowedTo, session }) => {
  // Projects: creator and members
  policy.projects.allowRead.where((project) =>
    anyOf([
      { $createdBy: session.user_id },
      policy.projectMembers.exists.where({
        projectId: project.id,
        user_id: session.user_id,
      }),
    ]),
  );
  policy.projects.allowInsert.always();
  policy.projects.allowUpdate.where({ $createdBy: session.user_id });

  // Tasks: inherit from project
  policy.tasks.allowRead.where(allowedTo.read("projectId"));
  policy.tasks.allowInsert.where(allowedTo.read("projectId"));
  policy.tasks.allowUpdate.where(allowedTo.read("projectId"));

  // Members: only the creator can manage
  policy.projectMembers.allowInsert.where((member) =>
    policy.projects.exists.where({
      id: member.projectId,
      $createdBy: session.user_id,
    }),
  );
  policy.projectMembers.allowRead.where((member) =>
    anyOf([
      policy.projects.exists.where({
        id: member.projectId,
        $createdBy: session.user_id,
      }),
      { user_id: session.user_id },
    ]),
  );
});
```

Subscribing to shared data [#subscribing-to-shared-data]

When multiple clients subscribe to the same query, they all see each other's changes in real-time.

```tsx title="ProjectTasks.tsx"
export function ProjectTasks({ projectId }: { projectId: string }) {
  const db = useDb();
  const tasks = useAll(app.tasks.where({ projectId, done: false }).orderBy("$createdAt", "desc"));

  function addTask(title: string) {
    db.insert(app.tasks, { title, done: false, projectId });
  }

  function completeTask(taskId: string) {
    db.update(app.tasks, taskId, { done: true });
  }

  if (!tasks) return <p>Loading…</p>;

  return (
    <ul>
      {tasks.map((task) => (
        <li key={task.id}>
          <button onClick={() => completeTask(task.id)}>Done</button>
          {task.title}
        </li>
      ))}
    </ul>
  );
}
```

When Alice inserts a task it appears in her UI instantly, syncs to the server, and the server pushes it to Bob — whose `useAll` subscription re-renders automatically.

<Accordions type="single">
  <Accordion title="How sync and conflicts work">
    When two users edit the same row concurrently, Jazz uses last-writer-wins (LWW) per column. Each column resolves independently, so if Alice updates `title` while Bob updates `done`, both changes are preserved. If they both update `title`, the last write (by wall-clock time) wins.

    For most applications this is the right default. See [How sync works](/docs/concepts/how-sync-works) for more detail.
  </Accordion>
</Accordions>
