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

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

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
export 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.ts
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();
  • appId identifies the app namespace for storage and sync.
  • app is your typed schema export.
  • permissions is the server-side policy bundle.
  • serverUrl identifies the required reachable core; initial: { backendSecret } admits the backend account before opening its native client.
  • jwksUrl verifies external JWTs inside await client.forRequest(req). Without it, the backend only accepts Jazz local-first tokens unless you set allowLocalFirstAuth: false. It does not currently accept anonymous bearer tokens.
  • dataPath controls 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.

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

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

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

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

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