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
# 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 demoTS · UTF-8 · LF
--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.)
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 diskTS · UTF-8 · LF
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
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 + typesTS · UTF-8 · LF
// 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 laterTS · UTF-8 · LF
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 (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 isolationTS · UTF-8 · LF
// 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 · paginatedTS · UTF-8 · LF
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 (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 handTS · UTF-8 · LF
// 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-registeredTS · UTF-8 · LF
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.
# 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 pathTS · UTF-8 · LF
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 — 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 dumperTS · UTF-8 · LF
// 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/OTS · UTF-8 · LF
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 — 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 runtimeTS · UTF-8 · LF
# 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)TS · UTF-8 · LF
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 — 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 typesTS · UTF-8 · LF
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 — 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 millisecondsTS · UTF-8 · LF
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-readyTS · UTF-8 · LF
The muscle-memory scripts
For when you forget which command does what (you will, I certainly do):
// 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 READMETS · UTF-8 · LF
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