Jazz Cloud
Learn how to keep your app's data on Jazz Cloud, with your own backend in front for auth.Jazz Cloud stores and syncs your app's data. Your own backend sits in front of it: browsers talk only to your backend, which signs users in with your auth and connects to Jazz Cloud. Jazz Cloud does no user auth of its own.
This page sets up your main backend: the backend that runs createJazz, serves sync to your
browsers and apps, and decides who each user is. Other trusted machines reach Jazz through it as
backend clients.
Install
Terminalnpm install jazz-tools
This installs Jazz 2, which is in alpha and leaves alpha soon. Versions before 2.0 are
Classic Jazz, which has a different API: projects on jazz-tools 0.x
and guides that use co.map, CoValue or Group are Classic.
Get an app ID
Your app ID namespaces your data for storage and sync. Click below to generate one on Jazz Cloud. You also get the secrets your backend needs. Generated apps are unclaimed until you claim them in the dashboard, and unclaimed apps are deleted after 14 days.
Or from the command line (AI agents: use this to provision your own app):
curl -X POST https://v2.dashboard.jazz.tools/api/apps/generate
Jazz Cloud sync URL: https://v2.sync.jazz.tools/
Connect your backend
Put the generated values in your backend's environment. All of them stay server-side; the browser never needs the app ID or the sync URL, and the admin secret is like a database password:
.envJAZZ_APP_ID="<your app ID>"JAZZ_SERVER_URL="https://v2.sync.jazz.tools/"JAZZ_ADMIN_SECRET="<admin secret>"
Then point createJazz at Jazz Cloud with the remote() store:
jazz.server.tsimport { createJazz, remote } from "jazz-tools/backend";import { app } from "./schema";import permissions from "./permissions";export const jazz = createJazz({app,permissions,store: remote({appId: process.env.JAZZ_APP_ID!,url: process.env.JAZZ_SERVER_URL!,secret: process.env.JAZZ_ADMIN_SECRET!, // the app's admin secret}),// Who is signed in, read from your own auth's session.authenticate: async (request) => {const session = await getSession(request);return session ? { userId: session.user.id } : null;},});
appandpermissionsare the ones your code imports. The bundle that's running is the bundle Jazz enforces, so rolling back your backend rolls back its permissions too.authenticate(request)reads your own session (a cookie, a header) and returns{ userId, claims? }, ornullwhen signed out. It is the only place that decides who a user is: sign-ins, sync connections andforReqUserall ask it. There's no Jazz token in the browser. If your provider issues JWTs,fromJWT()is a ready-madeauthenticate. See How Auth Works in Jazz.- Admin and backend routes never leave the process: from outside, only sync and sign-in are
reachable under
/jazz. createJazzopens nothing when your module loads: the store opens on first use, such as the first request orjazz.dbcall, and there's one Jazz per store per process. So it's safe at a module's top level everywhere, including Next.js, whose build and dev workers import your modules too.await jazz.ready()opens it right away, for a server that should fail at startup rather than on its first request.
On start, your backend applies its schema, migrations and permissions to the app on Jazz Cloud. For each request it tells Jazz Cloud who the user is, which is why authenticate lives in your backend. Browsers sync with your backend under /jazz, never with Jazz Cloud directly.
Mount it
app/jazz/[...path]/route.tsimport { jazz } from "@/jazz.server";export const { GET, POST, DELETE } = jazz.next();
Install ws and @vercel/functions, and add serverExternalPackages: ["jazz-napi"] to
next.config.ts so Next doesn't bundle Jazz's native module; next dev needs all three. On
Vercel, sync uses a WebSocket (through upgradeWebSocket from @vercel/functions). Under plain
next dev and next start, which can't upgrade sockets, the browser switches to an HTTP stream
on its own. With sqlite() (not vercel()), also install @vercel/sandbox for now: Next
resolves it while building even though sqlite() never loads it.
A custom Next server can't use
jazz.attach(server) yet: Next's own upgrade handler closes the /jazz sockets. Leave sockets to
the HTTP stream there.
In a server component or server action there is no Request. forReqUser only hands the
request to your authenticate, so build one from the incoming headers:
import { headers } from "next/headers";import { jazz } from "@/jazz.server";const db = await jazz.forReqUser(new Request("http://localhost", { headers: await headers() }));
Self-hosted on sqlite(), import jazz.server.ts only from the Next app itself. A custom server
file or a script that opens the same data directory in another process is refused (the
directory is locked to one process).
server.tsimport type { Server } from "node:http";import { Hono } from "hono";import { serve } from "@hono/node-server";import { jazz } from "./jazz.server";const api = new Hono();api.all(`${jazz.basePath}/*`, (c) => jazz.handle(c.req.raw));const server = serve({ fetch: api.fetch, port: 3000 });await jazz.attach(server as Server); // WebSocket upgrades on /jazz (needs `ws`)
Install ws too: attach loads it, and without it the server fails at startup with
ERR_MODULE_NOT_FOUND. serve() is typed as a server that may be HTTP/2, so attach needs the
Server cast.
server.tsimport express from "express";import { jazz } from "./jazz.server";const api = express();api.use((req, res, next) => jazz.nodeHandler(req, res, next)); // before any body parserapi.use(express.json());const server = api.listen(3000);await jazz.attach(server); // WebSocket upgrades on /jazz (needs `ws`)
Mount Jazz before express.json() or any other body parser: Jazz reads sign-in bodies itself, and
a parser that has already read one makes sign-in fail with a 500. forReqUser takes a Fetch
Request; from Express, build one with the cookies your authenticate reads:
const request = new Request(new URL(req.originalUrl, `http://${req.headers.host}`), {headers: new Headers(req.headers as Record<string, string>),});const db = await jazz.forReqUser(request);
Express 5 turns an error thrown in a route into a 500 HTML page. Map signed-out requests to a 401 with an error handler after your routes:
import { isRequestAuthenticationError } from "jazz-tools/backend";api.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => {if (isRequestAuthenticationError(err)) return res.sendStatus(401);next(err);});
On Fastify, mount Jazz in an onRequest hook, not a route: a route rejects sign-in's empty JSON
body with a 400 before your handler runs. For URLs under jazz.basePath, call reply.hijack(),
then jazz.nodeHandler(request.raw, reply.raw).
server.tsimport { jazz } from "./jazz.server";import { app } from "./schema";Bun.serve({port: 3000,routes: {"/api/todos": async (request) => {const db = await jazz.forReqUser(request);return Response.json(await db.all(app.todos));},},fetch: jazz.handle, // sync and sign-in under /jazz});
This syncs over an HTTP stream: the browser switches to it on its own. Bun closes idle
connections after 10 seconds by default; raise idleTimeout if streams drop.
For WebSockets, set upgradesSockets: true in createJazz, call server.upgrade(request) for
requests under jazz.basePath that ask for an upgrade, and hand each opened socket to
jazz.acceptSocket(request, socket). It takes a socket of the ws package's shape (send,
close, and on("message" | "close" | "error")), so pass a small adapter around Bun's
ServerWebSocket.
server.tsimport { serve } from "@hono/node-server";import { Effect, ManagedRuntime } from "effect";import { HttpRouter, HttpServerResponse } from "effect/http";import { Jazz, JazzBackend } from "jazz-tools/effect/backend";import { jazz } from "./jazz.server";import { app } from "./schema";const listTodos = HttpRouter.add("GET","/api/todos",Effect.gen(function* () {const jazz = yield* Jazz;return yield* HttpServerResponse.json(yield* jazz.all(app.todos));}).pipe(JazzBackend.forCurrentReqUser()), // as the signed-in user);await jazz.ready(); // open the store before Effect handlers use itconst runtime = ManagedRuntime.make(JazzBackend.layerJazz(jazz));const services = await runtime.context();const { handler } = HttpRouter.toWebHandler(listTodos);const server = serve({port: 3000,fetch: (request) =>new URL(request.url).pathname.startsWith("/jazz/")? jazz.handle(request) // sync and sign-in under /jazz: handler(request, services),});await jazz.attach(server); // WebSocket upgrades on /jazz (needs `ws`)
JazzBackend.layerJazz hands your createJazz instance to Effect handlers, and /jazz is
served by Jazz itself next to your routes. See Effect for the Jazz
service.
Connect the browser
The browser talks to /jazz on the same origin by default, so there's no serverUrl to set, and your
app's own session cookie is all it needs. It needs no Jazz API key or token:
App.tsx<JazzProvider signedOut={<SignIn />}><App /></JazzProvider>
The client asks your backend who is signed in: authenticate answers, and the client gets the
account, identity and claims as plain data. It asks again when the app starts, 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(). Every sync connection asks authenticate again, so a
sign-out on the server or changed claims apply without client code. See
How Auth Works in Jazz.
Trusted backend use
jazz.db acts with backend authority: it sees and writes everything, so check access in your own
code first. Use it for system work: jobs, webhooks, migrations, admin routes.
import { jazz } from "./jazz.server";import { app } from "./schema";await jazz.db.insert(app.auditLog, { event: "invoice.paid" });
User-scoped use
await jazz.forReqUser(request) runs your authenticate and returns a database with that user's
policies and authorship. It's the default for anything a user triggers: route handlers, server
actions, server components. Concurrent requests stay isolated.
app/api/todos/route.tsimport { jazz } from "@/jazz.server";import { app } from "@/schema";export async function GET(request: Request) {const db = await jazz.forReqUser(request);return Response.json(await db.all(app.todos));}
For a signed-out request, forReqUser throws a RequestAuthenticationError (code: "signed_out").
Map it to a 401 rather than a 500:
import { isRequestAuthenticationError } from "jazz-tools/backend";try {const db = await jazz.forReqUser(request);// …} catch (error) {if (isRequestAuthenticationError(error)) return new Response(null, { status: 401 });throw error;}
When a route acts with backend authority on a user's behalf (placing an order the user can't write
directly, say), await jazz.accountFor(request) names that user's account, or null when signed
out or when the user has no Jazz account yet, so the backend's db can write rows that point at it.
Users without an account
Your backend never creates an account on its own. A user gets one when they sign in from a Jazz
client, when a guest links to them, or when you call
jazz.createAccountFor(userId). Until then, forReqUser and withAttribution throw a
NoJazzAccountError (code: "no_account", with the userId), and accountFor returns null.
For users who never open a client, such as API-only users, webhooks or imports, create the account yourself, for example in your sign-in route. It returns the account id, and calling it again returns the same one:
const user = await authenticate(request);if (!user) return new Response(null, { status: 401 });const account = await jazz.createAccountFor(user.userId);
Once a user has an account, a guest can no longer link to them, so don't call createAccountFor in
a sign-up flow whose browser may hold a local-first guest. To tell the error apart, use
isNoJazzAccountError(error) from jazz-tools/backend.
A browser whose user no longer has an account on the core, for example after a reset dev store or on a new core, signs in again when sync refuses it, instead of reconnecting forever.
Backend clients
Other trusted machines, such as a job runner, a worker on another cloud or a partner's service,
connect to this backend's /jazz as backend clients, with their own
machine-to-machine credential. List the ones you trust in createJazz({ backendClients }).
Coming next
- Limits and pricing
- Moving an app off Jazz Cloud onto your own deployment