bionicjs
Blog

Hono is not our server

We generated a Hono router and told the docs it was the request handler. Neither was true. What Hono is for, what actually serves requests today, and the decision we still owe you.

  • Hono
  • Architecture
  • Correcting the docs

Two of our documentation pages said, in different words, that Hono is the request handler inside Nitro. One of them had a callout explaining that this was not the case. Both were wrong, and the truth is more interesting than either version.

What Hono is

Hono is a small web framework built on the Fetch API. Handlers take a context object and return a response, with no Node types involved:

app.get("/api/health", (c) => c.json({ status: "ok" }));

That design is why it runs unchanged on Node, Bun, Deno, Cloudflare Workers, and the edge.

The thing to be clear about: Hono is not built on h3, and h3 is not built on Hono. They are separate projects solving overlapping problems. Nitro happens to use h3 internally. Mounting Hono in a Nitro server means choosing a different request handler for the same server — not adding a layer on top of one, and certainly not a second server.

What we generate, and why

Hono has one trick worth stealing. A router is an ordinary value, so its own type describes every route it registered:

const app = new Hono().get("/api/health", (c) => c.json({ status: "ok" }));
export type AppRouter = typeof app;

Export that type and the client is typed for free. No openapi.json, no proto compiler, no generated client package. This is the best answer anyone has come up with to the problem every full-stack framework has: typing across the network boundary.

So @bionicjs/dev reads your server/api tree and writes two files:

// AUTO-GENERATED
import { Hono } from "hono";
import route_health from "../server/api/health";

const app = new Hono().basePath("/api");
const routes = app
  .post("/health", async (c) => {
    const args = await c.req.json().catch(() => undefined);
    const result = await route_health(args);
    return c.json(result);
  });

export type AppRouter = typeof routes;
export default app;
import { hc } from "hono/client";
import type { AppRouter } from "./hono";

const client = hc<AppRouter>("/");
export const api = client.api;

AppRouter is the load-bearing export. Every method name, argument type, and return type on the client is inferred from it.

What actually happens

Nothing mounts that router.

Nitro is never told .bionicjs/hono.ts exists. It scans server/api, builds an h3 router, and serves every endpoint through that. GET /api/health returns 200 {"status":"ok"} — from server/api/health.ts, as an h3 event handler, with no Hono involved at any point.

Meanwhile the generated api client points at a router that no server is running.

There is a second problem, and it is the more interesting one. Even if we mounted the router tomorrow, it would not work. The bridge calls route_health(args) — a plain function call. But your route file default-exports defineEventHandler(() => ...), an h3 handler that expects an H3Event. The two layers do not agree on what a handler is.

So: generated, unmounted, and contract-incompatible. We would rather say that in one paragraph than let you discover it from a 404.

What Nitro says about Hono

Hono is the most common server entry in the Nitro ecosystem, with Elysia, Express, and Fastify. The documented approach is a server.ts entry that handles everything:

import { Hono } from "hono";

export default new Hono().get("/api/*", (c) => c.text("Hono handled it"));

It is a catch-all, so more specific filesystem routes still win. You register it with serverEntry in nitro.config.ts, and on current Nitro you add import { nitro } from "nitro/vite" so one Vite dev server owns both halves.

A caveat we keep having to restate: we run nitropack@2.13.4. serverEntry and nitro/vite are Nitro v3 APIs. On 2.13 the equivalents are handlers in the config and starting Nitro yourself — which is what @bionicjs/dev already does. Copying v3 snippets into this codebase without upgrading will not work.

The option we should have mentioned first

Nitro already emits per-route types of its own:

interface NitroRoutes {
  "/api/health": { "get": { "/api/health": { ... } } };
}

Those feed the typed event.$fetch() helper, so a server handler can call its own API with full type checking and no generated client at all.

One trap: NitroApp in nitropack@2.13 is the type of the runtime instance — h3App, router, hooks, localFetch — not a client-side route map. It is not the analogue of Hono's AppRouter. nitro-routes.d.ts and $Fetch are.

The actual decision

Mount Hono as the server entry Rely on Nitro's native types
Handler shape Plain functions taking args, returning data h3 handlers receiving an H3Event
Client typing hc<AppRouter> — a real browser client Server side only; the browser needs its own fetch layer
Cost A second routing layer and a bridge to keep in sync Nothing extra, but the browser client is ours to write

The honest tension: hc is genuinely good, and the cost is that a request needing a cookie header, a redirect, or a streamed response is awkward to express as (args) => data. An H3Event is not a nuisance — it is where status codes, cookies, and streaming live.

We have not decided. Two things are true at once: the file-based API works today and works without Hono, and the typed client we promised does not yet work at all. The server/api convention will not change either way, because that is the part you touch.

If you have a use case that decides this — streaming responses to the browser, or a typed client you want today — we would like to hear it.