Install

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.

Terminal
mkdir my-jazz-app && cd my-jazz-app
pnpm 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.

Terminal
pnpm add jazz-tools@alpha jazz-napi@alpha hono @hono/node-server
pnpm add -D typescript tsx

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.ts
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:

permissions.ts
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:

Terminal
pnpm dlx jazz-tools@alpha create app
# outputs a UUID like: 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.ts
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();
  • appId identifies the app namespace for storage and sync.
  • app is your typed schema export.
  • permissions is the server-side policy bundle.
  • serverUrl + backendSecret let request-scoped handles sync through a Jazz server.
  • jwksUrl verifies external JWTs inside await context.forRequest(req). Without it, the backend only accepts Jazz self-signed tokens unless you set allowLocalFirstAuth: false.
  • dataPath controls 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.

src/index.ts
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.

src/index.ts
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.

src/index.ts
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:

src/index.ts
serve({ fetch: api.fetch, port: 3000 }, (info) => {
  console.log(`Server running on http://localhost:${info.port}`);
});
Terminal
npx tsx src/index.ts

Try it out with a self-signed dev token:

Terminal
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-auth in production
  • External auth requires --jwks-url, and TypeScript backends using forRequest() also need jwksUrl on createJazzContext(...)

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

On this page