TutorialGetting startedBookstore

Building a Bookstore API with DaloyJS From Scratch

A route-by-route walkthrough: create the project with create-daloy, model a Book with Zod, add list / create / fetch-by-id endpoints, watch validation errors arrive as RFC 9457 problem+json automatically, emit OpenAPI, generate a typed client, and write the whole test suite with app.request() — no HTTP server required.

Devlin DuldulaoFullstack cloud engineer14 min read

Hi, Devlin again. Ten years of fullstack, currently in Norway, and the post I get asked for most often is some version of just show me what a real route looks like, end to end. So this is that post. We build the canonical Bookstore API — list books, fetch one by id, create one, validate the input, ship the docs, generate a typed client, and write tests that run faster than your dev server boots. By the end of this you could plausibly hand someone a Slack link and say read this first.

One promise up front: every code snippet in here is a thing you can actually paste. No pseudocode, no "left as an exercise for the reader". The whole tutorial is the equivalent of a single afternoon of work — most of which the scaffolder does for you while you go get a coffee.

The seven steps, at a glance

1

Scaffold with create-daloy

One command. You pick the runtime and the package manager; the tool drops AGENTS.md, OpenAPI plumbing, and the test harness on disk.

2

Model the Book with Zod

A single schema is the source of truth: validation, response body, OpenAPI types, and the typed client all read from it.

3

Add the three routes

GET /books, GET /books/:id, POST /books. Throw, don't return.

4

Watch validation errors arrive for free

The framework auto-emits RFC 9457 problem+json with a errors array. You wrote zero lines for this.

5

Mount docs and serve

/docs, /openapi.json, and /openapi.yaml come up automatically when docs: true.

6

Generate the typed client

pnpm gen:openapi dumps the spec, pnpm gen turns it into a fully typed fetch SDK.

7

Test everything with app.request()

No port, no fetch, no flakes. Same App you ship.

Step 1 — Scaffold

terminal · zsh
bash
# Pick a template once, never write this glue again.
pnpm create daloy@latest bookstore-api \
  --template node-basic \
  --minimal \
  --yes

cd bookstore-api
pnpm install
pnpm dev
# ─→ listening on http://localhost:3000
# ─→ API docs       http://localhost:3000/docs
# ─→ OpenAPI JSON   http://localhost:3000/openapi.json
# ─→ Health         http://localhost:3000/healthz
create-daloy 0.x · template node-basic · minimal demo

--minimal strips the example bookstore routes from the template so we can rebuild them ourselves — pedagogy over convenience. (If you skip --minimal, the template gives you a working /books/:id route out of the box. Both paths are fine.)

tree · bookstore-api
bash
bookstore-api/
├─ src/
  ├─ build-app.ts        # pure factory: `buildApp(): App`
  ├─ index.ts            # serve(app, { port })
  └─ routes/
     └─ books.ts         # <- everything we build today
├─ tests/
  └─ books.test.ts       # node:test + app.request()
├─ scripts/
  └─ dump-openapi.ts     # writes generated/openapi.json
├─ openapi-ts.config.ts   # Hey API codegen config
├─ AGENTS.md              # rules of the road for coding agents
└─ package.json
what just landed on disk

Step 2 — Model the Book

Open src/routes/books.ts (create it if you used --minimal) and start with the schema. The single most important habit in DaloyJS: the Zod schema is the source of truth for everything — validation, response shape, OpenAPI, and the generated TypeScript types. Write it once.

src/routes/books.ts
ts
// src/routes/books.ts
import { z } from "zod";

/**
 * The on-the-wire shape of a Book.
 * Used in route responses and the GET /books list.
 */
export const Book = z.object({
  id:        z.string().uuid(),
  title:     z.string().min(1).max(200),
  author:    z.string().min(1).max(120),
  publishedAt: z.string().date(),       // "YYYY-MM-DD"
  pages:     z.number().int().positive(),
  tags:      z.array(z.string()).default([]),
});

/**
 * Payload for POST /books — server assigns the id, so it's omitted here.
 * Note how `tags` is optional but the response always has the default array.
 */
export const CreateBook = Book.omit({ id: true }).extend({
  tags: z.array(z.string()).optional(),
});

export type BookT       = z.infer<typeof Book>;
export type CreateBookT = z.infer<typeof CreateBook>;
one schema · validation + OpenAPI + types
src/routes/books.ts
ts
// src/routes/books.ts (continued)
//
// A real app uses Prisma, Drizzle, or whatever you brought from your last
// project. For the tutorial we keep an in-memory Map so the focus stays
// on the framework, not the database.
import { randomUUID } from "node:crypto";

