bionicjs
Blog

Should we layer Hono on Nitro?

We built a Hono router, never mounted it, then found Nitro already generates a typed client from server/api. Five ways the codegen was wrong, what twelve other frameworks do, and why this ends in a deletion.

  • Hono
  • Nitro
  • Architecture
  • Decision

Our code generates a Hono router. We never mounted it. Then we asked whether we should — and found that the framework we already depend on does the same job, for free, with no second web framework in the picture.

This is the story of that check, and why we are deleting the code.

The short answer

It is an overstretch. Not a small one, and not a "we should do it properly" thing. The whole idea was wrong, and the framework we already use had the feature the Hono layer was invented to provide.

What the code was supposed to do

BionicJS puts your API in server/api. A file is an endpoint:

import { defineEventHandler } from "h3";

export default defineEventHandler(() => ({ status: "ok" }));

The plan was to scan that folder, build a Hono router out of it, and hand you a client that knows every route and every return type. So when you call api.health(), TypeScript knows it returns { status: string }.

That goal is good. Typing a server folder all the way to the browser is the nice thing about this framework.

The problem is the machine we built to do it.

Five ways the generated router was wrong

We wrote about 60 lines that turned server/api into a Hono app. It was not wired to anything, and it did not match the files it was reading.

1. Nothing ever loaded it. The generator wrote the file to disk. No other file in the repo imported it. It was a router in a drawer.

2. It called handlers with the wrong argument. Your files export h3 handlers. The first argument of an h3 handler is the request event, not a JSON body. The generated code passed the parsed JSON body as if it were the event. Every handler would have read event.node or event.method from a plain object and gotten undefined.

3. It made every route a POST. The generated router called .post(...) for every file, whatever the handler was. server/api/health.ts is served by Nitro as GET /api/health. The generated router claimed the same URL as POST /api/health. Two layers, one address, no rule about who wins.

4. The client pointed at the wrong server. The client did fetch("/api/..."). The /api proxy sends that to Nitro. So even if the Hono router had been mounted, the client would still be talking to Nitro. The types would have described a server that never received the request.

5. The client we actually shipped is any. api.health() returned any. Not "mostly typed". any. The type safety was a promise in a readme.

The part that decided it

While checking all this, we found something in the framework we already use.

bionicjs dev already calls Nitro's prepare(). That function reads your server/api folder and writes a types file. For a health route, it writes this:

// Generated by nitro
declare module "nitropack/types" {
  interface InternalApi {
    '/api/health': {
      'default': Simplify<Serialize<Awaited<ReturnType<
        typeof import('../../server/api/health').default>>>>
    }
  }
}

Read that slowly. It says: the route /api/health has whatever type your actual handler file returns. It does that with typeof import(), which means TypeScript works it out on its own. No schema, no validator, no second framework, no runtime code at all — it is a types file.

We tested it. In a scaffolded app:

const ok = await $fetch("/api/health");

const s: string = ok.status;   // fine
const n: number = ok.status;   // Error: Type 'string' is not
                              // assignable to type 'number'.

The type came from our own health.ts. Then we deleted the generated file and ran the same check:

const ok = await $fetch("/api/health");
// 'ok' is of type 'unknown'.

That is the whole proof. With Nitro's generated types, the client is typed from the real handler. Without them, it is unknown. We were already running the code that makes this work, on every bionicjs dev, and had built a second system to do it worse.

So what do other frameworks do?

We went and looked at a lot of them. The pattern was very consistent.

Nobody puts two web frameworks on top of each other. Not one. Every framework we checked uses exactly one router, and that router owns the URLs.

  • Next.js — its own server. You can add a custom server, and the docs tell you not to: it loses optimizations, breaks their deploy target, and they call it an eject hatch.
  • Nuxt — one h3 app. No second server. (This is our closest cousin, so we looked hardest.)
  • SvelteKit, Astro, TanStack Start, React Router 7 — one server layer each. None of them build a typed HTTP client from a route folder.
  • tRPC — not a server at all. Its own docs say it must be served by something else.

Even Nitro's own Hono integration is not what we were building. It registers Hono as one fallback route at /** — a last resort for requests that nothing else claimed. The docs are clear that specific routes always win, and warn against using it for things that must run on every request, like logging or auth.

That warning is the whole argument. If Hono can only see requests Nitro did not handle, it can never see your server/api routes — the ones it was generated from.

Two teams, same problem, same answer. Given "we want to use Hono", Astro's answer was to make its own handlers usable by Hono middleware. Nitro's answer was a fallback route. Two different teams, neither of them made Hono a second router sitting beside their own.

The one who did, regrets it. Redwood runs two layers: a Fastify server and a GraphQL server. It is the standard example of a full-stack framework doing this. Its own docs now describe the GraphQL API as optional, in favour of React Server Components and Server Actions — a move back to one layer. A community post about SvelteKit's backend notes that form return values are not typed end to end, and recommends tRPC instead. GraphQL is a lot of machinery to answer "what is this route's return type".

Was there a good use for Hono at all?

Yes, two real ones. Neither needs what we built.

Hono has good middleware. Rate limiters, bearer auth, request IDs. You can use them from Nitro without a second router.

A separate public API is a fair want. If you ever want a distinct API at, say, /public, you can mount one at a prefix that does not collide, on purpose:

import { fromWebHandler } from "h3";
app.mount("/public", fromWebHandler(honoApp.fetch));

One router owns the URLs. Hono owns one branch of the tree. That is a decision, and it is a reasonable one. But it is a decision you make deliberately, not something you get by accident from codegen.

What we are doing

Deleting it. The list is short:

  • Remove the Hono router generation, the client generation, and Hono from the template's dependencies.
  • Delete the any proxy client. Point the client at Nitro's generated types, which already exist.
  • Say plainly in the docs that typing comes from Nitro, and show $fetch.

This is a deletion, not a project. Which is the pleasant surprise: the feature we wanted was real, and we already had it.

The lesson we are keeping

We added a layer because the goal was good, without checking whether the layer was needed. The check was four commands:

rg 'hono' --glob '!**/node_modules/**'
cat .nitro/types/nitro-routes.d.ts

A 60-line generator that nothing imported should have been caught the day it was written. Instead it sat there long enough to end up in a readme, and then in a second readme, and then in a sentence we had to correct in public.

Two things in one sentence: a codegen layer nobody mounts is not a feature, and before you build the layer, read what your dependencies already do. We were reading Nitro's source the whole time. The answer was in the file the framework wrote for us.