Large Hono apps are built from smaller Hono apps. All three composition tools -- app.route(), app.basePath(), and app.mount() -- live in src/hono-base.ts and reduce to the same primitive: #addRoute prefixing paths into a single flat route table. There is no nested dispatch at request time.

Every registration funnels through #addRoute

src/hono-base.ts
#addRoute(method: string, path: string, handler: H, baseRoutePath?: string): void {
  method = method.toUpperCase()
  path = mergePath(this._basePath, path)
  const r: RouterRoute = {
    basePath:
      baseRoutePath !== undefined ? mergePath(this._basePath, baseRoutePath) : this._basePath,
    path,
    method,
    handler,
  }
  this.router.add(method, path, [handler, r])
  this.routes.push(r)
}

The instance's _basePath is baked into the stored path at registration time. app.routes keeps the human-readable record (used by app.request introspection, the hono/dev helper, and the RPC type surface).

basePath returns a shallow clone

basePath does not mutate the app -- it clones it, sharing the router, the routes array, and the handlers, but with a different _basePath:

src/hono-base.ts
basePath<SubPath extends string>(path: SubPath) {
  const subApp = this.#clone()
  subApp._basePath = mergePath(this._basePath, path)
  return subApp
}

Because the clone shares this.routes and this.router, routes registered on either object land in the same table -- only the prefix differs. const api = new Hono().basePath('/api') is therefore free at request time; it is pure registration-time bookkeeping.

route() replays a sub-app's routes

app.route('/api', subApp) iterates the sub-app's recorded routes and re-adds each one onto a basePath-clone of the parent. One subtlety: if the sub-app had its own onError handler, each transplanted handler is wrapped so that the sub-app's error handler still applies to its own routes:

src/hono-base.ts
route(path, app) {
  const subApp = this.basePath(path)
  app.routes.map((r) => {
    let handler
    if (app.errorHandler === errorHandler) {
      handler = r.handler
    } else {
      handler = async (c: Context, next: Next) =>
        (await compose([], app.errorHandler)(c, () => r.handler(c, next))).res
      ;(handler as any)[COMPOSED_HANDLER] = r.handler
    }

    subApp.#addRoute(r.method, r.path, handler, r.basePath)
  })
  return this
}

After route() returns, the sub-app object is no longer involved in dispatch; its routes are the parent's routes. This is why route grouping in Hono has no per-request cost.

mount() hosts non-Hono handlers

mount bridges to any (request, ...args) => Response function -- another framework, or a bare fetch handler. It registers a single catch-all middleware under the mount point that rewrites the URL to strip the prefix, then delegates:

src/hono-base.ts
replaceRequest ||= (() => {
  const mergedPath = mergePath(this._basePath, path)
  const pathPrefixLength = mergedPath === '/' ? 0 : mergedPath.length
  return (request) => {
    const url = new URL(request.url)
    url.pathname = this.getPath(request).slice(pathPrefixLength) || '/'
    return new Request(url, request)
  }
})()

const handler: MiddlewareHandler = async (c, next) => {
  const res = await applicationHandler(replaceRequest(c.req.raw), ...getOptions(c))

  if (res) {
    return res
  }

  await next()
}
this.#addRoute(METHOD_NAME_ALL, mergePath(path, '*'), handler)

If the mounted application returns nothing, Hono falls through to its own routes (await next()), so a mount point can coexist with native routes. By default the mounted handler also receives c.env and the execution context as extra arguments; optionHandler and replaceRequest: false override both behaviors.

Related

  • Registration order and how the flat table is matched: Routing
  • The strict and getPath options that affect path normalization before matching: Configuration

Sources: src/hono-base.ts · last synced 2026-08-10 · 26de731 · version 4.13.1