Picking a Worker router: vanilla, Itty, or Hono

Three options: vanilla fetch (0 bundle), Itty Router (3KB), Hono (13KB). Syntax, middleware, Zod validation, when to pick which, and why this blog uses vanilla at 40+ routes.

· 5 min read · Đọc bản tiếng Việt
Three Worker router options side by side: vanilla fetch + URLPattern (0 bundle), Itty Router (~3KB functional), Hono (~13KB Express-like), plus middleware chains and Zod request validation

TL;DR

Three options for routing inside a Worker:

  1. Vanilla: switch (url.pathname) + URLPattern. Zero bundle. You write everything.
  2. Itty Router: ~3KB, functional style, middleware chain.
  3. 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

Comparison of vanilla, Itty Router, and Hono across 7 criteria: bundle size (0KB vs 3KB vs 13KB), type safety, middleware, route params, request validation, CORS/headers, testing. With guidance on when to pick which.


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 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

Middleware chain for a Worker: the request flows through CORS, Auth JWT, Rate limit, and Zod validator middleware before hitting the handler. Each one can short-circuit early (OPTIONS → 204, invalid JWT → 401, exceeded rate → 429, invalid schema → 400).

The usual order:

  1. CORS: OPTIONS preflight → respond immediately.
  2. Auth: reject early if no valid token.
  3. Rate limit: check before touching storage.
  4. Validator: parse + validate body/query.
  5. 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:

  1. The code is mature: 18 months in production, stable patterns, no refactor pressure.
  2. No OpenAPI need: it’s an internal API, no third-party consumer.
  3. No RPC client: the frontend is Astro SSG; it fetches with standard fetch().
  4. Dependency discipline: every npm package is maintenance burden. Vanilla = 0 deps.
  5. 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’s router.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 deploy output).
  • 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.


References