const store = new Map<string, BookT>();

// Seed two rows so GET /books has something to return on first boot.
for (const seed of [
  { title: "Noli Me Tangere",  author: "José Rizal",   publishedAt: "1887-03-21", pages: 351, tags: ["classic"] },
  { title: "El Filibusterismo", author: "José Rizal", publishedAt: "1891-09-18", pages: 280, tags: ["classic"] },
]) {
  const id = randomUUID();
  store.set(id, { id, tags: [], ...seed });
}
in-memory store — swap for Prisma later

Step 3 — Register the routes

Three little functions, each calling app.route(...). We keep them on a single registration function so build-app.ts stays tidy.

src/routes/books.ts
ts
// src/routes/books.ts (continued)
import {
  type App,
  NotFoundError,
} from "@daloyjs/core";

/**
 * Mount every book-related route on the given app.
 * Pure function on purpose — keeps build-app.ts small and lets tests
 * spin up a fresh App with just these routes if they want to.
 */
export function registerBookRoutes(app: App) {
  list(app);
  getById(app);
  create(app);
}
single entry point · easy to test in isolation
src/routes/books.ts
ts
// src/routes/books.ts (continued)
function list(app: App) {
  app.route({
    method: "GET",
    path: "/books",
    operationId: "listBooks",
    tags: ["Books"],
    request: {
      // Query params are validated and coerced — the handler sees real numbers.
      query: z.object({
        limit:  z.coerce.number().int().min(1).max(100).default(20),
        offset: z.coerce.number().int().min(0).default(0),
        tag:    z.string().optional(),
      }),
    },
    responses: {
      200: {
        description: "Paginated list of books",
        body: z.object({
          items: z.array(Book),
          total: z.number().int().nonnegative(),
        }),
      },
    },
    handler: async ({ query }) => {
      const all = [...store.values()];
      const filtered = query.tag
        ? all.filter((b) => b.tags.includes(query.tag!))
        : all;
      const items = filtered.slice(query.offset, query.offset + query.limit);
      return { status: 200, body: { items, total: filtered.length } };
    },
  });
}
GET /books · query validated & coerced · paginated

That z.coerce.number() is the small kindness that fixes the every framework on earth bug of handlers receiving "20" when they asked for 20. Schema-first means schema-once.

src/routes/books.ts
ts
// src/routes/books.ts (continued)
function getById(app: App) {
  app.route({
    method: "GET",
    path: "/books/:id",
    operationId: "getBookById",
    tags: ["Books"],
    request: {
      params: z.object({ id: z.string().uuid() }),
    },
    responses: {
      200: { description: "Found",     body: Book },
      404: { description: "Not found" /* problem+json — framework adds it */ },
    },
    handler: async ({ params }) => {
      const book = store.get(params.id);
      if (!book) throw new NotFoundError(`No book with id ${params.id}`);
      return { status: 200, body: book };
    },
  });
}
GET /books/:id · throw NotFoundError — never return 404 by hand
src/routes/books.ts
ts
// src/routes/books.ts (continued)
function create(app: App) {
  app.route({
    method: "POST",
    path: "/books",
    operationId: "createBook",
    tags: ["Books"],
    request: { body: CreateBook },
    responses: {
      201: {
        description: "Created",
        body: Book,
        headers: {
          location: { schema: z.string(), description: "URI of the new book" },
        },
      },
      // No 422 entry needed — the framework registers one automatically for
      // any route with a validated request, pointing at ProblemDetails.
    },
    handler: async ({ body }) => {
      const id = randomUUID();
      const created: BookT = { id, tags: [], ...body };
      store.set(id, created);
      return {
        status: 201,
        body: created,
        headers: { location: `/books/${id}` },
      };
    },
  });
}
POST /books · 201 + Location · 422 auto-registered

Step 4 — Free validation errors

Send a deliberately wrong body and watch what comes back. You did not write any of this response — the schema and the framework conspired to produce it.

terminal · curl
bash
# POST /books with an obviously bad body:
curl -sS -X POST http://localhost:3000/books \
  -H 'content-type: application/json' \
  -d '{ "title": "", "pages": -3, "publishedAt": "yesterday" }' | jq .

# HTTP/1.1 422 Unprocessable Entity
# Content-Type: application/problem+json
{
  "type":   "https://daloyjs.dev/errors/validation",
  "title":  "Request validation failed",
  "status": 422,
  "detail": "Invalid body",
  "errors": [
    { "path": "title",       "message": "String must contain at least 1 character(s)" },
    { "path": "author",      "message": "Required" },
    { "path": "publishedAt", "message": "Invalid date" },
    { "path": "pages",       "message": "Number must be greater than 0" }
  ]
}
# You did not write a single line for this. Schema + framework. Done.
application/problem+json · errors[] keyed by field path

