The Architecture of Hono: A Map of the Codebase
Prerequisites
- ›Basic TypeScript familiarity
- ›General understanding of HTTP request/response cycle
- ›Some prior exposure to a Node.js or edge-runtime web framework
The Architecture of Hono: A Map of the Codebase
Most web frameworks tell you what they do. Hono is worth studying for how it does it. In under 540 lines of core engine code, it delivers a framework that runs identically on Cloudflare Workers, Deno, Bun, Node.js, and AWS Lambda — with zero runtime dependencies and a type system that propagates route definitions all the way to an auto-generated RPC client. This article is the map. Every subsequent article in this series will reference the landmarks we establish here.
What Is Hono and Why Does It Exist?
Hono (炎, Japanese for "flame") is an ultrafast web framework built exclusively on Web Standards APIs. That single design constraint — depend only on Request, Response, URL, and fetch — is what makes multi-runtime support possible without adapter shims in the hot path.
The project targets v4.x (4.12.9 at the time of this writing) and weighs roughly 92,000 lines including tests. Yet the core — what actually handles your request — is a single file of ~540 lines. Everything else is opt-in: routers, middleware, JSX rendering, validators, and runtime adapters, each importable from its own sub-path.
The entry point says it all. src/index.ts re-exports the Hono class and a handful of core types. That's the entire public surface of import { Hono } from 'hono'.
Repository Directory Structure
Before diving into any implementation detail, let's build a mental model of where things live:
| Directory | Purpose |
|---|---|
src/hono.ts |
Concrete Hono class (injects default router) |
src/hono-base.ts |
Router-agnostic core engine (~540 lines) |
src/router/ |
Five router implementations |
src/middleware/ |
25+ built-in middleware modules |
src/helper/ |
Utility helpers (cookie, html, factory, etc.) |
src/adapter/ |
Runtime adapters (AWS Lambda, Bun, Cloudflare, Deno, Node.js) |
src/client/ |
hc — the typed RPC client |
src/validator/ |
Validation middleware core |
src/jsx/ |
Full JSX engine (SSR, streaming, client-side DOM) |
src/preset/ |
Alternative Hono configurations (tiny, quick) |
src/utils/ |
Shared utilities (URL parsing, HTML escaping, encoding) |
build/ |
esbuild-based build pipeline |
runtime-tests/ |
Per-runtime test suites (Deno, Bun, Cloudflare, etc.) |
graph TD
subgraph Core
HB[hono-base.ts<br/>~540 lines]
H[hono.ts]
C[compose.ts]
CTX[context.ts]
REQ[request.ts]
end
subgraph Routers
SR[SmartRouter]
RER[RegExpRouter]
TR[TrieRouter]
LR[LinearRouter]
PR[PatternRouter]
end
subgraph Presets
P1[default: hono.ts]
P2[tiny: PatternRouter]
P3[quick: LinearRouter+TrieRouter]
end
H --> HB
HB --> C
HB --> CTX
CTX --> REQ
H --> SR
SR --> RER
SR --> TR
P2 --> PR
P3 --> LR
P3 --> TR
Tip: When exploring an unfamiliar feature, start with the
src/middleware/<name>/index.tsorsrc/helper/<name>/index.tsconvention. Every middleware and helper follows this exact pattern.
The Two-Class Pattern: HonoBase and Hono
The most important architectural decision in the codebase is the split between HonoBase and Hono. The comment at src/hono-base.ts#L114-L117 says it explicitly:
This class is like an abstract class and does not have a router.
To use it, inherit the class and implement router in the constructor.
HonoBase contains everything about request handling: route registration, the #dispatch() pipeline, fetch(), request(), .route(), .mount(), error handling, and the compose() integration. What it deliberately omits is the router choice.
The concrete src/hono.ts then extends HonoBase and injects a default router in its constructor:
export class Hono<E, S, BasePath> extends HonoBase<E, S, BasePath> {
constructor(options: HonoOptions<E> = {}) {
super(options)
this.router =
options.router ??
new SmartRouter({
routers: [new RegExpRouter(), new TrieRouter()],
})
}
}
This is the Strategy pattern at its cleanest. The presets are just alternative subclasses that inject different routers.
classDiagram
class HonoBase {
+router: Router
+fetch()
+request()
-dispatch()
+route()
+mount()
+onError()
+notFound()
}
class Hono {
+constructor(): SmartRouter~RegExpRouter+TrieRouter~
}
class HonoTiny {
+constructor(): PatternRouter
}
class HonoQuick {
+constructor(): SmartRouter~LinearRouter+TrieRouter~
}
HonoBase <|-- Hono
HonoBase <|-- HonoTiny
HonoBase <|-- HonoQuick
Presets: tiny, quick, and Default
Each preset optimises for a different axis:
| Preset | Import Path | Router(s) | Optimised For |
|---|---|---|---|
| Default | hono |
SmartRouter → RegExpRouter + TrieRouter | Throughput (steady-state performance) |
| tiny | hono/tiny |
PatternRouter only | Bundle size (<12 kB) |
| quick | hono/quick |
SmartRouter → LinearRouter + TrieRouter | Cold-start speed (serverless) |
The tiny preset at src/preset/tiny.ts is just 20 lines — it extends HonoBase and assigns new PatternRouter(). That's the entire implementation.
The quick preset at src/preset/quick.ts swaps RegExpRouter for LinearRouter. LinearRouter has O(1) registration time but O(n) match time — the right trade-off when your function boots cold on every invocation and serves only a handful of requests before being evicted.
Tip: If you're deploying to a serverless environment where cold starts matter more than steady-state throughput,
import { Hono } from 'hono/quick'is a one-line swap that can shave milliseconds off your initialization time.
The 150+ Export Paths System
Open package.json and look at the exports field. Every sub-path import like hono/cors, hono/jwt, or hono/aws-lambda is individually mapped:
{
"./cors": {
"types": "./dist/types/middleware/cors/index.d.ts",
"import": "./dist/middleware/cors/index.js",
"require": "./dist/cjs/middleware/cors/index.js"
}
}
Each entry provides three things: TypeScript types, ESM import, and CJS require. This has two critical benefits:
- Tree-shaking: Bundlers only pull in what you actually import. Using
hono/corsdoesn't pull inhono/jwt. - Dual-format support: The same package works in ESM-first runtimes (Deno, Bun, modern Node) and legacy CJS consumers.
The jsr.json file mirrors these exports for the Deno / JSR registry, and the build script validates they stay in sync.
Build Pipeline
The build system lives in build/build.ts and is refreshingly straightforward:
flowchart LR
A["src/**/*.ts"] --> B["esbuild (ESM)"]
A --> C["esbuild (CJS)"]
A --> D["tsc --emitDeclarationOnly"]
B --> E["dist/*.js"]
C --> F["dist/cjs/*.js"]
D --> G["dist/types/*.d.ts"]
G --> H["removePrivateFields()"]
subgraph Validation
I["validateExports()"]
end
I -.-> B
Key design choices worth noting:
- No bundling for ESM output — the
addExtensionplugin rewrites imports to add.jsextensions but marks everything external. Each source file maps 1:1 to an output file. - Parallel builds — ESM, CJS, and type declarations run concurrently via
Promise.all(). - Export validation — before building,
validateExports()checks that every key inpackage.jsonexports exists injsr.jsonand vice versa. This prevents drift between npm and Deno consumers. - Private field removal — TypeScript's
#privatefields leak into.d.tsfiles. A post-build step strips them for cleaner public API types.
The build globs all src/**/*.ts files (excluding tests and Deno-specific modules) as entry points. This means adding a new middleware is as simple as creating src/middleware/<name>/index.ts and adding the corresponding export path to package.json — no build configuration changes needed.
What's Next
With this mental map in hand, you understand where to find anything in Hono's codebase and why it's structured as it is. In the next article, we'll trace the exact journey a request takes from app.fetch() through the dispatch pipeline, the middleware composition engine, and Context construction — the heartbeat of the entire framework.