Hono RPC + Zod: Type-Safe From Server to Client With No Codegen

Short answer: Hono's RPC makes types flow straight from a server route to the call site on the client with no codegen — you export type AppType = typeof route on the server, then hc<AppType>() on the client. The make-or-break rule few people mention up front: the routes must be method-chained (.get().post()...) for TypeScript to infer the whole structure. This is the deep dive following the RPC teaser in the Hono experience post.

TL;DR (Executive Summary)

  • The problem: In any app where a frontend calls its own API, types break at the network boundary. Change an endpoint's shape and you hand-fix the call sites, while the compiler stays silent until it fails at runtime.
  • The solution: Define a route with zValidator (Zod), export type that route, then use hc() on the client. Both input types (validator) and output types (c.json()) are inferred.
  • The outcome: Change a schema on the server → the client goes red at compile time. But you must hold three rules: chaining, app.route() when splitting files, and strict: true on both sides of the monorepo.

What RPC means here (and how it differs from tRPC)

RPC in Hono isn't a new protocol. Your routes are still ordinary REST — still callable with curl. "RPC" is just a thin type layer: the hc() client reads the server's AppType and exposes pre-typed call methods over fetch.

The difference from tRPC: tRPC builds a bespoke protocol on top of HTTP. Hono keeps plain HTTP/fetch. If you want endpoints that stay regular APIs while being type-safe in TypeScript, Hono's model fits better — and you don't lock the client into a tRPC runtime.

Step 1: validate with Zod, get types for free

zValidator takes a "target" (json, query, param, form, header, cookie) and a Zod schema. After it runs, c.req.valid(target) returns the validated data with its type:

import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'

const app = new Hono()
  .post(
    '/notes',
    zValidator('json', z.object({ title: z.string(), body: z.string() })),
    (c) => {
      const data = c.req.valid('json')   // { title: string; body: string }
      return c.json({ id: 1, ...data }, 201)
    }
  )
  .get(
    '/notes',
    zValidator('query', z.object({ page: z.coerce.number().default(1) })),
    (c) => {
      const { page } = c.req.valid('query')   // page: number (coerced)
      return c.json({ page, items: [] }, 200)
    }
  )

Two things to notice: the schema both blocks junk requests at runtime (returns 400 on bad input) and generates types at compile time. One source of truth, two effects. And note I always specify the status code in c.json(x, 201) — that's required for inferring the response type on the client (see below).

Step 2: the biggest gotcha — you must method-chain

This is where most of the time goes. RPC infers types from the app variable's type. If you write each route as a separate statement, that type doesn't accumulate:

// ❌ WRONG — the client will see no routes
const app = new Hono()
app.get('/notes', handlerA)
app.post('/notes', handlerB)
export type AppType = typeof app   // empty as far as RPC is concerned
// ✅ RIGHT — chain into one expression, then export
const app = new Hono()
  .get('/notes', handlerA)
  .post('/notes', handlerB)
export type AppType = typeof app   // both routes inferred

When the project grows and you want to split files, don't revert to the separate form. Split with app.route(), where each file is still one chain:

// notes.ts
const notes = new Hono()
  .get('/', (c) => c.json([{ id: 1 }]))
  .post('/', zValidator('json', schema), (c) => c.json({ ok: true }, 201))
export default notes

// index.ts
import notes from './notes'
const app = new Hono().route('/notes', notes)
export type AppType = typeof app
export default app

If the client reports "no types" despite the export — 9 times out of 10 something slipped out of the chain.

Step 3: the hc() client — call it like RPC, it's still fetch

On the client, pass AppType to hc. Paths map to the route structure, methods are $get/$post:

import { hc } from 'hono/client'
import type { AppType } from './server'

const client = hc<AppType>('https://api.example.com/')

const res = await client.notes.$post({
  json: { title: 'Learning Hono', body: 'a neutral note' },
})

if (res.ok) {
  const note = await res.json()   // type inferred from c.json() on the server
}

// param and query are always passed as strings, even when the schema is a number
const list = await client.notes.$get({ query: { page: '2' } })

Note two things: param/query are always strings (that's the nature of a URL — Zod coerce handles the conversion on the server), and a response is only inferred if the handler returns via c.json() with a status code. Responses returned from middleware are not seen by the client — so with RPC, avoid c.notFound(); use c.json({ error: '...' }, 404).

Step 4: reuse the types on the client

No need to redeclare interfaces on the frontend. Pull types straight off the client with InferRequestType/InferResponseType — perfect for wiring up forms or react-query:

import type { InferRequestType, InferResponseType } from 'hono/client'

type NewNote = InferRequestType<typeof client.notes.$post>['json']
type NoteRes = InferResponseType<typeof client.notes.$post>

Change the Zod schema on the server → both NewNote and NoteRes change with it, and every misuse goes red at compile time. This is exactly what removes a whole class of "frontend and backend drifted apart" bugs.

Monorepo: two rules so inference doesn't break

When the server and client live in one monorepo, two things are mandatory:

  1. "strict": true in both sides' tsconfig.json. Without it, RPC inference breaks silently.
  2. The client only needs import type { AppType } from the server package — no importing real code, no bundling the server into the client.
// tsconfig.json (both client and server)
{
  "compilerOptions": {
    "strict": true
  }
}

A tip from the docs that I confirmed with many routes: compile types to .d.ts first and let the client consume that, instead of making TypeScript re-infer the whole route chain in the IDE every time. With dozens-to-hundreds of routes, this is the difference between a smooth editor and a sluggish one.

Frequently asked questions

Do I have to use Zod, or will another validator work?

Zod isn't mandatory, but it's the most common choice because the @hono/zod-validator package is small and gives both runtime validation and type inference from one schema. Hono supports other validators (Valibot, TypeBox...) via the matching middleware. The key point isn't the library — it's letting one schema both block junk data and generate the type; don't split those two jobs across two sources of truth.

Does this RPC work when client and server are in different repos?

Yes, as long as the client can obtain AppType at build time. The usual approach is to publish the server's type as a package (or a .d.ts file) for the client to import type. It's slightly less seamless than a monorepo because you have to version that type, but it's still type sharing through TypeScript, not codegen. If the two sides change at independent cadences, consider OpenAPI as well; within one monorepo, direct RPC is the leanest.


This deep-dives the RPC teaser from the hands-on Hono experience post. If you're weighing whether to put Hono into a real system, read Should you use Hono in production?. To talk through a type-safe API layer or an infrastructure rebuild, see the project stories or get in touch.