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

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.

Set it up

Install the Sandbox SDK next to jazz-tools:

Terminal
pnpm add @vercel/sandbox

Use the vercel() store:

jazz.server.ts
import { 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 auth
return session ? { userId: session.user.id } : 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.

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:

Terminal
npm install ws @vercel/functions
next.config.ts
import 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.ts
import { 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.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 }).

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 only BLOB_READ_WRITE_TOKEN, so pass its id as vercelBlob({ storeId }). Nothing else to set up: browsers send and fetch large values through your /jazz route, 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.ts
import { s3, vercel, vercelBlob } from "jazz-tools/backend";
​
vercel(); // Vercel Blob if a Blob store is connected, otherwise SQLite
vercel({ blobs: vercelBlob({ storeId: "store_..." }) }); // a specific Blob store
vercel({ blobs: s3() }); // an S3 bucket connected through the Vercel Marketplace
vercel({ 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.

OptionDefaultWhat it does
namejazz-<VERCEL_PROJECT_ID>-production, or -preview-<branch>-<hash>The Sandbox and Drive name, one per production or preview branch
regioniad1Where the Sandbox runs; put it near your functions
vcpus1Sandbox size, 2 GB of memory per vCPU
maxDriveBytes10 GiBThe most data the environment's Drive holds
idleMs5 minutesHow long the Sandbox stays up with no requests or clients
coreTarballUrlthe Linux jazz-napi package for your installed jazz-tools versionWhich Jazz core build the Sandbox runs
blobsvercelBlob() if a Blob store is connected, otherwise "sqlite"Where large values go; see Large values
localsqlite({ 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 with export const maxDuration = 800; (Pro plan).
  • A redeploy on the same jazz-tools version doesn't disturb connected clients. Deploying a newer jazz-tools restarts 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'".