Hono and the RPC contract
The generated typed client, why it is not mounted yet, and the open design decision.
What Hono is
Hono is a small, fast web framework built on the Request and Response interfaces from the Fetch API. Its handlers receive a context object c and return a response, with no Node-specific types in sight. That is the whole reason it can run unchanged on Node, Bun, Deno, Cloudflare Workers, and the edge.
1app.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, noproto compiler, no generated client package.
1// server
2const app = new Hono()
3 .get("/api/health", (c) => c.json({ status: "ok" }));
4export type AppRouter = typeof app; // routes are known at the type level
5
6// client
7import { hc } from "hono/client";
8const client = hc<AppRouter>("/");
9await 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. The design intent is written down in ARCHITECTURE.md: client calls are derived from the Hono AppRouter type, POST to /api/<path>, are proxied to Nitro, and reach the matching handler.
What actually 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:
1// AUTO-GENERATED
2import { Hono } from "hono";
3import route_health from "../server/api/health";
4
5const app = new Hono().basePath("/api");
6const routes = app
7 .post("/health", async (c) => {
8 const args = await c.req.json().catch(() => undefined);
9 const result = await route_health(args);
10 return c.json(result);
11 });
12
13export type AppRouter = typeof routes;
14export default app;
1// AUTO-GENERATED
2import { hc } from "hono/client";
3import type { AppRouter } from "./hono";
4
5const client = hc<AppRouter>("/");
6export const api = client.api;
The name AppRouter is the load-bearing export. Everything downstream - the client's method names, the argument types, the return types - is inferred from it.
Current state: generated, not mounted
This is the honest part. The Hono layer is not currently part of the request path. Two things are unfinished, and it is worth naming both rather than implying the typed client works.
- Nothing mounts the router. Nitro is never told that
.bionicjs/hono.tsexists. It keeps servingserver/apithrough h3, which is why/api/healthgenuinely returns200 { status: 'ok' }. The generatedapiclient, meanwhile, 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. So mounting the router as written would call the wrong function with the wrong argument.
In other words: the file-based API works today, and it works without Hono. The typed client is the missing half. See the dev server for the request path that is actually live.
What Nitro says about Hono
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:
1import { Hono } from "hono";
2export default new Hono().get("/api/*", (c) => c.text("Hono handled it"));
3// 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.
nitropack@2.13.4. The serverEntry option and the nitro/vite plugin are Nitro v3 APIs. On 2.13 the equivalents are handlers in nitro.config.ts and starting Nitro yourself - which is what @bionicjs/dev already does. Do not copy v3 snippets into this codebase without upgrading.The alternative: Nitro's own typing
Before adding a second router, it is worth asking what Nitro already gives you. It emits per-route type declarations at dev time:
1interface NitroRoutes {
2 "/api/health": { "get": { "/api/health": { ... } } };
3}
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:
1export default defineEventHandler(async (event) => {
2 const { rows } = await event.$fetch("/api/health"); // typed from the route table
3 return { rows };
4});
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 which needs a cookie header, a redirect, or a streamed response becomes awkward to express through a plain(args) => data function. Whichever way this goes, theserver/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
serverEntry(or Nitro v2handlers) entry - generating the router is not enough. - The h3/Nitro path must keep working as a fallback. It is what currently serves every endpoint.
16 / 25