- Published on
Hono RPC — End-to-End Type Safety Without tRPC or GraphQL
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
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
- Defining Typed Routes with Hono
- The hc Client Factory
- Streaming RPC Responses
- Authentication with RPC Routes
- Hono + React Query Integration
- Monorepo Setup for Shared Types
- Hono RPC vs tRPC vs GraphQL
- Error Handling with Type Safety
- Middleware for Cross-Cutting Concerns
- Checklist
- Conclusion
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 app
The 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 app
The 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.tsx
Share 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 app
The 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.