For the long version of why this matters and how to consume it on the frontend, see the Problem Details post. For now, the punchline is: every wrong-shaped request your API will ever see returns the same document shape. The frontend code that handles it is one helper, total.

Step 5 — Wire it onto the App and serve

src/build-app.ts
ts
// src/build-app.ts — wire the routes onto the app.
import {
  App,
  rateLimit,
  requestId,
  secureHeaders,
} from "@daloyjs/core";

import { registerBookRoutes } from "./routes/books.js";

export function buildApp(): App {
  const app = new App({
    bodyLimitBytes: 1024 * 1024,
    requestTimeoutMs: 5_000,
    production: process.env.NODE_ENV === "production",
    docs: true,                         // /docs, /openapi.json, /openapi.yaml
    openapi: {
      servers: [{ url: `http://localhost:${process.env.PORT ?? 3000}` }],
    },
  });

  app.use(requestId());
  app.use(secureHeaders());
  app.use(rateLimit({ windowMs: 60_000, max: 120 }));

  registerBookRoutes(app);
  return app;
}

export default buildApp;
pure factory · imported by serve, tests, and the OpenAPI dumper
src/index.ts
ts
// src/index.ts — boot the HTTP listener. The only file that does I/O.
import { serve } from "@daloyjs/core/node";
import { printStartupBanner } from "@daloyjs/core/banner";
import { buildApp } from "./build-app.js";

const app  = buildApp();
const port = Number(process.env.PORT ?? 3000);

serve(app, { port });

const url = `http://localhost:${port}`;
printStartupBanner({
  name: "Bookstore API",
  url,
  runtime: "Node.js",
  links: [
    { label: "API docs",     url: `${url}/docs` },
    { label: "OpenAPI JSON", url: `${url}/openapi.json` },
    { label: "Health",       url: `${url}/healthz` },
  ],
});
the ONLY file in src/ that does I/O

Run pnpm dev and visit http://localhost:3000/docs — Scalar renders your three routes, complete with the Zod-derived schemas, the 422 problem+json response, and a working Try it panel. You did not write a single line of documentation; you wrote a schema and three handlers, and the docs fell out the other side.

Step 6 — Generate the typed client

scripts/dump-openapi.ts
ts
// scripts/dump-openapi.ts — single source of truth for the spec.
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { generateOpenAPI } from "@daloyjs/core";
import { buildApp } from "../src/build-app.ts";

const app = buildApp();
const spec = generateOpenAPI(app, {
  info: { title: "Bookstore API", version: "0.1.0" },
});

const out = "generated/openapi.json";
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, JSON.stringify(spec, null, 2) + "\n");
console.log("Wrote", out);
dumps the SAME spec /openapi.json serves at runtime
terminal · zsh
bash
# Step 1: dump the spec from the live route table.
pnpm gen:openapi
# ─→ Wrote generated/openapi.json

# Step 2: run Hey API codegen against the spec.
pnpm gen
# ─→ generated/client/sdk.gen.ts
# ─→ generated/client/types.gen.ts
# ─→ generated/client/client.gen.ts

# The two scripts are also chained on CI as `pnpm gen:all`.
two commands · one chained script (gen:all)

Now switch hats and pretend you're the frontend team. The generated SDK gives you typed function calls for every route, typed bodies, typed responses, and — crucially — a typed error field shaped like ProblemDetails. Autocomplete owns the rest.

apps/web/lib/books.ts
ts
// apps/web/lib/books.ts — frontend consumer of the typed client.
import { client, listBooks, createBook, getBookById } from "@/generated/client";

client.setConfig({ baseUrl: process.env.NEXT_PUBLIC_API_URL });

export async function fetchFirstPage() {
  const { data, error } = await listBooks({
    query: { limit: 10, offset: 0 },     // ← typed; required keys complained-about
  });
  if (error) throw new Error(error.title);
  return data;                            // ← { items: Book[]; total: number }
}

export async function addBook(input: Parameters<typeof createBook>[0]["body"]) {
  const { data, error } = await createBook({ body: input });
  if (error) {
    // `error` is ProblemDetails — autocompletes type/title/detail/status.
    if (error.status === 422) {
      // error.errors is { path; message }[] — straight into react-hook-form.
      return { ok: false as const, fieldIssues: error.errors ?? [] };
    }
    throw new Error(error.title);
  }
  return { ok: true as const, book: data };
}
frontend code · zero hand-written request types

