Vercel
Learn how to run Jazz inside your Next.js app on Vercel, with each environment's data in its own Sandbox.On Vercel, your Next.js app is the whole deployment. The vercel() store keeps each environment's
data in a persistent Vercel Sandbox that your functions
start on demand. There's no separate service to set up and no secrets to copy.
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.
Set it up
Install the Sandbox SDK next to jazz-tools:
Terminalpnpm add @vercel/sandbox
Use the vercel() store:
jazz.server.tsimport { createJazz, vercel } from "jazz-tools/backend";import { app } from "./schema";import permissions from "./permissions";export const jazz = createJazz({app,permissions,store: vercel(),authenticate: async (request) => {const session = await getSession(request); // your own authreturn 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.
In a Next.js app, install ws and @vercel/functions (both also needed under next dev) and keep
Jazz's native module out of Next's server bundle:
Terminalnpm install ws @vercel/functions
next.config.tsimport type { NextConfig } from "next";const nextConfig: NextConfig = {// Your backend loads Jazz's native module; Next must not bundle it.serverExternalPackages: ["jazz-napi"],};export default nextConfig;
Mount it with a catch-all route under /jazz, where clients connect by default:
app/jazz/[...path]/route.tsimport { jazz } from "@/jazz.server";export const { GET, POST, DELETE } = jazz.next();
Then deploy as usual with git push or vercel deploy. Your functions authenticate to Vercel with
their own OIDC token, and the core's secrets are generated inside the Sandbox, so there are no
environment variables to set.
On Vercel, sync uses a WebSocket (through upgradeWebSocket from @vercel/functions). Under plain
next dev, which can't upgrade sockets, the browser
switches to an HTTP stream on its own.
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 }).
Large values
Large values, such as files and long streams, are stored in chunks outside the core's database.
On Vercel, vercel() picks where they go:
- Vercel Blob, when a Blob store is connected to the project (the Storage tab in the Vercel
dashboard sets
BLOB_STORE_ID). A store created through the Vercel API or CLI sets onlyBLOB_READ_WRITE_TOKEN, so pass its id asvercelBlob({ storeId }). Nothing else to set up: browsers send and fetch large values through your/jazzroute, and the core reads and writes the blobs itself with your function's OIDC token. Blobs are private, and each is checked against its SHA-256 when read. - The core's SQLite file on its Drive, when no Blob store is connected. This works, but the file grows with every large value, and the backend logs a warning saying how to connect one.
To choose yourself, pass blobs:
jazz.server.tsimport { s3, vercel, vercelBlob } from "jazz-tools/backend";vercel(); // Vercel Blob if a Blob store is connected, otherwise SQLitevercel({ blobs: vercelBlob({ storeId: "store_..." }) }); // a specific Blob storevercel({ blobs: s3() }); // an S3 bucket connected through the Vercel Marketplacevercel({ blobs: "sqlite" }); // always the core's SQLite file
s3() reads S3_BUCKET_NAME, AWS_REGION and AWS_ROLE_ARN, which the Marketplace's AWS
integration sets, and the core assumes that role with your function's OIDC token. The bucket can be in
any region: on Vercel, AWS_REGION is the function's region, so if it doesn't match the bucket, the
backend learns the bucket's region from S3's first reply, signs for it from then on, and logs a
warning suggesting you set AWS_REGION to it. Putting the bucket in your Sandbox's region still
keeps latency and transfer costs down. Its options are the same
as for your own server.
Each Blob store or bucket serves one Vercel environment. A store you connect spans every
environment unless you limit it, so create one store per environment, connect each to its own
environment only, and enable OIDC for each environment in the project's settings. A store created
through the API or CLI is connected to production, preview and development alike, and a second
one fails with project_env_var_not_unique; connect and limit stores in the dashboard instead. A core given another environment's store refuses to start, for
example "Blob store … serves the production environment, not preview; connect a separate Blob store
to each environment". Both vercelBlob() and s3() also take a prefix, by default
jazz/<store id>/.
Where large values go is a one-way choice. A core that started on SQLite can move to Vercel Blob or S3 later: connect a store and redeploy. Every other change, including disconnecting or swapping the Blob store, moving between Vercel Blob and S3, or moving back to SQLite, is not supported yet and the core refuses to start.
Upload files from the browser with db.insertStreaming(..., file.stream()), which goes through
/jazz (see Uploading and downloading files),
not through a route of your own: Vercel refuses a function request body over 4.5 MB with
413 FUNCTION_PAYLOAD_TOO_LARGE, so an upload route that passes request.body to
insertStreaming works locally and fails on Vercel. Downloads through your own route are fine.
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.
A change to the vercelBlob() or s3() options doesn't reach a core that is already running the
same jazz-tools version. It applies the next time the core starts: after it stops when idle, or
when you deploy a new jazz-tools version.
Local development
Outside Vercel, vercel() uses a local store instead, by default sqlite({ path: ".jazz/local" }).
Only production and preview deployments use the Sandbox: next dev, vercel dev and an
environment pulled with vercel env pull all run against a SQLite file on your machine. Pass local to
choose a different one, such as vercel({ local: sqlite({ path: ".jazz/dev" }) }) to keep data you
already have.
Add .jazz/ to .gitignore.
Use vercel() in development too, rather than switching to sqlite() yourself: sqlite() refuses
to start on Vercel, which has no lasting disk.
Only one process can open a SQLite directory at a time. If a second one tries, it fails with an
error saying another process holds the directory, usually a second dev server. Stop it, or give
each server its own path.
Options
All options are optional.
| Option | Default | What it does |
|---|---|---|
name | jazz-<VERCEL_PROJECT_ID>-production, or -preview-<branch>-<hash> | The Sandbox and Drive name, one per production or preview branch |
region | iad1 | Where the Sandbox runs; put it near your functions |
vcpus | 1 | Sandbox size, 2 GB of memory per vCPU |
maxDriveBytes | 10 GiB | The most data the environment's Drive holds |
idleMs | 5 minutes | How long the Sandbox stays up with no requests or clients |
coreTarballUrl | the Linux jazz-napi package for your installed jazz-tools version | Which Jazz core build the Sandbox runs |
blobs | vercelBlob() if a Blob store is connected, otherwise "sqlite" | Where large values go; see Large values |
local | sqlite({ path: ".jazz/local" }) | The store used outside Vercel |
The default name uses Vercel's system environment variables, which are exposed by default. If
they aren't, vercel() throws and asks you to pass name. While vercel() throws, the browser
stays on its loading state, so check the function logs when a deployment never finishes loading. It also throws for a preview with no
Git branch, such as a deploy through the Vercel API or from the CLI outside a Git checkout: pass
name to give such previews a core of their own.
The default coreTarballUrl covers every published jazz-tools version, release candidates
included; set it only for a build you made yourself. If the Sandbox can't download the core, the
function's error says so and the Sandbox is stopped. A Sandbox that already holds another
app's core is refused.
Limits
- Production and each preview branch get one persistent Sandbox and its Drive.
Two apps in one project need their own
name. The smallest size is 2 GB of memory and 1 vCPU. - Production has one core. Each Git branch's previews get their own core and data
(
jazz-<VERCEL_PROJECT_ID>-preview-<branch>-<hash>). Nothing cleans up the Sandboxes and Drives of merged or deleted branches yet; delete them in the Vercel dashboard. - The first request in an environment takes longer: it creates the Sandbox and downloads the Jazz server onto the Drive. Vercel's own checks of a new deployment can make that first request, or wake a stopped Sandbox, so the cold start may already be over when you first open the page.
- The Sandbox stops after 5 minutes with no requests and no connected clients; a connected client keeps it awake, even when idle. The next request wakes it, which took 0.6 to 1 s in our benchmarks and 4.2 s on a real project; requests after the wake took about 0.45 s.
- Vercel ends a function's WebSockets at the route's
maxDuration(300 s by default). Just before that,jazz.next()closes each sync socket and clients reconnect at once. To reconnect less often, raise the limit in the route file withexport const maxDuration = 800;(Pro plan). - A redeploy on the same
jazz-toolsversion doesn't disturb connected clients. Deploying a newerjazz-toolsrestarts the core on that version, which briefly drops open sockets; clients reconnect. An older deployment that is still running never moves the core back. - A thin client resends unanswered writes after its socket reconnects, and each lands once; a
transaction in flight at the drop ends with
ThinConnectionLostError. See Pending writes. vercel()needs Next.js 16 for now: under Next 15's webpack build, every request fails with "Cannot find module 'jazz-tools/package.json'".