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 tsxDefine 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.table({
title: s.string(),
done: s.boolean(),
description: s.string().optional(),
parentId: s.ref("todos").optional(),
projectId: s.ref("projects").optional(),
owner_id: s.string(),
}),
};
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.ref("table"), 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 the optional 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, you should create and push a migration edge as described in Migrations:
pnpm dlx jazz-tools@alpha migrations create <appId> --fromHash <fromHash> --toHash <toHash>
pnpm dlx jazz-tools@alpha migrations push <appId> <fromHash> <toHash>
Learn more about schemas, optional validation, and migrations.
Add permissions
The server rejects all reads and writes unless you define permissions. For this quickstart, allow everything:
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-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 { createJazzContext } from "jazz-tools/backend";
import { app as schemaApp } from "../schema.js";
import permissions from "../permissions.js";
const context = createJazzContext({
appId: "todo-server-ts",
app: schemaApp,
permissions,
driver: { type: "persistent", dataPath: "./data/jazz.db" },
serverUrl: process.env.JAZZ_SERVER_URL,
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 api = new Hono();appIdidentifies the app namespace for storage and sync.appis your typed schema export.permissionsis the server-side policy bundle.serverUrl+backendSecretlet request-scoped handles sync through a Jazz server.jwksUrlverifies external JWTs insideawait context.forRequest(req). Without it, the backend only accepts Jazz self-signed tokens unless you setallowLocalFirstAuth: false.dataPathcontrols where local server state persists.
Each route handler awaits context.forRequest(c.req) to get a database handle with permissions scoped to the request.
For server-owned work, use context.asBackend(). For embedded or local-only setups without a
server, context.db() gives you an unscoped local Db. 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 context.forRequest(c.req);
const { title } = await c.req.json();
const { value: todo } = db.insert(schemaApp.todos, {
title,
done: false,
owner_id: "system",
});
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 context.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 context.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 context.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 { mintSelfSignedToken } = require("jazz-napi"); const seed = Buffer.alloc(32, 7).toString("base64url"); console.log(mintSelfSignedToken(seed, "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 needjwksUrloncreateJazzContext(...)
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