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.
mkdir my-jazz-app && cd my-jazz-app
pnpm initInstall
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.
pnpm add jazz-tools@alpha jazz-napi@alpha hono @hono/node-server
pnpm add -D typescript tsxOn 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.
import { 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:
import { 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:
pnpm dlx jazz-tools@alpha create app
# outputs a UUID like: 019d0ba1-519a-7e01-b0eb-0059ee898e4d
export JAZZ_APP_ID=019d0ba1-519a-7e01-b0eb-0059ee898e4dCreate src/index.ts. This quickstart puts everything in one file; a real app would split across multiple modules.
import { 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();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.
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.
api.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);
});Update and delete to-dos
Use db.update and db.delete to modify existing rows.
api.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 });
});Full mutation API: Writing Data.
List to-dos
Use db.all to query rows. The query builder supports filtering, sorting, and pagination.
api.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);
});Full query API: Reading Data.
Run it
Start the server at the bottom of src/index.ts:
serve({ fetch: api.fetch, port: 3000 }, (info) => {
console.log(`Server running on http://localhost:${info.port}`);
});npx tsx src/index.tsTry it out with a self-signed dev token:
TOKEN=$(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"forRequest verifies the caller's bearer token inside the backend context. The token above is a
Jazz self-signed dev token, which works because allowLocalFirstAuth defaults to true. If you
want to accept external JWTs from your auth provider, also set jwksUrl so the backend can verify
them via JWKS.
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