Read OSS

Architecture Overview and How to Navigate the Hono Codebase

Intermediate

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:

src/index.ts#L1-L52

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 app in a Hono project, the runtime calls app.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.

src/hono.ts#L16-L34

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:

src/preset/tiny.ts#L1-L20

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:

src/preset/quick.ts#L1-L24

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 — RegExpRouter compiles 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:

  1. ESM (dist/) — Bundled with esbuild, using a custom plugin that rewrites .ts imports to .js
  2. CJS (dist/cjs/) — Also esbuild, with format: 'cjs'
  3. Type declarations (dist/types/) — Generated by tsc --emitDeclarationOnly, then post-processed to remove #private fields from .d.ts files
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&lt;T&gt;<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.