TL;DR
3 lựa chọn cho route Worker:
- Vanilla:
switch (url.pathname)+URLPattern. Không có bundle. Tự làm mọi thứ. - Itty Router: ~3KB, phong cách functional, chuỗi middleware.
- Hono: ~13KB, kiểu Express, RPC client, hệ sinh thái middleware lớn.
Luận điểm chính:
“Hono cho mọi thứ” là lời khuyên phổ biến nhưng không phải đúng cho mọi khối lượng công việc. Blog này có 40+ route và chạy vanilla. 50 route, team quen Express, cần OpenAPI: Hono. 10-50 route, muốn functional, nhẹ: Itty. < 10 route hoặc cực nhạy với cold start: vanilla.
Bài này đi qua cú pháp từng router, pattern middleware, validate request với Zod, và lý do blog này chọn vanilla dù có nhiều route.
Dành cho ai
- Dev mới bắt đầu project Worker, đang chọn router.
- Người đang dùng vanilla và cân nhắc chuyển sang Hono khi mở rộng.
- Ai đã dùng Express/Fastify và muốn cái tương đương trên Workers.
Nên đọc trước: Part 2 (handler fetch), Part 3 (mental model 3-binding).
Sau bài này bạn sẽ:
- Biết cú pháp 3 router phổ biến.
- Dựng chuỗi middleware (CORS, auth, rate limit, validate).
- Quyết định được router nào cho project của mình.
Bài này không nói về gì
- Framework full-stack (Remix, Astro, SvelteKit): Part 11.
- ORM (Drizzle, Prisma): Part 10.
- Sinh spec OpenAPI: chỉ nhắc là Hono hỗ trợ, không đi sâu.
So sánh 3 lựa chọn
Vanilla: không bundle
Cách đơn giản nhất. Handler fetch + switch hoặc 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 });
}
};
Với nhiều route, dùng URLPattern (chuẩn Web):
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);
}
// ...
}
Khi nào vanilla thắng
- < 10 route: switch đủ, thêm framework là thừa.
- Cực nhạy với cold start: 0 byte bundle = isolate nạp nhanh nhất (nhưng thực tế ~5ms cold start đã rất rẻ).
- Học Workers API thuần: không bị framework che.
- Nhiều route nhưng code đã tổ chức tốt: tách thành module, pattern ổn định.
Blog này có 40+ route (trang public, /api/, /admin/, /og/, /preview/) vẫn chạy vanilla. Lý do: code đã chín, không muốn thêm dependency.
Cú pháp vanilla thật gọn
// worker/index.ts — blog này
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);
}
};
Bảng dispatch rõ ràng, mỗi route handler ở file riêng. Không khác Hono nhiều.
Itty Router: functional, nhẹ
Itty Router ~3KB sau tree-shake. Phong cách functional, xích các middleware lại với nhau.
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);
}
};
Middleware Itty
Middleware là function trả void (đi tiếp trong chuỗi) hoặc trả Response (cắt ngắn).
// Middleware CORS
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",
}
});
}
};
// Middleware Auth
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;
// ...
});
Khi nào Itty thắng
- 10-50 route: đủ tính năng, không thừa.
- Team thích functional: không muốn class, decorator.
- Ít dependency: 3KB, không kéo theo transitive.
- Không cần validator có sẵn: nếu OK với Zod viết tay.
Hono: đầy đủ tính năng
Hono ~13KB. API kiểu Express, hệ sinh thái middleware lớn, RPC client type-safe, sinh OpenAPI.
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>();
// Middleware toàn cục
app.use("*", cors());
app.use("/admin/*", bearerAuth({ token: "secret" }));
// Route với 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 gộp request, env, 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" });
});
Khác vanilla: handler nhận c thay vì (request, env, ctx) tách rời.
Validator Hono
zValidator kết hợp 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;
// ...
}
);
Validator fail → tự trả 400 với schema lỗi. Không throw trong handler.
Hono RPC: client type-safe
Tính năng killer. Server + client chia sẻ type:
// 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, whatever)
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"] tồn tại trong type
const res = await client.api.posts[":slug"].$get({ param: { slug: "hello" } });
const post = await res.json();
// post is typed correctly
Khi client + server cùng repo, RPC rất tốt. Khi client bên thứ ba, vẫn phải OpenAPI.
Khi nào Hono thắng
- 50+ route: chuỗi middleware + router lồng không rối.
- Cần validate request sạch: middleware Zod.
- Sinh spec OpenAPI tự động:
@hono/zod-openapi. - RPC client cùng repo: type-safe từ đầu đến cuối.
- Team quen Express: đường cong học ngắn.
Pattern chuỗi middleware
Middleware nên có thứ tự:
- CORS: preflight OPTIONS trả ngay.
- Auth: từ chối sớm nếu không có token hợp lệ.
- Giới hạn tốc độ: kiểm tra trước khi đụng storage.
- Validator: parse + validate body/query.
- Handler: logic nghiệp vụ, đã yên tâm đầu vào sạch.
Ví dụ Hono
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");
// Handler: đã qua CORS, giới hạn tốc độ, validate schema
await sendEmail(c.env, email, message);
return c.json({ ok: true });
}
);
Vanilla ghép thủ công
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);
}
}
Làm được nhưng dài. Đến đây Itty/Hono tiện hơn rõ.
Validate request với Zod
Dù dùng router nào, Zod là chuẩn de-facto cho validate.
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 với zValidator (ngắn gọn hơn)
app.post("/posts", zValidator("json", postSchema), async (c) => {
const data = c.req.valid("json"); // đã có type đầy đủ
// ...
});
Test router
Cả 3 router đều test cùng cách với vitest-pool-workers:
import { SELF } from "cloudflare:test";
import { describe, it, expect } from "vitest";
describe("API routes", () => {
it("GET /api/posts returns 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: "" }), // validate thất bại
});
expect(res.status).toBe(400);
});
it("OPTIONS triggers 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");
});
});
Với Hono có thêm app.request():
const res = await app.request("/api/posts");
expect(res.status).toBe(200);
Nhanh hơn SELF.fetch (không khởi động isolate riêng).
Vì sao blog này vẫn vanilla
40+ route nhưng vẫn vanilla, không Hono. Lý do:
- Code đã chín: 18 tháng chạy, pattern ổn định, không cần refactor.
- Không cần OpenAPI: API nội bộ, không có bên thứ ba tiêu thụ.
- Không có RPC client: frontend là Astro SSG, fetch qua
fetch()chuẩn. - Kỷ luật dependency: mỗi package npm là gánh nặng bảo trì. Vanilla = 0 deps.
- Rõ ràng khi debug: lớp trừu tượng của Hono đôi khi ẩn luồng request.
Ngược lại, nếu bắt đầu mới:
- Project có bên thứ ba tiêu thụ API → Hono với OpenAPI.
- Ứng dụng full-stack với client có type → Hono RPC.
- Nhiều endpoint với validator phức tạp → Hono + zValidator.
“Bắt đầu mới = Hono” là mặc định đúng. “Refactor từ vanilla sang Hono” = tính chi phí/lợi ích kỹ.
Gotcha
① URLPattern chưa hỗ trợ đầy đủ ở mọi browser
Với Worker OK (Cloudflare hỗ trợ). Nhưng sao chép code sang Deno/Node cần kiểm tra.
② Thứ tự middleware Hono là quan trọng
// SAI: giới hạn tốc độ trước auth → bot không có token vẫn chạm DO giới hạn tốc độ
app.use("/api/*", rateLimiter);
app.use("/api/*", requireAuth);
// ĐÚNG: auth trước → từ chối 401 sớm
app.use("/api/*", requireAuth);
app.use("/api/*", rateLimiter);
Hoặc: endpoint ẩn danh → giới hạn tốc độ trước (chặn bot). Endpoint đã xác thực → auth trước.
③ Middleware Itty không chain Promise
// SAI: trả Promise không await → middleware không block
router.all("*", (request, env) => {
someAsync(request, env); // không await
});
// ĐÚNG
router.all("*", async (request, env) => {
await someAsync(request, env);
});
④ Hono RPC không tự sync khi triển khai lệch
Client type sinh từ typeof app export. Triển khai server mới mà client chưa rebuild → type cũ, runtime lệch.
Fix: monorepo + triển khai atomic, hoặc API có version (/v1/, /v2/).
⑤ app.request() của Hono không test middleware gọi bên ngoài
// middleware gọi API Turnstile thực
app.use("/api/subscribe", turnstileMiddleware);
app.request() chạy trong test runner Node, gọi Turnstile thật → chậm + flaky. Dùng SELF.fetch() với vitest-pool-workers để mock middleware hoặc fetch.
Chọn như thế nào
Sơ đồ đơn giản:
Bắt đầu project mới?
├── Có → Hono (mặc định, mở rộng tốt)
└── Không
└── Code vanilla đã chạy ổn?
├── Có → giữ vanilla, không refactor vì trào lưu
└── Không → đánh giá điểm đau:
├── Validate lặp → Zod + Hono zValidator
├── Middleware lộn xộn → Hono hoặc Itty
└── Chỉ cần 1-2 route mới → thêm pattern vanilla
Lời khuyên thực tế
- Không loại bỏ vanilla vì “không cool”. 0 dependency, 0 đường cong học cho team mới, rõ ràng.
- Không chọn Hono “vì ai cũng nói Hono”. Kiểm tra khối lượng công việc cụ thể.
- Itty là điểm ngọt cho nhiều project cỡ vừa, ít được thảo luận.
- Trải nghiệm viết test quan trọng ngang thiết kế API.
app.request()của Hono +router.fetch()của Itty đều test nhanh.
Production checklist
- Router được chọn khớp với quy mô (< 10 / 10-50 / 50+ route).
- Thứ tự middleware: CORS → Auth → Rate → Validate → Handler.
- Validate với Zod hoặc tương đương, không tin đầu vào thô.
- Response lỗi nhất quán (định dạng JSON, không trộn HTML + JSON).
- Handler 404 tường minh, không lộ stack trace.
- Test phủ happy path + validate fail + auth fail cho từng route.
- Kiểm soát kích thước bundle (xem đầu ra
wrangler deploy). - TypeScript strict mode để bắt chữ ký router sai sớm.
Kết
3 router, 3 đánh đổi khác nhau. Vanilla cho đơn giản + 0 deps. Itty cho nhẹ + functional. Hono cho đầy đủ tính năng + RPC.
Không có “router tốt nhất”. Có “khớp khối lượng công việc”.
Part 10 tới: ORM trên D1 — Drizzle vs Prisma. SQL thô vs ORM, type safety, migration, và khi nào bỏ qua ORM hoàn toàn.