Thin Client
Learn how to set up the thin browser client, which talks to your backend with no local database.The default browser client: about 160 KB gzipped, with no Wasm and no local storage. Your main backend runs every query under the signed-in user's policies and sends finished rows, and writes commit on the server.
When to pick it
Jazz has two browser clients. Both use the same schema, queries, permissions and Db API, so you
can start thin and upgrade to local-first
later without changing app code.
| Thin Client | Local-First Client | |
|---|---|---|
| Bundle | about 160 KB gzipped | adds the Wasm engine, loaded as its own chunk |
| Queries run | on your backend, under the user's policies | in the browser, against a local replica |
| Writes show up | after one round trip | instantly, then sync |
| Offline | no | yes, reads and writes |
| Local storage | none | IndexedDB, per account |
| Wasm and workers | none | a Wasm engine in a shared worker |
| Setup | nothing beyond JazzProvider | a client loader and a few bundler lines |
Both need React 19 or later for the React bindings (jazz-tools declares react >=19, so npm
refuses to install it next to React 18).
Pick the thin client by default. Pick local-first when users need instant UI on every change, work offline, or keep large working sets that should not round-trip on every read.
Connect
The thin client is what you get with no choice made: JazzProvider in React, Vue or Svelte, or
createJazzClient from jazz-tools/client, without a client loader.
App.tsximport { JazzProvider } from "jazz-tools/react";function Root() {return (<JazzProvider signedOut={<SignIn />}><App /></JazzProvider>);}
In the Next.js App Router, JazzProvider is a client component. Put it in a file that starts
with "use client" and render that from app/layout.tsx around {children}:
app/providers.tsx"use client";import { JazzProvider } from "jazz-tools/react";export function Providers({ children }: { children: React.ReactNode }) {return <JazzProvider signedOut={<SignIn />}>{children}</JazzProvider>;}
Server components and route handlers read through
jazz.forReqUser instead.
App.vue<script setup lang="ts">import { JazzProvider } from "jazz-tools/vue";</script><template><JazzProvider><TodoApp /><template #signedOut><SignIn /></template></JazzProvider></template>
After your own sign-in, call reauthenticate() from useJazzAuth() (from jazz-tools/vue).
See Framework Patterns for the other Vue APIs.
App.svelte<script lang="ts">import { JazzProvider } from "jazz-tools/svelte";</script><JazzProvider><TodoApp />{#snippet signedOut()}<SignIn />{/snippet}</JazzProvider>
After your own sign-in, call reauthenticate() on useJazzAuth() (from jazz-tools/svelte,
called during component setup). In SvelteKit, render the provider only in the browser; see
Framework Patterns for that and the other Svelte APIs.
app.tsimport { createJazzClient } from "jazz-tools/client";import { app } from "./schema";const jazz = await createJazzClient({});// After your own sign-in or sign-out, ask your backend again.authClient.onSessionChange(() => void jazz.reauthenticate());const db = jazz.getSnapshot().client!.db;const todos = await db.all(app.todos);
Sign-in
Both clients sign in the same way: they ask your main backend at /jazz who is signed in, with
your app's own session cookie, and your backend's authenticate decides who that user is. The
client asks on start, when the page regains focus and when another tab signs in or out. After your
own sign-in or sign-out, call reauthenticate() (from useJazzAuth() in React) so the change shows
at once. The browser never holds a Jazz key or token. See How Auth Works.
Reads and writes
Both clients return the same Db: queries, subscribe, insert, update,
upsert, delete, transactions and the framework hooks work unchanged.
The difference is when a write shows up:
- Thin client. A write shows after one round trip to your backend, and
wait()resolves when the server has answered. - Local-first client. A write shows instantly in every local query, then syncs.
wait()takes a durability tier (localorglobal) to wait for the point you need.
Options
Both clients take these options:
| Option | Default | What it does |
|---|---|---|
serverUrl | /jazz | Where your main backend mounts Jazz. Set it only if you mount it somewhere else |
initial | "signed-out" | "local-first" starts visitors as local-first guests |
clearOnSignOut | false | Wipe the account's local data on sign-out, for shared devices |
There is no driver option, since the thin client keeps nothing locally.
What runs on the server
- Transactions (mergeable and serializable), branch and snapshot views (
db.captureSnapshot, branch/base options), large-value streaming (insertStreaming/upsertStreaming)
Limitations
Each is an explicit "not supported" error:
- no offline use: while the backend is unreachable, reads and subscriptions wait and are answered after the reconnect; writes are not optimistic: they show after one round trip, and
wait()resolves when the server answered - no attributed writes
- no streaming inside a transaction
- text edits with
editedFromon merge text columns are rejected (NotObserved) for now; use the local-first client for collaborative text editing - pending writes live in memory only: a write the server hasn't answered yet is lost if the page reloads or closes (see Pending writes below)
- a single write, and a single answer to a query, can be at most 32 MiB. The client refuses a
larger write itself; streamed writes (
insertStreamingand the like) go in pieces, so a larger file uploads fine. The server answers a larger result with aTooLargeerror for that query alone (ending a live query) and keeps the session, so one very large row fails every query that includes it. Read such a value a page at a time with a range select, such asselect({ content: { from, to } }), until streaming reads arrive - no admin secret in the browser
Pending writes
When the socket drops, the client reconnects on its own and resends any write the server hadn't
answered. Standalone inserts, updates, upserts, deletes and restores carry a key the server
deduplicates by, so a resend lands exactly once. getSyncStatus() and useSyncStatus() show
whether the client is connected and how many writes still wait for an answer.
A write ends with ThinConnectionLostError (from jazz-tools/thin) only when the connection ends
for good while it waits: after reauthenticate() signs in someone else, after disconnect(), or
when the client closes. It may or may not have committed, so read before you retry. A transaction
in flight when the socket drops ends the same way.
Pending writes are kept in memory, not on disk. To warn before the user leaves with unsent changes:
window.addEventListener("beforeunload", (event) => {if (db.getSyncStatus().pendingUploads > 0) event.preventDefault();});
For writes that survive a reload, upgrade to local-first.
Upgrading to local-first
Hand the provider a loader for the local-first client. It starts thin, then upgrades in the
background after the first load. The session keeps the same Db: live queries and listeners carry on, restarting from the
local-first client's first result, and the thin client's pending writes finish first:
<JazzProvider client={() => import("jazz-tools/local-first")}>
The dynamic import lives in your code, so the local-first client and its Wasm become a separate
chunk: apps that never pass client never download it.
To upgrade only when you need to, for example when a user opens an editor or goes offline, call the hook instead:
const { upgradeToLocalFirst } = useDb();await upgradeToLocalFirst(() => import("jazz-tools/local-first"));
Without a framework, call the same method on the session's Db:
await db.upgradeToLocalFirst(() => import("jazz-tools/local-first")). The session upgrades in place,
so you keep using the same Db. See Local-First Client.