Architecture Overview and How to Navigate the Hono Codebase
Prerequisites
- ›Basic TypeScript knowledge
- ›Familiarity with web frameworks (Express/Koa concepts)
- ›Understanding of Request/Response Web Standards APIs
Architecture Overview and How to Navigate the Hono Codebase
Hono is an ultrafast web framework that runs everywhere — Cloudflare Workers, Deno, Bun, Node.js, AWS Lambda, and more. It achieves this not through runtime-specific shims or compatibility layers, but through a deceptively simple decision: build everything on the Web Standards Request and Response APIs. The result is a framework with zero dependencies, a 14KB core, and a codebase that's remarkably navigable once you understand its structure.
This article is the first in a five-part series exploring Hono's internals. We'll start with the architecture that makes the framework tick — the class hierarchy, the preset system, the export map, and how all the pieces relate to each other. By the end, you'll have a mental map that makes the rest of the codebase approachable.
Design Philosophy: Web Standards and Zero Dependencies
Hono's core bet is that the Web Standards fetch API — Request, Response, Headers, URL — is the universal interface for HTTP across JavaScript runtimes. Rather than abstracting over runtime differences (like Express does with Node.js's http module), Hono targets the standard directly.
This manifests in the entry point. The main export is a single class:
The entire public API boils down to Hono (the class) plus a handful of type exports — Env, Context, HonoRequest, Handler, MiddlewareHandler, and the RPC client types. There's no createServer, no listen(), no http.IncomingMessage. Your app's entry point is always app.fetch(request), which is exactly the signature that Cloudflare Workers, Deno, and Bun expect from a module-mode export.
flowchart LR
A[Cloudflare Workers] -->|"export default app"| F["app.fetch(req)"]
B[Deno] -->|"Deno.serve(app.fetch)"| F
C[Bun] -->|"export default app"| F
D[Node.js] -->|"@hono/node-server"| F
E[AWS Lambda] -->|"handle(event, ctx)"| F
F --> G["Response"]
Tip: When you see
export default appin a Hono project, the runtime callsapp.fetch()directly. This is why Hono's zero-dependency approach works — every target runtime speaks the same language.
The Hono Class Hierarchy
The concrete Hono class you import from 'hono' is only 34 lines. It exists for a single purpose: wiring up the default router.
export class Hono<...> extends HonoBase<E, S, BasePath> {
constructor(options: HonoOptions<E> = {}) {
super(options)
this.router =
options.router ??
new SmartRouter({
routers: [new RegExpRouter(), new TrieRouter()],
})
}
}
All framework logic — routing, middleware dispatch, error handling, the fetch() method — lives in HonoBase, a 539-line class in src/hono-base.ts. The separation is deliberate: it lets different entry points (presets) swap the router without duplicating any framework code.
classDiagram
class HonoBase {
+router: Router
+routes: RouterRoute[]
+fetch(request, env?, executionCtx?): Response
+use(path?, ...middleware): Hono
+on(method, path, ...handlers): Hono
+route(path, app): Hono
+mount(path, handler): Hono
+onError(handler): Hono
+notFound(handler): Hono
-#dispatch(request, executionCtx, env, method): Response
-#addRoute(method, path, handler): void
}
class Hono {
constructor sets SmartRouter with RegExpRouter + TrieRouter
}
class HonoTiny {
constructor sets PatternRouter
}
class HonoQuick {
constructor sets SmartRouter with LinearRouter + TrieRouter
}
HonoBase <|-- Hono
HonoBase <|-- HonoTiny
HonoBase <|-- HonoQuick
The HonoOptions interface at src/hono-base.ts#L46-L87 reveals the three configuration knobs: strict mode (whether trailing slashes matter), a custom router, and getPath (for host-header-based routing). Everything else is framework internals.
Presets: Tiny and Quick
Hono ships three entry points, each a thin class that extends HonoBase with a different router:
| Import | File | Router | Best for |
|---|---|---|---|
hono |
src/hono.ts |
SmartRouter(RegExpRouter + TrieRouter) | General use — fastest matching |
hono/tiny |
src/preset/tiny.ts |
PatternRouter | Smallest bundle size (~14KB) |
hono/quick |
src/preset/quick.ts |
SmartRouter(LinearRouter + TrieRouter) | Fast cold starts (serverless) |
The tiny preset is just 20 lines:
export class Hono<...> extends HonoBase<E, S, BasePath> {
constructor(options: HonoOptions<E> = {}) {
super(options)
this.router = new PatternRouter()
}
}
The quick preset swaps in LinearRouter, which has O(1) route registration but O(n) matching — ideal when you have many routes but each cold start only handles a few requests before the instance is recycled:
Tip: If you're deploying to Cloudflare Workers or AWS Lambda where cold starts matter and you have many routes, start with
hono/quick. For long-running servers, stick with the default —RegExpRoutercompiles all routes into a single regex for O(1) matching.
Directory Structure and the ~90 Export Paths
Hono's package.json defines approximately 90 export paths, each mapping to a distinct module. The src/ directory mirrors this structure cleanly:
| Directory | Export pattern | Purpose |
|---|---|---|
src/middleware/ |
hono/cors, hono/jwt, hono/logger, ... |
Built-in middleware |
src/adapter/ |
hono/cloudflare-workers, hono/aws-lambda, ... |
Runtime adapters |
src/helper/ |
hono/factory, hono/cookie, hono/streaming, ... |
Utility helpers |
src/client/ |
hono/client |
Type-safe RPC client |
src/jsx/ |
hono/jsx, hono/jsx/dom |
JSX runtime (server + client) |
src/router/ |
hono/router/* |
Five router implementations |
src/validator/ |
hono/validator |
Input validation middleware |
src/utils/ |
(internal) | Shared utilities, not exported |
Each export path resolves to a separate module, which means tree-shaking works naturally — importing hono/cors never pulls in the JWT middleware or the JSX runtime.
graph TD
subgraph "Core (always imported)"
A["hono<br/>src/hono.ts"]
B["src/hono-base.ts"]
C["src/context.ts"]
D["src/compose.ts"]
E["src/router.ts"]
end
subgraph "Optional modules"
F["hono/cors"]
G["hono/jwt"]
H["hono/client"]
I["hono/jsx"]
J["hono/validator"]
end
A --> B
B --> C
B --> D
B --> E
Build System: Dual ESM/CJS Output
The build script at build/build.ts#L1-L111 produces three output artifacts in parallel:
- ESM (
dist/) — Bundled with esbuild, using a custom plugin that rewrites.tsimports to.js - CJS (
dist/cjs/) — Also esbuild, withformat: 'cjs' - Type declarations (
dist/types/) — Generated bytsc --emitDeclarationOnly, then post-processed to remove#privatefields from.d.tsfiles
flowchart LR
SRC["src/**/*.ts"] --> ESB1["esbuild (ESM)"]
SRC --> ESB2["esbuild (CJS)"]
SRC --> TSC["tsc --emitDeclarationOnly"]
ESB1 --> DIST_ESM["dist/*.js"]
ESB2 --> DIST_CJS["dist/cjs/*.js"]
TSC --> DTS["dist/types/*.d.ts"]
DTS --> RP["removePrivateFields()"]
RP --> DTS_CLEAN["cleaned .d.ts files"]
The addExtension plugin (lines 42–67) is worth noting — it resolves TypeScript imports to their .js counterparts by checking if the target .ts file exists. This avoids the need for explicit .js extensions in source code while producing valid ESM output.
The post-processing step removePrivateFields() strips TypeScript's #private declarations from .d.ts files. This is necessary because Hono uses JavaScript private fields (#dispatch, #notFoundHandler, etc.) for true encapsulation, but TypeScript exposes them in declaration files, which can cause compatibility issues for consumers.
Component Relationships Overview
Before we trace a request through the framework in the next article, let's establish how the core components relate to each other:
flowchart TD
APP["Hono / HonoBase<br/>app.fetch()"] -->|"creates"| CTX["Context<br/>c.text(), c.json()"]
APP -->|"calls"| ROUTER["Router.match()"]
ROUTER -->|"returns"| MR["Result<T><br/>matched handlers + params"]
MR -->|"passed to"| COMP["compose()<br/>middleware chain"]
COMP -->|"executes handlers with"| CTX
CTX -->|"lazily creates"| REQ["HonoRequest<br/>c.req.param(), c.req.query()"]
CTX -->|"builds"| RES["Response"]
HonoBase orchestrates everything. It holds the router, the error handler, and the not-found handler. When fetch() is called, it delegates to #dispatch(), which extracts the path, asks the router for matching handlers, creates a Context, and either calls the single matched handler directly (a fast path optimization) or runs the handlers through compose().
Context is the "c" in every handler (c) => c.json({...}). It wraps the raw Request, lazily creates a HonoRequest for convenient parameter access, and provides response helpers (c.text(), c.json(), c.html(), c.redirect()). It also manages header merging between middleware layers.
compose() is a 73-line function that implements Koa-style onion middleware. It's the engine that makes await next() work, allowing middleware to run code both before and after downstream handlers.
The Router interface is deliberately minimal: add(method, path, handler) and match(method, path). This contract is what allows five different router implementations to be swapped in transparently.
In the next article, we'll follow a request through this entire pipeline — from app.fetch() through router matching, context creation, and middleware composition, all the way to the final Response.