Architecture Overview and Code Navigation
Prerequisites
- ›Basic familiarity with TypeScript
- ›Understanding of web frameworks and the Request/Response API
- ›General knowledge of npm package structure
Architecture Overview and Code Navigation
Hono is a web framework that runs on every major JavaScript runtime — Cloudflare Workers, Deno, Bun, AWS Lambda, and Node.js — with a bundle size starting at under 14 KB. To achieve this, the codebase is organized around a principle of aggressive decomposition: a three-layer class hierarchy separates the public API from router selection from framework logic, five distinct router implementations trade off between cold-start speed and steady-state throughput, and 60+ package export paths let bundlers tree-shake away everything you don't use.
This article will orient you in the codebase so you can navigate it with confidence. We'll trace the path from import { Hono } from 'hono' through the class chain, map the directory structure, understand the preset system, and see how the build pipeline packages it all for consumption.
The Three-Layer Class Hierarchy
When you write import { Hono } from 'hono', the resolution chain passes through three files before you reach the actual framework logic. Understanding this chain is the first key to navigating the codebase.
Layer 1: The public re-export. src/index.ts is the package entry point. It re-exports the Hono class and a curated set of types — Env, Handler, MiddlewareHandler, Context, HonoRequest, and the client inference types. This file is 52 lines of pure re-exports. Its job is to define the public API surface and nothing else.
Layer 2: The router selector. src/hono.ts is a 34-line subclass that exists for exactly one reason: to plug in the default router configuration. The constructor sets this.router to a SmartRouter wrapping RegExpRouter and TrieRouter:
constructor(options: HonoOptions<E> = {}) {
super(options)
this.router =
options.router ??
new SmartRouter({
routers: [new RegExpRouter(), new TrieRouter()],
})
}
Layer 3: The framework engine. src/hono-base.ts contains HonoBase — the 540-line class with all the routing logic, fetch(), #dispatch(), middleware composition, error handling, route(), basePath(), and mount(). Note that the class is declared as class Hono internally but exported as HonoBase on line 539. This naming convention lets each preset re-export its own class as Hono without conflict.
flowchart TD
A["import { Hono } from 'hono'"] --> B["src/index.ts<br/>Re-exports Hono + types"]
B --> C["src/hono.ts<br/>Hono extends HonoBase<br/>Sets SmartRouter(RegExp + Trie)"]
C --> D["src/hono-base.ts<br/>class Hono (exported as HonoBase)<br/>540 lines of framework logic"]
Tip: The
hono-base.tsclass declaresrouter!: Router<[H, RouterRoute]>with a definite assignment assertion (the!). That's because it's designed as an "abstract" class — you must setthis.routerin the subclass constructor. TypeScript's strict mode is satisfied by the assertion, not by initialization in the base class.
Directory Structure and Subsystems
The src/ directory maps cleanly to Hono's major subsystems. Here's an annotated directory map:
| Directory | Purpose | Key Files |
|---|---|---|
src/ (root) |
Core framework | hono-base.ts, context.ts, request.ts, compose.ts, router.ts, types.ts |
src/router/ |
5 router implementations | reg-exp-router/, trie-router/, smart-router/, linear-router/, pattern-router/ |
src/middleware/ |
~25 built-in middleware | cors/, jwt/, bearer-auth/, cache/, compress/, combine/, csrf/, etc. |
src/adapter/ |
Platform adapters | aws-lambda/, cloudflare-workers/, deno/, bun/, netlify/, lambda-edge/, etc. |
src/client/ |
RPC client (hc) |
client.ts, types.ts, utils.ts |
src/jsx/ |
Dual JSX runtime | base.ts, streaming.ts, dom/, context.ts |
src/helper/ |
Utility modules | html/, cookie/, factory/, testing/, streaming/ |
src/validator/ |
Validation integration | validator.ts |
src/utils/ |
Internal utilities | url.ts, html.ts, body.ts, cookie.ts, jwt/ |
The core request path involves just five files: src/hono-base.ts, src/compose.ts, src/context.ts, src/request.ts, and src/router.ts. Everything else — routers, middleware, adapters, JSX — plugs in through clean interfaces.
graph TD
HB[hono-base.ts] --> CO[compose.ts]
HB --> CTX[context.ts]
HB --> R[router.ts interface]
CTX --> REQ[request.ts]
R --> RR[RegExpRouter]
R --> TR[TrieRouter]
R --> SR[SmartRouter]
R --> LR[LinearRouter]
R --> PR[PatternRouter]
HB --> MW[middleware/*]
HB --> AD[adapter/*]
Presets: Router Selection as a Constructor Concern
Hono's design insight is that router selection is a deployment concern, not a framework concern. The same HonoBase class powers all configurations — only the router differs. This is expressed through presets: thin subclasses that wire in a specific router combination.
There are three presets:
| Import Path | Preset | Router Configuration | Best For |
|---|---|---|---|
hono |
Default | SmartRouter(RegExpRouter, TrieRouter) |
Long-running servers |
hono/tiny |
Tiny | PatternRouter |
Smallest bundle (~14 KB) |
hono/quick |
Quick | SmartRouter(LinearRouter, TrieRouter) |
Serverless cold starts |
The src/preset/tiny.ts preset is just 20 lines — it extends HonoBase and sets this.router = new PatternRouter(). The src/preset/quick.ts preset is similarly concise, using SmartRouter with LinearRouter and TrieRouter.
flowchart LR
subgraph Default["hono (default)"]
SM1[SmartRouter] --> RE[RegExpRouter]
SM1 --> TR1[TrieRouter]
end
subgraph Quick["hono/quick"]
SM2[SmartRouter] --> LR[LinearRouter]
SM2 --> TR2[TrieRouter]
end
subgraph Tiny["hono/tiny"]
PR[PatternRouter]
end
The default preset tries RegExpRouter first — it compiles all routes into a single regex for O(1) matching. If any route pattern is too complex (e.g., mixing wildcards with named parameters in certain ways), RegExpRouter throws UnsupportedPathError, and SmartRouter falls through to TrieRouter. We'll explore this mechanism in detail in Part 3.
Tip: If you know your routes are simple, the default preset is fastest at steady state. If you're deploying to a serverless platform where cold start time matters more than per-request throughput, use
hono/quick. If bundle size is your constraint, usehono/tiny.
Module Export System and Build Pipeline
Hono's package.json contains 60+ export paths, enabling imports like hono/cors, hono/jwt, hono/aws-lambda, and hono/jsx. This is not merely cosmetic — it's the foundation of Hono's tree-shaking story. Each export path maps to a separate module, so bundlers can include only the code you actually import.
The build pipeline in build/build.ts runs three tasks in parallel:
- ESM build via esbuild — bundles each
src/**/*.tsfile todist/with.jsextensions - CJS build via esbuild — outputs to
dist/cjs/for Node.jsrequire()compatibility - Type declarations via
tsc --emitDeclarationOnly— outputs.d.tsfiles todist/types/
flowchart TD
SRC["src/**/*.ts"] --> ESM["esbuild → dist/ (ESM)"]
SRC --> CJS["esbuild → dist/cjs/ (CJS)"]
SRC --> DTS["tsc → dist/types/ (.d.ts)"]
DTS --> POST["removePrivateFields()<br/>Strip #private from .d.ts"]
subgraph Validation
PKG["package.json exports"] <-->|validateExports| JSR["jsr.json exports"]
end
Two post-build steps are worth noting. First, the script calls removePrivateFields() on all .d.ts files to strip TypeScript's #private field declarations — these are an implementation detail that shouldn't leak into the public type surface. Second, validateExports() cross-checks the export paths in package.json against jsr.json (for Deno's JSR registry), ensuring they stay in sync.
The build uses a custom esbuild plugin called addExtension that rewrites import paths to include .js extensions — necessary for proper ESM resolution in Node.js. The entry points are globbed from src/**/*.ts, excluding test files and Deno-specific modules.
The Constructor: Dynamic Method Assignment
One detail in hono-base.ts that might surprise you: the HTTP method functions (get, post, put, delete, options, patch, all) are not defined as class methods. They're assigned dynamically in the constructor at src/hono-base.ts#L126-L173:
const allMethods = [...METHODS, METHOD_NAME_ALL_LOWERCASE]
allMethods.forEach((method) => {
this[method] = (args1: string | H, ...args: H[]) => {
if (typeof args1 === 'string') {
this.#path = args1
} else {
this.#addRoute(method, this.#path, args1)
}
args.forEach((handler) => {
this.#addRoute(method, this.#path, handler)
})
return this as any
}
})
This supports two calling conventions: app.get('/path', handler) and the chained form app.get('/path').get(handler). When the first argument is a string, it sets this.#path; when it's a handler, it registers the route with the previously set path. The type-level counterparts are declared on the class as get!: HandlerInterface<...> — definite assignment assertions that TypeScript resolves through the constructor's dynamic assignment.
Putting It Together
Here's the mental model for navigating Hono's source:
- To understand how requests flow: read
hono-base.ts→compose.ts→context.ts→request.ts - To understand routing: read
router.ts(interface), then any router inrouter/ - To understand a middleware: read the middleware's
index.ts— they're self-contained - To understand platform support: read an adapter in
adapter/ - To understand types: read
types.ts— it's whereHandlerInterfaceandToSchemalive
The codebase is remarkably flat. There are no dependency injection frameworks, no plugin registries, no configuration inheritance chains. Just classes, functions, and interfaces.
In the next article, we'll trace a single HTTP request through the entire framework — from fetch() through #dispatch(), router matching, middleware composition, and response building — to see how these pieces fit together at runtime.