Hono
Hono is a standards-based web framework, independent of Nitro and h3. bionicjs generates a Hono router and a typed client from your server/api tree — and today that generated router is not mounted. This page explains the idea, the current state, and the open decision.
What Hono is
Hono is a small, fast web framework built on the Fetch API's Request and
Response. Its handlers receive a context object c and return a
response, with no Node-specific types in sight. That is why the same
router runs unchanged on Node, Bun, Deno, Cloudflare Workers, and the edge.
app.get("/api/health", (c) => c.json({ status: "ok" }));
The important correction, because it comes up constantly: Hono is not built on h3, and h3 is not built on Hono. They are independent projects that solve overlapping problems at different layers. Nitro happens to use h3 internally. If you mount Hono as a server entry, you are choosing a different request handler for the same Nitro server — not adding a second server.
The idea: types without codegen
Hono has a trick worth stealing. A router is an ordinary value, so the
router's own type describes every route it registered. Expose that type
and the client is typed for free — no openapi.json, no proto
compiler, no generated client package.
// server
const app = new Hono()
.get("/api/health", (c) => c.json({ status: "ok" }));
export type AppRouter = typeof app; // routes are known at the type level
// client
import { hc } from "hono/client";
const client = hc<AppRouter>("/");
await client.api.health.$get(); // typed, autocomplete, refactor-safe
bionicjs generates exactly that pair from your server/api tree, so a
file-based endpoint is also an end-to-end typed client method.
What gets generated
generateRPC() in @bionicjs/dev globs server/api/**/*.ts and emits
two files into .bionicjs/. Given a single health.ts endpoint, the
output is:
// 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;
// AUTO-GENERATED
import { hc } from "hono/client";
import type { AppRouter } from "./hono";
const client = hc<AppRouter>("/");
export const api = client.api;
The AppRouter export is the load-bearing one. Everything downstream —
the client's method names, the argument types, the return types — is
inferred from it.
Current state: not mounted
This is the honest part. The Hono layer is not currently part of the request path. Two things are unfinished, and both are worth naming rather than implying the typed client works.
Nothing mounts the router. Nitro is never told
.bionicjs/hono.tsexists. It keeps servingserver/apithrough h3, which is why/api/healthgenuinely returns200. The generatedapiclient points at a router that no server is running.The two layers disagree on the handler contract. The bridge above calls
route_health(args)as a plain function. Butserver/api/health.tsdefault-exportsdefineEventHandler(() => ...), an h3 handler that expects anH3Event. Mounting the router as written would call the wrong function with the wrong argument.
The file-based API works today, and it works without Hono. The typed client is the missing half.
The Hono integration in Nitro
Hono is the most common server entry in the Nitro ecosystem, alongside
Elysia, Express, and Fastify. Nitro's 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"));
// or mount it: app.route("/api", routes), or export `{ fetch }`
The entry is a catch-all for unmatched requests, so more specific
filesystem routes still win. You register it with serverEntry in
nitro.config.ts, and with current Nitro you add the framework's Vite
plugin import { nitro } from "nitro/vite" so one Vite dev server owns
both halves.
Version caveat. bionicjs currently runs
nitropack@2.13.4. TheserverEntryoption and thenitro/viteplugin are Nitro v3 APIs. On 2.13 the equivalents arehandlersinnitro.config.tsand starting Nitro yourself — which is what@bionicjs/devalready does. Do not copy v3 snippets into this codebase without upgrading.
Native typing in Nitro
Before adding a second router, it is worth asking what Nitro already gives you. It emits per-route type declarations at dev time:
interface NitroRoutes {
"/api/health": { "get": { "/api/health": { ... } } };
}
Those declarations 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:
export default defineEventHandler(async (event) => {
const { rows } = await event.$fetch("/api/health"); // typed from the route table
return { rows };
});
Note what NitroApp is, because it is easy to guess wrong: in
nitropack@2.13 it is the type of the runtime instance — h3App,
router, hooks, localFetch — not a client-side route map like Hono's
AppRouter. For client-side typing, nitro-routes.d.ts and $Fetch are
the surfaces.
The open decision
Two coherent designs, and the framework has not committed yet. Both are defensible; they differ in what a browser client has to do.
| Mount Hono as the server entry | Rely on Nitro's native types | |
|---|---|---|
| Handler shape | Plain functions taking args, returning data. No event object. | h3 handlers receiving an H3Event. Status, headers, cookies, streaming. |
| Client typing | hc<AppRouter> gives a real client for the browser. |
Route table types the server side; the browser needs a hand-written or generated fetch layer. |
| Cost | A second routing layer plus a bridge that has to keep two contracts in sync. | Nothing extra — but the browser client is on us. |
The honest summary: hc is genuinely nice, and the cost is that a
request needing a cookie header, a redirect, or a streamed response
becomes awkward to express through a plain (args) => data function.
Whichever way this goes, the server/api file convention should not
change — that is the part users touch.
- Either way, the fix for the contract mismatch is the same: pick one handler shape and make the generator emit a bridge that matches it.
- If Hono is mounted, it also needs a server entry — generating the router is not enough.
- The h3/Nitro path must keep working as a fallback. It is what currently serves every endpoint.