Hono RPC — End-to-End Type Safety Without tRPC or GraphQL
Advertisement
Introduction
tRPC revolutionized full-stack type safety, but it requires shared TypeScript packages and adds framework overhead. Hono RPC takes a simpler approach: share type definitions through auto-inference, not code generation. This guide covers Hono RPC architecture, monorepo setup, and why it''s superior to tRPC for REST APIs.
Understanding Hono RPC
Hono RPC is deceptively simple. Your server routes become types automatically:
// Server (Hono)
const app = new Hono()
app.get('/users', (c) => {
return c.json([{ id: 1, name: 'Alice' }])
})
app.post('/users', (c) => {
const body = c.req.json()
return c.json({ id: 2, name: body.name }, 201)
})
export type App = typeof appThe client imports App and gets full type inference:
// Client
import { hc } from 'hono/client'
import type { App } from '../server'
const client = hc('http://localhost:3000')
// ✅ Fully typed — TypeScript knows return type is User[]
const users = await client.users.$get()
// ✅ Fully typed — TypeScript knows method expects POST
const response = await client.users.$post({ json: { name: 'Bob' } })No code generation. No separate RPC layer. Just standard REST with type inference.
Defining Typed Routes with Hono
Combine Hono with Zod for complete type safety:
import { Hono } from 'hono'
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
})
type User = z.infer<typeof UserSchema>
const CreateUserSchema = z.object({
name: z.string(),
email: z.string().email(),
})
const app = new Hono()
app.get('/users', (c) => {
const users: User[] = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
]
return c.json(users)
})
app.post(
'/users',
zValidator('json', CreateUserSchema),
(c) => {
const { name, email } = c.req.valid('json')
const user: User = { id: 2, name, email }
return c.json(user, 201)
}
)
export type App = typeof appThe App type includes validation and response types. The client inherits all of it.
The hc Client Factory
hc generates a type-safe client from your App type:
import { hc } from 'hono/client'
import type { App } from '../server'
const client = hc('http://localhost:3000')
// GET /users — returns Promise<User[]>
const users = await client.users.$get()
// POST /users — expects { name: string; email: string }
const newUser = await client.users.$post({
json: { name: 'Charlie', email: 'charlie@example.com' },
})
// GET /users/:id
const user = await client.users[':id'].$get({
param: { id: '1' },
})Route parameters are in param, body in json, query in query. All typed.
Streaming RPC Responses
Stream responses for long-running operations:
app.get('/stream', async (c) => {
const stream = new ReadableStream({
async start(controller) {
for (let i = 0; i < 10; i++) {
controller.enqueue(`data: ${JSON.stringify({ index: i })}\n\n`)
await new Promise(resolve => setTimeout(resolve, 100))
}
controller.close()
},
})
return new Response(stream, {
headers: { 'Content-Type': 'text/event-stream' },
})
})The client consumes streams with proper types:
const response = await client.stream.$get()
const reader = response.body?.getReader()
while (true) {
const { done, value } = await reader!.read()
if (done) break
const text = new TextDecoder().decode(value)
// Parse and handle streamed data
}Authentication with RPC Routes
Guards automatically apply to routes:
const authGuard = (c: Context, next: Next) => {
const auth = c.req.header('Authorization')
if (!auth?.startsWith('Bearer ')) {
throw new HTTPException(401, { message: 'Unauthorized' })
}
try {
const payload = verifyJWT(auth.slice(7))
c.set('user', payload)
return next()
} catch {
throw new HTTPException(401, { message: 'Invalid token' })
}
}
app.get('/me', authGuard, (c) => {
const user = c.get('user')
return c.json(user)
})The hc client passes headers automatically:
const client = hc('http://localhost:3000', {
headers: {
'Authorization': `Bearer ${token}`,
},
})
const profile = await client.me.$get()Hono + React Query Integration
React Query pairs perfectly with Hono RPC:
import { useQuery } from '@tanstack/react-query'
import { hc } from 'hono/client'
import type { App } from '../server'
const client = hc('http://localhost:3000')
export function Users() {
const { data: users, isLoading } = useQuery({
queryKey: ['users'],
queryFn: () => client.users.$get(),
})
if (isLoading) return <div>Loading...</div>
return (
<ul>
{users?.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}React Query handles caching, invalidation, and retries. Hono provides the types.
Monorepo Setup for Shared Types
Structure your monorepo:
.
├── packages/
│ └── shared/
│ └── src/
│ └── types.ts
├── services/
│ ├── api/
│ │ └── src/
│ └── index.ts
│ └── web/
│ └── src/
│ └── app.tsxShare types via package exports:
// packages/shared/package.json
{
"name": "@app/shared",
"exports": {
"types": "./src/types.ts"
}
}
// services/api/src/index.ts
import { UserSchema } from '@app/shared'
// services/web/src/app.tsx
import type { App } from '../../../services/api/src/index.ts'
import type { User } from '@app/shared'Or share directly without a package:
// services/api/src/index.ts
import { UserSchema } from '../../services/shared/types.ts'
// services/web/src/app.tsx
import type { App } from '../../services/api/src/index.ts'Hono doesn''t care how you share the App type—just that it''s available.
Hono RPC vs tRPC vs GraphQL
Feature comparison:
| Feature | Hono RPC | tRPC | GraphQL |
|---|---|---|---|
| Type Safety | ✅ Full | ✅ Full | ⚠️ Partial |
| REST API | ✅ Yes | ❌ No | ❌ No |
| Performance | ✅ Excellent | ✅ Good | ❌ Overhead |
| Learning Curve | ✅ Minimal | ⚠️ Medium | ❌ Steep |
| Cache Invalidation | ✅ Standard HTTP | ⚠️ Complex | ⚠️ Complex |
| Client Size | ✅ <5kb | ⚠️ 15kb+ | ❌ 50kb+ |
Use Hono RPC for: REST APIs, CRUD apps, performance-critical systems.
Use tRPC for: Full-stack type safety (but don''t want REST).
Use GraphQL for: Complex query shapes, real-time subscriptions, federation.
Error Handling with Type Safety
Define typed errors:
type ErrorResponse = {
error: string
code: 'VALIDATION_ERROR' | 'NOT_FOUND' | 'UNAUTHORIZED'
}
app.get('/users/:id', (c) => {
const id = parseInt(c.req.param('id'))
if (isNaN(id)) {
return c.json<ErrorResponse>(
{ error: 'ID must be a number', code: 'VALIDATION_ERROR' },
400
)
}
const user = findUser(id)
if (!user) {
return c.json<ErrorResponse>(
{ error: 'User not found', code: 'NOT_FOUND' },
404
)
}
return c.json(user)
})
export type App = typeof appThe client knows possible error shapes:
try {
const user = await client.users[':id'].$get({ param: { id: '1' } })
} catch (err: any) {
const error = err as ErrorResponse
if (error.code === 'NOT_FOUND') {
// Handle not found
}
}Middleware for Cross-Cutting Concerns
Middleware applies to all routes:
app.use('*', async (c, next) => {
const start = Date.now()
await next()
const duration = Date.now() - start
c.header('X-Response-Time', `${duration}ms`)
})
app.use('/api/*', authGuard)
app.use('*', async (c, next) => {
try {
await next()
} catch (err) {
console.error(err)
return c.json(
{ error: 'Internal server error' },
500
)
}
})Middleware executes in order. No magic—just standard handler composition.
Checklist
- Create Hono app with typed routes
- Add Zod validators to all endpoints
- Export
Apptype from server - Generate
hcclient in frontend - Integrate React Query for data fetching
- Test type inference end-to-end
- Add authentication guards
- Document API with Swagger (auto-generated)
- Deploy server and client
- Monitor type safety with TypeScript strict mode
Conclusion
Hono RPC is the simplest path to full-stack type safety. No code generation, no framework overhead, no GraphQL complexity. It''s REST with types. For teams building CRUD apps, microservices, or anything REST-shaped, Hono RPC is the clear choice. tRPC is more powerful for RPC-specific use cases, but Hono RPC''s simplicity and REST compatibility make it the default for most projects.
Advertisement