TypeScript Server
Build a server-side to-do API with Jazz and Hono, step by step.Create a project
Start with a fresh project. If you already have one, skip to Install.
Terminalmkdir my-jazz-app && cd my-jazz-apppnpm init
Install
jazz-napi is the native runtime for Jazz on Node.js. It bundles the query engine, storage, and sync layer as a Rust binary via NAPI. jazz-tools detects it automatically at runtime, but it must be listed as an explicit dependency.
Terminalpnpm add jazz-tools@alpha jazz-napi@alpha hono @hono/node-serverpnpm add -D typescript tsx
On Linux, GNU native bindings support x64 and ARM64 with glibc ≥ 2.34 and
libstdc++ providing GLIBCXX_3.4.29 / CXXABI_1.3.13, including AL2023.
Alpine/musl is not a GNU NAPI target. Standalone CLI binaries have a separate
support matrix. See the Linux native package requirements
for the build baseline and verification details.
Define your schema
Create schema.ts at the root of your project (or src/lib/schema.ts for SvelteKit). This is the source of truth for your data model.
schema.tsimport { schema as s } from "jazz-tools";const schema = {projects: s.table({name: s.string(),},{ todos: s.reverse("todos", "project") },),todos: s.table({title: s.string(),done: s.boolean(),description: s.string().optional(),parentId: s.uuid().optional(),projectId: s.uuid().optional(),owner_id: s.uuid(),},{parent: s.rel("todos", "parentId"),children: s.reverse("todos", "parent"),project: s.rel("projects", "projectId"),},),};type AppSchema = s.Schema<typeof schema>;export const app: s.App<AppSchema> = s.defineApp(schema);
The most common DSL column builders are s.string(), s.boolean(), s.int(), s.float(), s.timestamp(), s.bytes(), s.uuid(), s.array(s.string()), s.enum("a", "b"), s.json(), and s.json(schema).
For the full TypeScript/SQL mapping table, see Schemas: available column types.
Validate schema
Validate your schema and permissions locally:
pnpm dlx jazz-tools@alpha validate
This validates schema.ts and permissions.ts, then compiles them into Jazz's
internal schema representation.
When Jazz reports a difference between the old and new schema hashes after changing schema.ts, and your app already has data, create a migration and deploy the updated app as described in Migrations:
pnpm dlx jazz-tools@alpha migrations create <appId> --fromHash <fromHash> --toHash <toHash>pnpm dlx jazz-tools@alpha deploy <appId>
Learn more about schemas, optional validation, and migrations.
Add permissions
Every operation requires an explicit permission grant. Omitted operations are denied, including on tables with no policy declarations. For this quickstart, explicitly allow all four operations:
permissions.tsimport { schema as s } from "jazz-tools";import { app } from "./schema.js";export default s.definePermissions(app, ({ policy }) => {policy.todos.allowRead.always();policy.todos.allowInsert.always();policy.todos.allowUpdate.always();policy.todos.allowDelete.always();});
Set up your server
Generate an app ID:
Terminalpnpm dlx jazz-tools@alpha create app# outputs a UUID like: 019d0ba1-519a-7e01-b0eb-0059ee898e4dexport JAZZ_APP_ID=019d0ba1-519a-7e01-b0eb-0059ee898e4d
Create src/index.ts. This quickstart puts everything in one file; a real app would split across multiple modules.
src/index.tsimport { Hono } from "hono";import { serve } from "@hono/node-server";import { createJazzSession } from "jazz-tools/backend";import { app as schemaApp } from "../schema.js";import permissions from "../permissions.js";const session = await createJazzSession({appId: process.env.JAZZ_APP_ID ?? "todo-server-ts",app: schemaApp,permissions,driver: { type: "persistent", dataPath: "./data/jazz.db" },serverUrl: process.env.JAZZ_SERVER_URL!,initial: { backendSecret: process.env.JAZZ_BACKEND_SECRET! },jwksUrl: process.env.JAZZ_JWKS_URL,jwtPublicKey: process.env.JAZZ_JWT_PUBLIC_KEY,allowLocalFirstAuth: process.env.JAZZ_ALLOW_LOCAL_FIRST_AUTH !== "false",});const snapshot = session.getSnapshot();if (snapshot.status !== "ready" || !snapshot.client) {throw snapshot.error ?? new Error("Backend session is not ready");}const client = snapshot.client;const api = new Hono();
src/index.tsimport { serve } from "@hono/node-server";import { Config, Effect, Layer, ManagedRuntime, Option, Schema } from "effect";import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";import { Jazz, JazzBackend } from "jazz-tools/effect/backend";import { app as schemaApp } from "../schema.js";import permissions from "../permissions.js";// One backend session for the whole server, closed when the server stops.const JazzLive = Layer.unwrap(Effect.gen(function* () {return JazzBackend.layer({appId: yield* Config.String("JAZZ_APP_ID").pipe(Config.withDefault("todo-server-effect")),app: schemaApp,permissions,driver: { type: "persistent", dataPath: "./data/jazz.db" },serverUrl: yield* Config.String("JAZZ_SERVER_URL"),initial: { backendSecret: yield* Config.String("JAZZ_BACKEND_SECRET") },jwksUrl: Option.getOrUndefined(yield* Config.option(Config.String("JAZZ_JWKS_URL"))),allowLocalFirstAuth: yield* Config.Boolean("JAZZ_ALLOW_LOCAL_FIRST_AUTH").pipe(Config.withDefault(true),),});}),);const TodoInput = Schema.Struct({ title: Schema.String });const TodoPatch = Schema.Struct({ done: Schema.Boolean });const TodoParams = Schema.Struct({ id: Schema.String });
appIdidentifies the app namespace for storage and sync.appis your typed schema export.permissionsis the server-side policy bundle.serverUrlidentifies the required reachable core;initial: { backendSecret }admits the backend account before opening its native client.jwksUrlverifies external JWTs insideawait client.forRequest(req). Without it, the backend only accepts Jazz local-first tokens unless you setallowLocalFirstAuth: false. It does not currently accept anonymous bearer tokens.dataPathcontrols where local server state persists.
For this TypeScript backend, use a direct HTTPS jwksUrl. HTTP is allowed for
development only when the URL's WHATWG-canonical hostname is localhost,
[::1], or an IPv4 address in 127.0.0.0/8 (for example,
http://127.0.0.1:3000/api/auth/jwks). HTTP with the trailing-dot spelling
localhost., other schemes, and remote HTTP are rejected before fetching keys.
The backend rejects every redirect, even to another HTTPS URL, so configure your
provider's final JWKS URL rather than a redirecting URL.
Each route handler awaits client.forRequest(c.req) to get a database handle with permissions scoped to the request.
For server-owned work, use the ready snapshot's client.db. Backend admission requires the core
even with a memory driver; keep the secret server-side and provide it through initial or session.becomeBackend(...). Server Setup
covers those patterns in more detail.
The persistent driver stores data on disk through the native jazz-napi runtime. In the
current Node.js setup that means SQLite-backed local persistence. dataPath is the directory
where that local database lives.
There is also a memory driver, which does not persist data. To use it, set a serverUrl pointing to an upstream peer that can persist the data.
Add a to-do
Add each of the following snippets to src/index.ts, below the setup code.
Use db.insert to create a new row.
src/index.tsapi.post("/api/todos", async (c) => {const db = await client.forRequest(c.req);const session = db.getAuthState().session;if (!session?.user.account) return c.json({ error: "Account required" }, 401);const { title } = await c.req.json();const { value: todo } = db.insert(schemaApp.todos, {title,done: false,owner_id: session.user.account,});return c.json(todo, 201);});
src/index.ts// Each handler reads `Jazz`; `forCurrentRequest` provides it as the user who// sent the request, with that user's permissions.const createTodo = HttpRouter.add("POST","/api/todos",Effect.gen(function* () {const jazz = yield* Jazz;// A missing or invalid bearer token fails `forCurrentRequest` before this// runs, and currently surfaces as a 500 (typed auth errors: #3655).const account = jazz.db.getAuthState().session?.user.account;if (!account) {return yield* HttpServerResponse.json({ error: "Account required" }, { status: 401 });}const { title } = yield* HttpServerRequest.schemaBodyJson(TodoInput);const todo = yield* jazz.insert(schemaApp.todos, { title, done: false, owner_id: account });return yield* HttpServerResponse.json(todo, { status: 201 });}).pipe(JazzBackend.forCurrentRequest()),);
Update and delete to-dos
Use db.update and db.delete to modify existing rows.
src/index.tsapi.patch("/api/todos/:id", async (c) => {const db = await client.forRequest(c.req);const { id } = c.req.param();const { done } = await c.req.json();db.update(schemaApp.todos, id, { done });return c.json({ ok: true });});api.delete("/api/todos/:id", async (c) => {const db = await client.forRequest(c.req);const { id } = c.req.param();db.delete(schemaApp.todos, id);return c.json({ ok: true });});
src/index.tsconst updateTodo = HttpRouter.add("PATCH","/api/todos/:id",Effect.gen(function* () {const jazz = yield* Jazz;const { id } = yield* HttpRouter.schemaPathParams(TodoParams);const { done } = yield* HttpServerRequest.schemaBodyJson(TodoPatch);yield* jazz.update(schemaApp.todos, id, { done });return yield* HttpServerResponse.json({ ok: true });}).pipe(JazzBackend.forCurrentRequest()),);const deleteTodo = HttpRouter.add("DELETE","/api/todos/:id",Effect.gen(function* () {const jazz = yield* Jazz;const { id } = yield* HttpRouter.schemaPathParams(TodoParams);yield* jazz.delete(schemaApp.todos, id);return yield* HttpServerResponse.json({ ok: true });}).pipe(JazzBackend.forCurrentRequest()),);
Full mutation API: Writing Data.
List to-dos
Use db.all to query rows. The query builder supports filtering, sorting, and pagination.
src/index.tsapi.get("/api/todos", async (c) => {const db = await client.forRequest(c.req);const todos = await db.all(schemaApp.todos.where({ done: false }).orderBy("title", "asc").limit(100),);return c.json(todos);});
src/index.tsconst listTodos = HttpRouter.add("GET","/api/todos",Effect.gen(function* () {const jazz = yield* Jazz;const todos = yield* jazz.all(schemaApp.todos.where({ done: false }).orderBy("title", "asc").limit(100),);return yield* HttpServerResponse.json(todos);}).pipe(JazzBackend.forCurrentRequest()),);
Full query API: Reading Data.
Run it
Start the server at the bottom of src/index.ts:
src/index.tsserve({ fetch: api.fetch, port: 3000 }, (info) => {console.log(`Server running on http://localhost:${info.port}`);});
src/index.tsconst Routes = Layer.mergeAll(createTodo, listTodos, updateTodo, deleteTodo);// `toWebHandler` turns the routes into a fetch handler that any Node or edge// server can host. The Jazz session is opened once and shared by every request.const jazzRuntime = ManagedRuntime.make(JazzLive);const services = await jazzRuntime.context();const { handler, dispose } = HttpRouter.toWebHandler(Routes);const server = serve({ fetch: (request) => handler(request, services), port: 3000 }, (info) => {console.log(`Server running on http://localhost:${info.port}`);});process.on("SIGTERM", () => server.close(() => void dispose().then(() => jazzRuntime.dispose())));
Terminalnpx tsx src/index.ts
Try it out with a self-signed dev token:
TerminalTOKEN=$(node -e 'const { mintLocalFirstToken } = require("jazz-napi"); const seed = Buffer.alloc(32, 7).toString("base64url"); console.log(mintLocalFirstToken(seed, process.env.JAZZ_APP_ID ?? "todo-server-ts", 3600));')curl -X POST http://localhost:3000/api/todos \-H "Content-Type: application/json" \-H "Authorization: Bearer $TOKEN" \-d '{"title": "Buy milk"}'curl http://localhost:3000/api/todos \-H "Authorization: Bearer $TOKEN"curl -X PATCH http://localhost:3000/api/todos/<id> \-H "Content-Type: application/json" \-H "Authorization: Bearer $TOKEN" \-d '{"done": true}'curl -X DELETE http://localhost:3000/api/todos/<id> \-H "Authorization: Bearer $TOKEN"
Authentication
- Local-first auth is enabled by default in development and requires
--allow-local-first-authin production - External auth requires
--jwks-url, and TypeScript backends usingforRequest()also needjwksUrloncreateJazzSession(...)
Next steps
- Authentication — identity providers, JWKS, and session resolution
- Permissions — row-level access policies
- Queries — filtering, sorting, pagination, and relations
- Server Setup — hosting, sync, and deployment