The server runtime/Hono and the RPC contract

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.

a Hono handler
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.

the whole contract
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:

.bionicjs/hono.ts
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;
.bionicjs/api-client.ts
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.

  1. Nothing mounts the router. Nitro is never told that .bionicjs/hono.ts exists. It keeps serving server/api through h3, which is why /api/health genuinely returns 200 { status: 'ok' }. The generated api client, meanwhile, points at a router that no server is running.
  2. The two layers disagree on the handler contract. The bridge above calls route_health(args) as a plain function. But server/api/health.ts default-exports defineEventHandler(() => ...), an h3 handler that expects an H3Event. 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.

ⓘ
This is why “is the API just straight Nitro?” has the answer it has. Yes - straight Nitro, with h3 handlers, plus a generated Hono router that is not connected yet.

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:

Nitro server entry
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.

ⓘ
Version caveat. BionicJS currently runs 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:

.nitro/types/nitro-routes.d.ts
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:

server/api/report.ts
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 entryRely on Nitro's native types
Handler shapePlain functions taking args, returning data. No event object.h3 handlers receiving an H3Event. Status, headers, cookies, streaming.
Client typinghc<AppRouter> gives a real client for the browser.Route table types the server side; the browser needs a hand-written or generated fetch layer.
CostA 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 v2 handlers) entry - generating the router is not enough.
  • The h3/Nitro path must keep working as a fallback. It is what currently serves every endpoint.