Your Own Server

Learn how to run Jazz in your own Node server, VM or container, with the data in SQLite on its disk.

On your own machine, Jazz is part of your backend's process, with the data in a directory on its disk.

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

Terminal
npm 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.

Create Jazz once

jazz.server.ts
import { createJazz, sqlite } from "jazz-tools/backend";
import { app } from "./schema";
import permissions from "./permissions";
​
export const jazz = createJazz({
app,
permissions,
store: sqlite({ path: process.env.JAZZ_DATA_DIR ?? "./data/jazz" }),
// Read your session; return { userId, expiresAt, claims? } for a signed-in user, or null
authenticate: async (request) => null,
});
  • app defines your schema, and permissions defines who can access the data. Your backend uses the versions your code imports (so rolling back your application also returns to the schema and permissions of that version).
  • authenticate(request) reads your own session (a cookie, a header) and returns { userId, expiresAt, claims? } (expiresAt: when that session expires, in Unix seconds), or null when signed out. Jazz uses it for every operation that needs authentication: sign-ins, sync connections and forReqUser. If your provider issues JWTs, fromJWT() is a ready-made authenticate. 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.
  • createJazz opens nothing when your module loads: the store opens on first use, such as the first request or jazz.db call, 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.

The examples on this page are ES modules and use top-level await, so the backend package needs "type": "module" (or .mts files). schema.ts and permissions.ts must be ES modules too. In a CommonJS package, such as an Expo app's, importing them gives { default }, and createJazz fails with "permissions.ts defines permissions for unknown table(s): default". A backend in its own ESM package isn't enough while those two files stay in the CommonJS package: put them in a folder (for example jazz/) with a package.json of { "type": "module" }, and import them from there in both the app and the backend.

Large values

Large values, such as files and long streams, are stored in chunks. Rows and the chunk index always stay in SQLite. By default sqlite() keeps the chunks as files in <path>/blobs. Choose where they go with blobs:

import { files, s3, sqlite } from "jazz-tools/backend";
​
sqlite({ path: "./data/jazz" }); // chunks in ./data/jazz/blobs
sqlite({ path: "./data/jazz", blobs: files({ dir: "/mnt/blobs" }) }); // a directory of your choice
sqlite({ path: "./data/jazz", blobs: s3() }); // an S3-compatible bucket
sqlite({ path: "./data/jazz", blobs: "sqlite" }); // inside the SQLite database (not recommended)

"sqlite" works, but the database file grows with every large value.

s3() works with any S3-compatible service, such as AWS S3 or Cloudflare R2. All its options are optional:

OptionDefaultWhat it does
bucketS3_BUCKET_NAMEThe bucket
regionAWS_REGIONThe bucket's region; auto for R2
endpointAWSAnother S3-compatible endpoint, such as R2's
prefixjazz/<store id>/Prepended to every object name
// Cloudflare R2
s3({
bucket: "my-app",
region: "auto",
endpoint: "https://<account>.r2.cloudflarestorage.com",
});

The backend reads its credentials from AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, or from AWS_ROLE_ARN with AWS_WEB_IDENTITY_TOKEN_FILE. Each chunk is uploaded with its SHA-256, so the bucket rejects a corrupted upload. One bucket can serve many apps and environments: each data directory keeps its objects under its own prefix, claims that prefix the first time it uses it, and deletes the chunks it no longer needs. Another data directory is refused on a prefix that is already claimed.

The data directory records its choice in <path>/blob-store. You can move an existing directory from "sqlite" to files() or s3() later; any other switch is not supported yet and refuses to start, so treat it as a one-way choice. A blob directory belongs to exactly one data directory, so two data directories can't share one. To copy a data directory, give the copy its own blob directory or prefix and delete its store-id and blob-store files before it first starts, or it claims the original's.

History keeps every version of a row, so deleting rows doesn't free their large values, and there is no way to erase them yet. Expect blob storage to only grow.

Mount it

app/jazz/[...path]/route.ts
import { 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).

A separate frontend dev server

When the frontend runs on its own dev server (Vite, say) and the backend on another port, proxy /jazz and your auth routes to the backend so the browser stays on one origin, with WebSockets on:

vite.config.ts
import { defineConfig } from "vite";
​
export default defineConfig({
server: {
proxy: {
"/jazz": { target: "http://localhost:3000", ws: true },
"/api/auth": { target: "http://localhost:3000" },
},
},
});

