Building end-to-end type-safe APIs without codegen — flowing server types to the client in edge runtimes with Hono RPC and Zod
If you've ever maintained an OpenAPI spec file only to find the server and client types drifting apart, you're not alone. I spent a while generating types with openapi-typescript, wiring the generation script into CI, and having generated files mixed into every PR commit — and honestly, it was a hassle. Forget to regenerate client types after changing server code, and it blows up at runtime. Someone skips updating the spec file, and static analysis becomes useless.
Hono RPC approaches this problem by eliminating the codegen step entirely. You export the server's route types directly as TypeScript types, and the client consumes them via import type. Nothing is added at runtime, and there's no separate schema language. Because Zod handles both runtime validation and compile-time type inference simultaneously, a single schema becomes the single source of truth.
Hono is an edge runtime framework — including Cloudflare Workers — with steadily growing adoption (you can check the trend yourself on npm trends). This article covers how to combine Hono RPC and Zod to build an end-to-end type-safe API layer in an edge environment.
How Types Flow
The core idea is that types flow in one direction. They originate from a Zod schema, accumulate into route types, and only the type signature is passed to the client.
Step 1 — Declare input schema with zValidator
The @hono/zod-validator middleware binds a Zod schema directly to a route. When a request comes in, it validates the schema at runtime, and inside the handler you can retrieve the already-validated data via c.req.valid(). The static type of that value is inferred from the Zod schema, so no casting is needed.
Step 2 — Export AppType
Apply typeof to the route variable and export it as export type. This is the point where only TypeScript types are shared — no actual runtime code.
Step 3 — Create a client with hc<AppType>(baseUrl)
Pass AppType as a generic to the hc function from hono/client, and you get autocomplete for endpoint paths, HTTP methods, request bodies, and response shapes — all of it.
Here's the runtime flow as a request arrives, is validated, and a response is returned. Type narrowing is a compile-time concept, so it doesn't appear in the diagram.
At compile time, something separate is happening. The return type signature of zValidator accumulates into the route type, and when you call c.req.valid('json') inside the handler, TypeScript exposes the Zod schema-based type right there.
Server Setup on Cloudflare Workers
First, install the packages.
npm install hono @hono/zod-validator zod
npm install -D wranglerWriting the Server Routes
// src/index.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
const CreateUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
})
const app = new Hono()
const routes = app
.get('/users', async (c) => {
const users = [{ id: '1', name: 'Alice', email: 'alice@example.com' }]
return c.json(users)
})
.post(
'/users',
zValidator('json', CreateUserSchema),
async (c) => {
const { name, email } = c.req.valid('json')
const newUser = { id: crypto.randomUUID(), name, email }
return c.json(newUser, 201)
}
)
.get(
'/users/:id',
zValidator('param', z.object({ id: z.string() })),
async (c) => {
const { id } = c.req.valid('param')
return c.json({ id, name: 'Alice', email: 'alice@example.com' })
}
)
export type AppType = typeof routes
export default appThe key pattern is stacking routes via method chaining on the routes variable, then exporting typeof routes as AppType. Chaining methods like .get and .post each return a new type carrying the expanded route information, but the app from new Hono() doesn't have that information yet. That's why you need to capture the chaining result in a separate variable and apply typeof to it. (The .route() composition case covered later works a bit differently.)
Consuming from the Client
// client.ts
import { hc } from 'hono/client'
import type { AppType } from './index'
const client = hc<AppType>('https://api.example.com')
const usersRes = await client.users.$get()
const users = await usersRes.json()
const createRes = await client.users.$post({
json: { name: 'Bob', email: 'bob@example.com' }
})
const userRes = await client.users[':id'].$get({
param: { id: '1' }
})
const user = await userRes.json()If you omit import type { AppType }, server code can end up mixed into your bundle later. This is covered more in the tradeoffs section.
Deployment Configuration
A minimal wrangler.toml is required for Cloudflare Workers deployment.
# wrangler.toml
name = "my-hono-api"
main = "src/index.ts"
compatibility_date = "2026-08-01"Set up package.json like this.
{
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy"
}
}Running Locally with Bun
Bun automatically starts an HTTP server when a file exports an object of the form export default { fetch }. Hono's app has a .fetch method, making it compatible with this convention — but it works because Bun supports this convention, not because any default export becomes a server.
If you want to explicitly specify a port or other options, it's safer to wrap it in Bun.serve.
// src/dev.ts
import app from './index'
export default {
port: 3000,
fetch: app.fetch,
}{
"scripts": {
"dev:bun": "bun run src/dev.ts",
"dev:workers": "wrangler dev",
"deploy": "wrangler deploy"
}
}The hc client is runtime-agnostic, so frontend code stays the same regardless of which environment you deploy to. Just swap out the API URL via an environment variable.
Isolating AppType in a Turborepo Monorepo
In a monorepo with separate backend and frontend, you need to be more deliberate about how you share AppType. The goal is to block server code (ORMs, environment variable access, etc.) from leaking into the client bundle while still sharing the types.
Here is the recommended package structure.
apps/
api/ # Hono on Cloudflare Workers or Bun
web/ # Next.js or React
packages/
api-types/ # Isolated export of AppType only// packages/api-types/src/index.ts
export type { AppType } from '@myapp/api'// packages/api-types/package.json
{
"name": "@myapp/api-types",
"exports": {
".": "./src/index.ts"
},
"devDependencies": {
"@myapp/api": "workspace:*"
}
}This package contains no runtime code, so it has no effect on bundle size. In apps/web, you can use it like this.
// apps/web/lib/api.ts
import { hc } from 'hono/client'
import type { AppType } from '@myapp/api-types'
export const apiClient = hc<AppType>(
process.env.NEXT_PUBLIC_API_URL!
)Integrating with Next.js 15 + React Query
Connect apiClient to React Query's queryFn and the server response types propagate automatically all the way into the return type of useQuery.
// apps/web/app/users/page.tsx
'use client'
import { useQuery } from '@tanstack/react-query'
import { apiClient } from '@/lib/api'
export default function UsersPage() {
const { data, isLoading } = useQuery({
queryKey: ['users'],
queryFn: async () => {
const res = await apiClient.users.$get()
return res.json()
}
})
if (isLoading) return <div>Loading...</div>
return (
<ul>
{data?.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}No separate fetch wrapper, no OpenAPI client generation step — change a server route and the client types reflect it immediately.
Tradeoffs and Alternatives
Hono RPC isn't the best choice for every situation. Here are some things to consider before committing.
Tool Comparison (as of 2026)
| Hono RPC | tRPC | oRPC | ts-rest | |
|---|---|---|---|---|
| Requires code generation | No | No | No | No |
| Auto-generates OpenAPI spec | Third-party (hono-openapi) |
Not supported | Built-in | Built-in |
| Preserves REST URLs | Yes | No (single endpoint style) | Yes | Yes |
| Node.js-only dependencies | None | None | None | None |
| Client runtime approach | fetch-based | fetch-based | fetch-based | fetch-based |
| Validator coupling | Standard Schema support | Zod/Valibot etc. | Standard Schema support | Zod etc. |
All four tools are fetch-based, so the edge runtime itself isn't a major barrier for any of them. The meaningful differences come down to which validator/middleware combinations are supported, whether REST URLs are exposed as-is, and whether OpenAPI is required. If you're publishing a public API or third-party clients need to consume an OpenAPI spec, oRPC or ts-rest are more natural fits. For a pure TypeScript monorepo where the middleware and plugin ecosystem matters, tRPC is more mature.
Common Pitfalls in Practice
Pitfall 1 — Forgetting export type
I underestimated this distinction early on, then found Drizzle ORM code mixed into the client bundle during a bundle analysis and was caught off guard.
// Dangerous: may export runtime code as well
export { AppType }
// Safe: exports types only
export type { AppType }The same applies on the client side.
// Dangerous
import { AppType } from './server'
// Safe
import type { AppType } from './server'Pitfall 2 — Increased TypeScript compile times with large route counts
Hono sequentially extends the Context type with each route. As the number of routes grows, TypeScript compile times can increase sharply. GitHub Issue #3869 discusses cases where build times grew to several minutes.
The remedy is to split routers by feature domain.
// src/routes/users.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
const CreateUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
})
export const usersRoutes = new Hono()
.get('/', async (c) => c.json([]))
.post('/', zValidator('json', CreateUserSchema), async (c) => {
const data = c.req.valid('json')
return c.json({ id: crypto.randomUUID(), ...data }, 201)
})// src/index.ts
import { Hono } from 'hono'
import { usersRoutes } from './routes/users'
const app = new Hono()
.route('/users', usersRoutes)
export type AppType = typeof app
export default appHere we use typeof app directly. This looks inconsistent with the single-file example earlier, but the distinction is clear. .route() immediately composes the sub-router's type into the parent app instance's type and returns it, so the final route type lives on the app variable itself. By contrast, when chaining multiple .get and .post calls, the app from new Hono() doesn't yet have route information, so you need to apply typeof to the chaining result (routes). In short, the criterion is: which identifier ultimately holds the final route extension.
Build times can be reduced further by using TypeScript Project References for incremental builds.
Pitfall 3 — "Type instantiation is excessively deep" error
This error occurs when TypeScript can't unroll the conditional/recursive types accumulated through route chaining beyond a certain depth. It has no direct causal relationship with strict mode. The actual causes are typically: (a) excessively deep chaining, (b) recursive schemas in response types, or (c) the number of routes in the client-side hc<AppType> exceeding a threshold. The router splitting from Pitfall 2 combined with Project References is the most reliable mitigation.
Pitfall 4 — Extracting complex response types
When nested types are complex — like ORM relation results — you can use the InferResponseType utility to extract the response type.
import type { InferResponseType } from 'hono/client'
import type { AppType } from '@myapp/api-types'
const client = hc<AppType>('https://api.example.com')
type CreateUserResponse = InferResponseType<
typeof client.users.$post,
201
>tsconfig Checklist
"strict": true— Required for accurate type inference and null safety. It's a separate issue from compile speed, but turning it off can causec.req.valid()type inference to behave unexpectedly."moduleResolution": "bundler"or"nodenext"— Required for resolvinghono/clientsubpath exports."target": "ES2022"or higher — Aligns with edge runtime (Workers, Bun) execution environments.
The Hono official Best Practices also recommends strict mode as a separate item.
Swapping Validators with Standard Schema
One interesting development as of 2026 is the Standard Schema specification. Co-created by the maintainers of Zod, Valibot, and ArkType, this interface specifies a common shape for schema libraries to expose parsing results. On the Hono side, @hono/standard-validator lets you attach any validator that satisfies this spec to a route.
In edge environments where a lean bundle is critical, you can switch from Zod to Valibot and significantly reduce bundle size when reusing schemas on the client side.
import { Hono } from 'hono'
import { sValidator } from '@hono/standard-validator'
import * as v from 'valibot'
const CreateUserSchema = v.object({
name: v.pipe(v.string(), v.minLength(1)),
email: v.pipe(v.string(), v.email()),
})
const app = new Hono()
.post('/users', sValidator('json', CreateUserSchema), (c) => {
const { name, email } = c.req.valid('json')
return c.json({ id: crypto.randomUUID(), name, email }, 201)
})From the Hono RPC perspective, the route type signature is maintained regardless of which validator you use. The hc<AppType> consumption pattern stays the same.
Wrapping Up — What to Try Next
The value of the Hono RPC + Zod combination lies in getting both runtime validation and compile-time types from a single schema. Eliminating the codegen step means server route changes are immediately reflected in client types, and it pairs well with edge environments sensitive to cold start times. Hono core's bundle size is very small, but it's worth keeping in mind that your actual stack includes Zod (approximately 57KB minified for v3, smaller in v4) and validator middleware on top of that.
For those looking to add this to an existing project, here are three recommended next steps.
- Migrate one existing route to the
zValidator+AppType+hccombination and observe where compile errors surface on the client. - Use a bundle analyzer (
@next/bundle-analyzer, etc.) to confirm that server code isn't leaking into the client bundle. - If Zod feels too heavy, try swapping validators with
@hono/standard-validator+ Valibot and measure the actual bundle size difference.
When the time comes to expose a public-facing API, attaching a spec via hono-openapi or migrating to oRPC/ts-rest are natural extension paths.
References
- Hono Official Docs - RPC Guide
- Hono Official Docs - Validation
- Hono Official Docs - Stacks
- Hono Official Docs - Best Practices
- Yusuke Wada - Hey, this is Hono's RPC
- Standard Schema Specification
@hono/standard-validatorMiddleware- Catalin's Tech - Hono RPC And TypeScript Project References
- GitHub Issue #3869 - Hono Type Inference is taking too long during builds
- Fiberplane Blog - Hacking Hono: The Ins and Outs of Validation Middleware
- JSR - @hono/zod-validator
- npm trends - hono