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" }),
authenticate: async (request) => /* your session */ null,
});
  • app and permissions are 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? }, or null when signed out. It is the only place that decides who a user is: sign-ins, sync connections and forReqUser all ask it. There's no Jazz token in the browser. 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). In a CommonJS package, such as an Expo app's, a default import like permissions arrives as { default }, and createJazz fails with "permissions.ts defines permissions for unknown table(s): default". Run the backend from its own ESM package instead.

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 AWS S3, Cloudflare R2 and MinIO. 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

That's the whole deployment:

  • No separate process and no port. Sync is served by your own HTTP server under /jazz.
  • No deploy step. On start, Jazz applies the schema, migrations and permissions your code declares. If they can't be applied (a schema with no migration path), the process refuses to start instead of serving with the wrong rules.
  • No secrets to manage. Browsers hold no Jazz credential: your authenticate function decides who each request is (see How Auth Works in Jazz).

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, 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.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 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 }).

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
}),
authenticate: async (request) => /* your session */ 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.

Coming next