Step 7 — Test it (without booting a server)

app.request(url, init?) is the same App your production server wraps, but called in-process. No port, no fetch, no "wait for the dev server to be ready". Faster than your test runner's spinner.

tests/books.test.ts
ts
// tests/books.test.ts — node:test + app.request(). No port. No flakes.
import { test } from "node:test";
import assert from "node:assert/strict";
import { buildApp } from "../src/build-app.ts";

test("GET /books returns the seeded items", async () => {
  const app = buildApp();
  const res = await app.request("/books");
  assert.equal(res.status, 200);
  const body = await res.json();
  assert.equal(body.total, 2);
  assert.equal(body.items[0].title, "Noli Me Tangere");
});

test("POST /books creates and round-trips through GET /books/:id", async () => {
  const app = buildApp();
  const create = await app.request("/books", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      title: "Po-on",
      author: "F. Sionil José",
      publishedAt: "1984-01-01",
      pages: 379,
      tags: ["classic", "rosales-saga"],
    }),
  });
  assert.equal(create.status, 201);
  const location = create.headers.get("location")!;
  assert.match(location, /^\/books\/[0-9a-f-]{36}$/);

  const created = await create.json();
  const fetched = await app.request(location);
  assert.equal(fetched.status, 200);
  assert.deepEqual(await fetched.json(), created);
});

test("POST /books returns RFC 9457 422 on a bad body", async () => {
  const app = buildApp();
  const res = await app.request("/books", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ title: "", pages: -3, publishedAt: "yesterday" }),
  });
  assert.equal(res.status, 422);
  assert.equal(res.headers.get("content-type"), "application/problem+json");

  const problem = await res.json();
  assert.equal(problem.type, "https://daloyjs.dev/errors/validation");
  const fields = problem.errors.map((e: { path: string }) => e.path).sort();
  assert.deepEqual(fields, ["author", "pages", "publishedAt", "title"]);
});

test("GET /books/:id returns 404 problem+json for unknown id", async () => {
  const app = buildApp();
  const res = await app.request("/books/00000000-0000-0000-0000-000000000000");
  assert.equal(res.status, 404);
  assert.equal(res.headers.get("content-type"), "application/problem+json");
});
node:test · zero external deps · runs in milliseconds
terminal · zsh
bash
pnpm test
# > node --test --import=tsx tests/**/*.test.ts
#
# ✔ GET /books returns the seeded items (8.4ms)
# ✔ POST /books creates and round-trips through GET /books/:id (12.1ms)
# ✔ POST /books returns RFC 9457 422 on a bad body (5.9ms)
# ✔ GET /books/:id returns 404 problem+json for unknown id (3.2ms)
#
# ℹ tests 4
# ℹ pass 4
# ℹ fail 0
four tests · happy + unhappy paths · CI-ready

The muscle-memory scripts

For when you forget which command does what (you will, I certainly do):

package.json
json
// package.json — the muscle memory commands.
{
  "scripts": {
    "dev":          "tsx watch src/index.ts",
    "build":        "tsc -p tsconfig.json",
    "start":        "node --enable-source-maps dist/index.js",
    "test":         "node --test --import=tsx 'tests/**/*.test.ts'",
    "typecheck":    "tsc -p tsconfig.json --noEmit",
    "gen:openapi":  "tsx scripts/dump-openapi.ts",
    "gen":          "openapi-ts",
    "gen:all":      "pnpm gen:openapi && pnpm gen"
  }
}
all the scripts in one place · paste into your README

What just happened

We modeled a domain in Zod. We declared three routes. We got validation, 404 handling, RFC 9457 problem+json, an OpenAPI document, a Scalar UI, and a fully typed fetch SDK — and we never had to write the "glue" that usually fills the first thousand lines of a Node project. The tests run without a port. The frontend client is generated from the same schema the server uses to validate. The error shape is standardized, so the helper that consumes it is one function.

If you want to keep going from here:

  • Swap the in-memory Map for Prisma — see the Prisma guide.
  • Add auth and per-route rate limits — the secure-by-default post covers the defaults you already have.
  • Move the same code to Cloudflare Workers, Bun, Deno, or Vercel Edge with no rewrite — the five-runtimes post shows the proof.
  • Sessions and CSRF for the cookie-based parts of your frontend — the sessions and CSRF posts have the receipts.

That's the tour. If you send this to a new hire and they get stuck on step n, file an issue — I'll fix the post, not the framework.

— Devlin

Devlin Duldulao

Ten years of fullstack, currently writing TypeScript from a desk in Norway. This is the walkthrough I wish someone had handed me on day one — bookmark it and send it to the next new hire.