ZeroStarter

The Type-Safe API

How one AppType flows from Hono routes to a fully-typed client, with no codegen.

One type describes the entire API: every route's path, input, and response shape. The web client imports that type and infers the whole surface from it, so an endpoint call is checked at compile time and there is no client to generate or keep in sync.

The loop

The types flow from the backend to the frontend in a straight line:

api/hono routers → export type AppType → hc<AppType>(url) → apiClient.<route> → unwrap → { data, error }
  • api/hono/src/index.ts composes the routers and exports export type AppType = typeof routes.
  • web/next/src/lib/api/client.ts builds hc<AppType>(url) on top of Hono RPC and exports apiClient.

There is no code generation and no schema to keep in sync: the client reads the backend's exported AppType directly. Rename a route or change its response, and every call to it stops compiling.

Calling the API

Wrap any call in unwrap. It returns a { data, error } result where exactly one side is non-null, and it never throws:

import { apiClient, unwrap } from "@/lib/api/client"

const { data, error } = await unwrap(apiClient.health.$get())
if (error) throw new Error(error.message)
// data: { message: string; version: string; environment: "local" | "development" | "test" | "staging" | "production" }

data is the payload from the { data } envelope, typed from the route's Zod schema. error is { code, message }. code is the API's ErrorCode union plus the two transport codes unwrap itself adds (NETWORK_ERROR, UNKNOWN_ERROR), so it is a closed set you can switch on. See API Conventions for the envelope and the full code list.

The client is pre-configured with credentials: "include", so cookie-based auth just works. A protected route is the same call:

const { data, error } = await unwrap(apiClient.v1.session.$get())
// data is the typed session payload the route declares (or null when error is set)

Auth is not RPC-typed

/api/auth/* is a raw Better Auth handler, not a Hono RPC route, so its responses are not covered by AppType. For a typed session on the server, use the helper in web/next/src/lib/auth/index.ts.

Adding a route

That typed apiClient.v1.session.$get() above works because the route is defined in api/hono/src/routers/ and the router is registered in index.ts. The { Variables: Session } generic is what makes c.get("session") and c.get("user") typed once authMiddleware has run:

// api/hono/src/routers/v1.ts
export const v1Router = new Hono<{ Variables: Session }>()
  .use("/*", authMiddleware)
  .get("/session", describeRoute({/* OpenAPI metadata */}), (c) => {
    // Named field by field rather than handed out whole: the session object carries more than this route documents.
    const session = c.get("session")
    return c.json({
      data: { createdAt: session.createdAt, expiresAt: session.expiresAt, id: session.id },
    })
  })
// api/hono/src/index.ts: served at /api/v1
const routes = app.basePath("/api").route("/v1", v1Router)

Once registered, apiClient.v1.session.$get() appears typed on the frontend with no extra step. Because bun --hot doesn't pick up newly-created files, restart the API after adding one.

The api-endpoint skill has the full recipe: the router template, the mandatory validation hook, where to register, and the curl to test it. See AI Skills.

The API reference

Every route decorated with describeRoute shows up in an interactive Scalar reference at /api/docs, with request and response schemas from the Zod validators and code samples that default to hono/client. The raw spec is at /api/openapi.json.

Live data over WebSockets

The same typed client reaches WebSockets. The health endpoint ships a live example at /api/health/ws, and the RPC object exposes it as apiClient.health.ws.$ws(), so the socket URL derives from the same configured API base with no second client to keep in sync. See Realtime for the Bun and Node adapter split that lets it run on both Bun and Vercel, the reconnect pattern, and the REST-baseline fallback.

Next