With Better Auth, add the dev server's origin to trustedOrigins, or sign-in answers 403 INVALID_ORIGIN (see Better Auth).

Keep ws: true on: a proxy that leaves the WebSocket upgrade hanging costs about 20 seconds on every page load before the client falls back to an HTTP stream.

In production, serve the built frontend from the same origin as the backend (or put both behind one proxy that passes WebSocket upgrades through).

Deploy it

Deploy your backend as usual. Jazz serves sync requests through your HTTP server at /jazz. On startup, Jazz applies the schema, migrations and permissions defined in your code. If it can't apply them, for example because a migration is missing, it won't start.

Keep the data directory on a durable disk (a Docker volume, an attached block device), and run one process per directory: a second process on the same directory is refused (<path>/jazz.lock). sqlite() refuses to start on serverless hosts with no lasting disk (Vercel, AWS Lambda, Netlify); on Vercel, use vercel(). Jazz creates the directory's files readable by every user on the machine (mode 0644), so on a shared host restrict the directory itself to the service's user (for example chmod 700).

Dockerfile
FROM node:22-slim
WORKDIR /app
COPY . .
RUN npm ci && npm run build
ENV JAZZ_DATA_DIR=/data/jazz
VOLUME /data
CMD ["node", "dist/server.js"]

On shutdown, close Jazz so writes still in flight reach the store and open sockets end cleanly:

server.ts
process.on("SIGTERM", () => {
void jazz.close().then(() => server.close());
});

Proxies, paths and origins

These createJazz options cover a backend behind a proxy or on a non-default path:

OptionDefaultWhat it does
basePath"/jazz"The path Jazz is served under. Mount your routes with jazz.basePath, and pass the same path as serverUrl in the browser
publicUrlthe request's hostThe URL (or URLs) browsers reach Jazz at, when a proxy changes the host or path. Recommended in production; list your dev URL too if it uses this backend
allowedOriginsthe backend's ownOther browser origins that may sync and sign in
upgradesSocketsfalseSet when your host upgrades sockets itself and passes them to jazz.acceptSocket (Bun, Deno, your own ws); attach() and Vercel set it on their own

A proxy in front of your backend must pass WebSocket upgrades through to /jazz. Pages on another origin can't sign in with your app's cookie yet, even with allowedOrigins; serve the page and /jazz from the same origin for now.

Connect the browser

The browser talks to /jazz on the same origin by default and signs in with your app's own session cookie:

App.tsx
<JazzProvider config={{ serverUrl: "/jazz" }} 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. Your backend decides who is signed in; after your own sign-in or sign-out, call refresh() from useJazzSession() and the session follows its answer. 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.

One handle per request

Call jazz.forReqUser(request) in each request that needs it. Don't keep the handle it returns in module-level state: it belongs to that request's user.

app/api/todos/route.ts
import { 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 guest's device account. 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 }).

Data on another machine

When the data should live apart from your app servers (several app instances sharing one database, serverless functions, an existing Jazz Cloud app), keep it in Jazz Cloud and point createJazz at it with remote():

jazz.server.ts
import { 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
}),
// Read your session; return { userId, expiresAt, claims? } for a signed-in user, or null
authenticate: async (request) => null,
});

Everything else stays the same. Browsers still sync with your backend under /jazz and never see the core's address or secret. Your backend sends its schema, migrations and permissions to the core on start, and tells the core who each user is; the core does no auth of its own and trusts only the holder of the app's admin secret. Keep it server-side, like a database password.

Jazz always runs inside a backend like this one. There is no separate Jazz server to install or run.

Coming from the alpha.59 server

Alpha.59 ran Jazz as its own process (jazz-tools server), and clients connected to its port (:1625/ by default). In alpha.60 that process is gone:

  • Clients sync with your backend under /jazz, on your app's own origin, instead of with a separate Jazz server.
  • --jwks-url and the other JWT flags move into authenticate. Pass the same values to fromJWT().
  • jazz-tools deploy is gone. Your backend applies its schema, migrations and permissions each time it starts.
  • Move the data directory with the import tool (see Upgrading from alpha.59). The import keeps large values inside jazz.sqlite. On its first start your backend moves them to blobs/, or wherever blobs points, without logging anything. Run the import's verify before that first start.