TL;DR
Three options for routing inside a Worker:
- Vanilla:
switch (url.pathname)+URLPattern. Zero bundle. You write everything. - Itty Router: ~3KB, functional style, middleware chain.
- Hono: ~13KB, Express-like, RPC client, large middleware ecosystem.
The key claim:
“Use Hono for everything” is common advice, but it’s not right for every workload. This blog has 40+ routes and runs on vanilla. 50+ routes, an Express-familiar team, or needing OpenAPI: Hono. 10-50 routes, preferring functional and lightweight: Itty. Fewer than 10 routes or extreme cold-start sensitivity: vanilla.
This post covers each router’s syntax, middleware patterns, request validation with Zod, and why this blog chose vanilla despite its route count.
Who this is for
- Developers starting a new Worker project and picking a router.
- Anyone on vanilla considering a move to Hono as they scale.
- Express / Fastify users looking for the Workers equivalent.
Read first: Part 2 (the fetch handler), Part 3 (3-binding mental model).
After this post you’ll:
- Know the syntax of the three common routers.
- Build middleware chains (CORS, auth, rate limit, validate).
- Choose the right router for your project.
What this post isn’t about
- Full-stack frameworks (Remix, Astro, SvelteKit): Part 11.
- ORMs (Drizzle, Prisma): Part 10.
- OpenAPI generation: noted as a Hono feature, not deep-dived.
Comparing the three
Vanilla: zero bundle
The simplest approach. A fetch handler + switch or if/else.
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/") {
return new Response("Home");
}
if (url.pathname.startsWith("/api/posts/")) {
const slug = url.pathname.slice("/api/posts/".length);
return handleGetPost(slug, env);
}
if (url.pathname === "/api/posts" && request.method === "POST") {
return handleCreatePost(request, env);
}
return new Response("Not found", { status: 404 });
}
};
For more routes, use URLPattern (a Web standard):
const patterns = {
getPost: new URLPattern({ pathname: "/api/posts/:slug" }),
listPosts: new URLPattern({ pathname: "/api/posts" }),
adminDashboard: new URLPattern({ pathname: "/admin/*" }),
};
async fetch(request, env) {
const url = request.url;
const getPostMatch = patterns.getPost.exec(url);
if (getPostMatch) {
const { slug } = getPostMatch.pathname.groups;
return handleGetPost(slug, env);
}
if (patterns.adminDashboard.test(url)) {
return handleAdmin(request, env);
}
// ...
}
When vanilla wins
- < 10 routes: a switch is enough; a framework is overkill.
- Extreme cold-start sensitivity: 0-byte bundle = fastest isolate load (though ~5ms cold starts are already very cheap).
- Learning Workers API: nothing hidden by a framework.
- Well-organised code with many routes: modular handlers, stable patterns.
This blog has 40+ routes (public pages, /api/*, /admin/*, /og/*, /preview/*) and runs vanilla. Reason: the code is mature and I don’t want another dependency.
Vanilla stays readable
// worker/index.ts — this blog
import { handleSubscribe } from "./routes/subscribe";
import { handleWebmention } from "./routes/webmention";
import { handleAdmin } from "./routes/admin";
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const url = new URL(request.url);
const path = url.pathname;
if (path.startsWith("/api/subscribe")) return handleSubscribe(request, env, ctx);
if (path.startsWith("/api/webmention")) return handleWebmention(request, env, ctx);
if (path.startsWith("/admin/")) return handleAdmin(request, env, ctx);
if (path.startsWith("/og/") && path.endsWith(".png")) return handleOg(request, env, ctx);
// Everything else: static asset
return env.ASSETS.fetch(request);
}
};
A clean dispatch table, each route handler in its own file. Not that different from Hono.
Itty Router: functional, light
Itty Router is ~3KB after tree-shake. Functional style, chained middleware.
npm install itty-router
import { Router } from "itty-router";
const router = Router();
router
.get("/api/posts", async (request, env) => {
const rows = await env.DB.prepare("SELECT * FROM posts").all();
return Response.json(rows.results);
})
.get("/api/posts/:slug", async (request, env) => {
const { slug } = request.params;
const post = await env.DB.prepare("SELECT * FROM posts WHERE slug = ?")
.bind(slug).first();
return post ? Response.json(post) : new Response("Not found", { status: 404 });
})
.post("/api/posts", async (request, env) => {
const body = await request.json();
// ...
return Response.json({ created: true }, { status: 201 });
})
.all("*", () => new Response("Not found", { status: 404 }));
export default {
async fetch(request, env, ctx) {
return router.fetch(request, env, ctx);
}
};
Itty middleware
Middleware returns void (continue the chain) or returns a Response (short-circuit).
// CORS middleware
const withCors = (request: Request) => {
if (request.method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
}
});
}
};
// Auth middleware
const withAuth = async (request: Request, env: Env) => {
const token = request.headers.get("Authorization")?.slice("Bearer ".length);
if (!token) return new Response("Unauthorized", { status: 401 });
const claims = await verifyToken(token, env);
if (!claims) return new Response("Invalid token", { status: 401 });
(request as any).claims = claims;
};
router
.all("*", withCors)
.all("/admin/*", withAuth)
.get("/admin/stats", async (request, env) => {
const claims = (request as any).claims;
// ...
});
When Itty wins
- 10-50 routes: enough features, nothing extra.
- Team prefers functional: no classes, no decorators.
- Minimal dependencies: 3KB, no transitive deps.
- Don’t need a built-in validator: if you’re comfortable wiring up Zod by hand.
Hono: full-featured
Hono is ~13KB. Express-like API, a large middleware ecosystem, a type-safe RPC client, and an OpenAPI generator.
npm install hono
import { Hono } from "hono";
import { cors } from "hono/cors";
import { bearerAuth } from "hono/bearer-auth";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
type Env = { Bindings: { DB: D1Database } };
const app = new Hono<Env>();
// Global middleware
app.use("*", cors());
app.use("/admin/*", bearerAuth({ token: "secret" }));
// Route with validator
const postSchema = z.object({
title: z.string().min(1).max(200),
body: z.string().min(1),
tags: z.array(z.string()).optional(),
});
app.post(
"/api/posts",
zValidator("json", postSchema),
async (c) => {
const { title, body, tags } = c.req.valid("json");
await c.env.DB.prepare("INSERT INTO posts (title, body) VALUES (?, ?)")
.bind(title, body).run();
return c.json({ created: true }, 201);
}
);
app.get("/api/posts/:slug", async (c) => {
const slug = c.req.param("slug");
const post = await c.env.DB.prepare("SELECT * FROM posts WHERE slug = ?")
.bind(slug).first();
return post ? c.json(post) : c.notFound();
});
export default app;
Hono context (c)
c bundles request, env, and response builder:
app.get("/test", async (c) => {
c.req.query("q"); // query param
c.req.param("id"); // path param
c.req.header("X-Custom"); // header
c.req.json(); // body JSON
c.env.DB; // binding
c.executionCtx.waitUntil(); // ctx
return c.json({ ok: true }, 200, { "X-Custom": "yes" });
});
Different from vanilla: handlers get a single c instead of (request, env, ctx).
Hono validator
zValidator integrates Zod:
const querySchema = z.object({
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(20),
});
app.get("/api/posts",
zValidator("query", querySchema),
async (c) => {
const { page, limit } = c.req.valid("query");
const offset = (page - 1) * limit;
// ...
}
);
Validation failure auto-returns 400 with a structured error. No throwing inside the handler.
Hono RPC: type-safe client
The killer feature. Server and client share types:
// server
const app = new Hono()
.get("/api/posts/:slug", (c) => {
const slug = c.req.param("slug");
return c.json({ slug, title: "...", body: "..." });
})
.post("/api/posts",
zValidator("json", postSchema),
(c) => c.json({ created: true })
);
export type AppType = typeof app;
export default app;
// client (another app, browser, Node, wherever)
import { hc } from "hono/client";
import type { AppType } from "../worker/server";
const client = hc<AppType>("https://my-app.workers.dev");
// Type-safe: client.api.posts[":slug"] exists in the type
const res = await client.api.posts[":slug"].$get({ param: { slug: "hello" } });
const post = await res.json();
// post is correctly typed
Great when client and server live in the same repo. Less useful when the client is third-party — you’ll still want OpenAPI.
When Hono wins
- 50+ routes: middleware chains and nested routers stay readable.
- You want clean request validation: the Zod middleware is excellent.
- Auto-generated OpenAPI specs:
@hono/zod-openapi. - Type-safe RPC client in the same repo: end-to-end types.
- Express-familiar team: the learning curve is short.
Middleware-chain pattern
The usual order:
- CORS: OPTIONS preflight → respond immediately.
- Auth: reject early if no valid token.
- Rate limit: check before touching storage.
- Validator: parse + validate body/query.
- Handler: the business logic, now with clean input.
Hono version
import { Hono } from "hono";
import { cors } from "hono/cors";
import { rateLimiter } from "./middleware/rate-limit";
import { requireAdmin } from "./middleware/auth";
import { zValidator } from "@hono/zod-validator";
const app = new Hono<Env>();
app.use("*", cors({ origin: "https://my-app.com" }));
app.use("/admin/*", requireAdmin);
app.use("/api/*", rateLimiter({ max: 60, window: 60 }));
app.post("/api/contact",
zValidator("json", contactSchema),
async (c) => {
const { email, message } = c.req.valid("json");
// The handler has already passed CORS, rate limit, schema validation.
await sendEmail(c.env, email, message);
return c.json({ ok: true });
}
);
Vanilla compose manually
type Middleware = (request: Request, env: Env, ctx: ExecutionContext) => Promise<Response | void>;
async function compose(request: Request, env: Env, ctx: ExecutionContext, middlewares: Middleware[], handler: Middleware) {
for (const mw of middlewares) {
const result = await mw(request, env, ctx);
if (result instanceof Response) return result;
}
return handler(request, env, ctx) as Promise<Response>;
}
async fetch(request, env, ctx) {
if (url.pathname === "/api/contact" && request.method === "POST") {
return compose(request, env, ctx, [
withCors,
withRateLimit(60, 60),
withZod(contactSchema),
], handleContact);
}
}
Possible but noisy. This is where Itty/Hono really pay off.
Request validation with Zod
Whatever router you pick, Zod is the de-facto standard for validation.
import { z } from "zod";
const postSchema = z.object({
title: z.string().min(1).max(200),
body: z.string().min(10),
tags: z.array(z.string().regex(/^[a-z0-9-]+$/)).max(10).optional(),
published: z.boolean().default(false),
});
// Vanilla
async function handleCreatePost(request: Request, env: Env) {
let body;
try {
body = await request.json();
} catch {
return new Response("Invalid JSON", { status: 400 });
}
const parsed = postSchema.safeParse(body);
if (!parsed.success) {
return Response.json({ errors: parsed.error.flatten() }, { status: 400 });
}
const { title, body: postBody, tags, published } = parsed.data;
// ...
}
// Hono with zValidator (shorter)
app.post("/posts", zValidator("json", postSchema), async (c) => {
const data = c.req.valid("json"); // fully typed
// ...
});
Testing the router
All three routers test the same way under vitest-pool-workers:
import { SELF } from "cloudflare:test";
import { describe, it, expect } from "vitest";
describe("API routes", () => {
it("GET /api/posts returns a list", async () => {
const res = await SELF.fetch("https://example.com/api/posts");
expect(res.status).toBe(200);
const posts = await res.json();
expect(Array.isArray(posts)).toBe(true);
});
it("POST /api/posts validates body", async () => {
const res = await SELF.fetch("https://example.com/api/posts", {
method: "POST",
body: JSON.stringify({ title: "" }), // fails validation
});
expect(res.status).toBe(400);
});
it("OPTIONS triggers the CORS preflight", async () => {
const res = await SELF.fetch("https://example.com/api/posts", {
method: "OPTIONS",
});
expect(res.status).toBe(204);
expect(res.headers.get("access-control-allow-methods")).toContain("POST");
});
});
Hono adds app.request():
const res = await app.request("/api/posts");
expect(res.status).toBe(200);
Faster than SELF.fetch (no isolate boot).
Why this blog stays on vanilla
40+ routes but still vanilla, no Hono. Reasons:
- The code is mature: 18 months in production, stable patterns, no refactor pressure.
- No OpenAPI need: it’s an internal API, no third-party consumer.
- No RPC client: the frontend is Astro SSG; it fetches with standard
fetch(). - Dependency discipline: every npm package is maintenance burden. Vanilla = 0 deps.
- Clarity on debug: Hono’s abstractions sometimes hide the request flow.
The flip: if I were starting over,
- External API consumers → Hono with OpenAPI.
- Full-stack app with a typed client → Hono RPC.
- Many endpoints with complex validators → Hono + zValidator.
“Start new = Hono” is the right default. “Refactor from vanilla to Hono” is a cost/benefit call.
Gotchas
① URLPattern isn’t supported everywhere yet
Fine in Workers (Cloudflare supports it). But copy-pasting code to Deno/Node needs a check.
② Hono middleware order matters
// WRONG: rate limit before auth → bots without tokens still touch the rate-limit DO
app.use("/api/*", rateLimiter);
app.use("/api/*", requireAuth);
// RIGHT: auth first → reject 401 early
app.use("/api/*", requireAuth);
app.use("/api/*", rateLimiter);
Exception: anonymous endpoints → rate limit first (to block bots). Authenticated endpoints → auth first.
③ Itty middleware must be awaited
// WRONG: returning a Promise without await → middleware doesn't block
router.all("*", (request, env) => {
someAsync(request, env); // not awaited
});
// RIGHT
router.all("*", async (request, env) => {
await someAsync(request, env);
});
④ Hono RPC doesn’t auto-sync across misaligned deploys
Client types are generated from typeof app. Deploying a new server before rebuilding the client → stale types, runtime mismatch.
Fix: monorepo + atomic deploys, or versioned APIs (/v1/, /v2/).
⑤ Hono app.request() doesn’t mock external calls
// middleware calls the real Turnstile API
app.use("/api/subscribe", turnstileMiddleware);
app.request() runs inside the Node test runner and will hit the real Turnstile → slow + flaky. Use SELF.fetch() with vitest-pool-workers to mock middleware or external fetches.
How to choose
A simple flowchart:
New project?
├── Yes → Hono (default, scales well)
└── No
└── Is vanilla already stable?
├── Yes → keep vanilla, don't refactor for fashion
└── No → evaluate the pain point:
├── Repetitive validation → Zod + Hono zValidator
├── Tangled middleware → Hono or Itty
└── Just 1-2 new routes → extend the existing vanilla
Practical advice
- Don’t rule out vanilla because it “isn’t cool”. 0 deps, 0 learning curve for new team members, very readable.
- Don’t pick Hono “because everyone says Hono”. Check the actual workload.
- Itty is the sweet spot for many mid-sized projects, and gets under-discussed.
- Testing ergonomics matter as much as API design. Both Hono’s
app.request()and Itty’srouter.fetch()are fast to test with.
Production checklist
- Router choice matches scale (< 10 / 10-50 / 50+ routes).
- Middleware order: CORS → Auth → Rate → Validate → Handler.
- Validation with Zod or equivalent, no raw-input trust.
- Consistent error response format (JSON, not mixed with HTML).
- Explicit 404 handler, no stack-trace leak.
- Tests cover happy path + validation fail + auth fail per route.
- Bundle size monitored (check
wrangler deployoutput). - TypeScript strict mode to catch bad handler signatures early.
Wrap-up
Three routers, three trade-off profiles. Vanilla for simplicity + 0 deps. Itty for lightweight + functional. Hono for full features + RPC.
There’s no “best router”. There’s a match to your workload.
Part 10: ORMs on D1 — Drizzle vs Prisma. Raw SQL vs ORM, type safety, migrations, and when to skip ORMs entirely.