The answer. Deploying is cheap, so the monolith-versus-microservices question has a different answer on Workers: one Worker, one router, and the whole API readable top to bottom. A router (Hono is the common choice; itty-router is smaller) matches method and path, parses params and query, and runs middleware. The first matching handler wins, so the wildcard 404 must be registered last. Split into separate Workers when a real boundary appears: a different auth model, a different deploy cadence, or something that must be private. Then connect them with a Service Binding, not a public URL.
The pattern.
import { Hono } from "hono";
const app = new Hono<{ Bindings: Env }>();
app.get("/health", (c) => c.json({ ok: true }));
app.get("/images", async (c) => {
const limit = Number(c.req.query("count") ?? 20);
const { results } = await c.env.DB.prepare("select * from images order by id desc limit ?").bind(limit).all();
return c.json(results);
});
app.post("/images", async (c) => {
const body = await c.req.json<{ url: string; author: string }>();
const r = await c.env.DB.prepare("insert into images (url, author) values (?, ?) returning id").bind(body.url, body.author).first();
return c.json(r, 201);
});
app.notFound((c) => c.text("Not found", 404)); // registered last, catches everything else
export default app;
Watch out.
request.bodycan be read once. Read it in the handler that owns it; middleware that needs the body mustclone().- A router does not validate. Parse the body with a schema (zod, valibot) before it touches a binding.
- Sub-path Workers on the same host (
/ingest/*on one Worker,/*on another) work through routes, and the more specific route wins.
Related: custom-domains-vs-routes-who-is-the-origin · service-bindings-call-a-private-worker-without-a-url · d1-migrations-prepared-statements-and-batch