The problem both tools solve is the same: you write an API in TypeScript and your frontend immediately knows the types — request shape, response shape, errors — without a code generation step, without an OpenAPI schema, without a shared types package you have to keep synchronized manually.
The implementation is very different.
tRPC builds on React Query and has deep Next.js integration. The client is a React Query wrapper — you get caching, background refetching, and optimistic updates automatically. Hono RPC is lighter. It generates a typed fetch client from your route definitions, works with any TypeScript consumer (React, Vue, Svelte, React Native, plain Node.js scripts), and runs on Cloudflare Workers, Bun, and Deno with zero changes.
Choosing between them is less about which API is more elegant and more about what already exists in your stack.
The Core Model
tRPC: Procedures on a Router
tRPC organizes your API into procedures — queries for reads, mutations for writes, subscriptions for real-time. Each procedure validates input (via Zod or Valibot) and returns a typed output. The router is the contract.
// server/routers/users.ts
import { router, publicProcedure, protectedProcedure } from '../trpc'
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { db } from '../db'
export const usersRouter = router({
getById: publicProcedure
.input(z.object({ id: z.string().cuid() }))
.query(async ({ input }) => {
const user = await db.user.findUnique({ where: { id: input.id } })
if (!user) throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' })
return user
}),
create: protectedProcedure
.input(z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
role: z.enum(['USER', 'ADMIN']).default('USER')
}))
.mutation(async ({ input, ctx }) => {
const existing = await db.user.findUnique({ where: { email: input.email } })
if (existing) throw new TRPCError({ code: 'CONFLICT', message: 'Email already in use' })
return db.user.create({ data: { ...input, createdBy: ctx.session.userId } })
}),
list: publicProcedure
.input(z.object({
page: z.number().int().min(1).default(1),
limit: z.number().int().min(1).max(100).default(20)
}))
.query(async ({ input }) => {
const [users, total] = await Promise.all([
db.user.findMany({
skip: (input.page - 1) * input.limit,
take: input.limit,
orderBy: { createdAt: 'desc' }
}),
db.user.count()
])
return { users, total, pages: Math.ceil(total / input.limit) }
})
})
// server/routers/index.ts
import { router } from '../trpc'
import { usersRouter } from './users'
import { postsRouter } from './posts'
export const appRouter = router({
users: usersRouter,
posts: postsRouter,
})
export type AppRouter = typeof appRouterThe AppRouter type is the single export that carries the entire API contract to the client. No schema file, no generated code.
Hono RPC: Routes as Types
Hono RPC takes a different approach. Instead of a dedicated router type, the TypeScript type of your Hono app instance is the contract. Route chaining preserves type information on the app object.
// server/routes/users.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { db } from '../db'
import { requireAuth } from '../middleware/auth'
const users = new Hono()
.get('/:id', async (c) => {
const id = c.req.param('id')
const user = await db.user.findUnique({ where: { id } })
if (!user) return c.json({ error: 'Not found' }, 404)
return c.json(user)
})
.post(
'/',
requireAuth,
zValidator('json', z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
role: z.enum(['USER', 'ADMIN']).default('USER')
})),
async (c) => {
const body = c.req.valid('json')
const existing = await db.user.findUnique({ where: { email: body.email } })
if (existing) return c.json({ error: 'Email already in use' }, 409)
const user = await db.user.create({ data: body })
return c.json(user, 201)
}
)
.get('/', zValidator('query', z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20)
})), async (c) => {
const { page, limit } = c.req.valid('query')
const [users, total] = await Promise.all([
db.user.findMany({ skip: (page - 1) * limit, take: limit, orderBy: { createdAt: 'desc' } }),
db.user.count()
])
return c.json({ users, total, pages: Math.ceil(total / limit) })
})
// server/app.ts
import { Hono } from 'hono'
import { users } from './routes/users'
import { posts } from './routes/posts'
const app = new Hono()
.route('/users', users)
.route('/posts', posts)
export { app }
export type AppType = typeof appThe chained .route().route() pattern preserves the type of each sub-router on the app object. That's what hc<AppType>() reads on the client side.
Client Integration
This is where the two tools diverge the most in daily usage.
tRPC: React Query Built In
Every tRPC call is a React Query hook. Caching, background refetching, stale-while-revalidate, optimistic updates — you get all of it without any extra setup.
// lib/trpc.ts
import { createTRPCReact } from '@trpc/react-query'
import type { AppRouter } from '../server/routers'
export const trpc = createTRPCReact<AppRouter>()
// app/providers.tsx
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { trpc } from '@/lib/trpc'
import { httpBatchLink } from '@trpc/client'
import { useState } from 'react'
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient())
const [trpcClient] = useState(() =>
trpc.createClient({
links: [httpBatchLink({ url: '/api/trpc' })]
})
)
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
)
}
// components/UserList.tsx
'use client'
import { trpc } from '@/lib/trpc'
export function UserList() {
const utils = trpc.useUtils()
const { data, isLoading } = trpc.users.list.useQuery({ page: 1, limit: 20 })
const createUser = trpc.users.create.useMutation({
onSuccess: () => {
// Invalidate the list cache automatically
utils.users.list.invalidate()
}
})
if (isLoading) return <div>Loading...</div>
return (
<>
{data?.users.map(user => (
<div key={user.id}>{user.name} — {user.email}</div>
))}
<button onClick={() => createUser.mutate({ name: 'New', email: 'new@example.com' })}>
Add User
</button>
</>
)
}useMutation → onSuccess → utils.invalidate() is the standard pattern. tRPC doesn't reinvent caching — it wires your procedures directly into React Query's cache model.
Hono RPC: Typed Fetch, You Handle Caching
The hc client is a thin typed wrapper over fetch. No caching, no background refetching — just type-safe HTTP calls.
// lib/client.ts
import { hc } from 'hono/client'
import type { AppType } from '../server/app'
export const client = hc<AppType>('http://localhost:3000')
// components/UserList.tsx — with React Query manually wired
'use client'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { client } from '@/lib/client'
export function UserList() {
const queryClient = useQueryClient()
const { data, isLoading } = useQuery({
queryKey: ['users', { page: 1 }],
queryFn: async () => {
const res = await client.users.$get({ query: { page: '1', limit: '20' } })
return res.json()
}
})
const createUser = useMutation({
mutationFn: async (body: { name: string; email: string }) => {
const res = await client.users.$post({ json: body })
if (!res.ok) throw new Error('Failed to create user')
return res.json()
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] })
})
// ...
}You can absolutely use React Query with Hono's hc — but the wiring is manual. You manage query keys, you write the queryFn, you set up the provider. tRPC does all of that automatically.
The flip side: because hc is just a typed fetch wrapper, it works in any TypeScript environment. Vue, Svelte, Angular, React Native, Node.js scripts — anywhere.
Non-React Clients
This is Hono RPC's clearest advantage over tRPC.
// Vue 3 + Nuxt
const { data } = await useAsyncData('users', async () => {
const res = await client.users.$get({ query: { page: '1', limit: '20' } })
return res.json() // typed: { users: User[], total: number, pages: number }
})
// React Native
const [users, setUsers] = useState<User[]>([])
useEffect(() => {
client.users.$get({ query: { page: '1', limit: '20' } })
.then(r => r.json())
.then(data => setUsers(data.users))
}, [])
// Node.js script — e.g., a seeding script or CLI tool
import { hc } from 'hono/client'
import type { AppType } from '../server/app'
const client = hc<AppType>('http://localhost:3000')
await client.users.$post({ json: { name: 'Seed User', email: 'seed@example.com', role: 'ADMIN' } })
console.log('Seeded successfully')
// Server-to-server (microservice calling another service)
const internalClient = hc<AppType>(process.env.USERS_SERVICE_URL!, {
headers: { 'x-internal-token': process.env.INTERNAL_TOKEN! }
})
const user = await internalClient.users[':id'].$get({ param: { id: userId } }).then(r => r.json())tRPC has a vanilla client (createTRPCClient) that works outside React, but it's HTTP-over-the-wire — it doesn't give you the procedure layer directly in server-to-server calls. Hono RPC's hc is genuinely portable.
Subscriptions
tRPC has first-class support. Hono doesn't — you wire SSE or WebSockets separately.
// tRPC subscription — real-time notifications
const notificationsRouter = router({
onNewMessage: publicProcedure
.input(z.object({ roomId: z.string() }))
.subscription(async function* ({ input }) {
for await (const message of subscribeToRoom(input.roomId)) {
yield message
}
})
})
// Client — works exactly like a query
trpc.notifications.onNewMessage.useSubscription(
{ roomId: 'room-123' },
{
onData: (message) => {
setMessages(prev => [...prev, message])
},
onError: (err) => console.error('Subscription error:', err)
}
)For Hono, real-time means using the SSE helpers separately from the RPC layer:
// Hono SSE — real-time, but outside the RPC client
app.get('/notifications/stream', async (c) => {
return streamSSE(c, async (stream) => {
for await (const event of subscribeToRoom(c.req.query('roomId')!)) {
await stream.writeSSE({ data: JSON.stringify(event), event: 'message' })
}
})
})
// Client — native EventSource, not the hc() typed client
const source = new EventSource(`/notifications/stream?roomId=${roomId}`)
source.addEventListener('message', (e) => {
const message = JSON.parse(e.data)
setMessages(prev => [...prev, message])
})If real-time subscriptions are core to your app (chat, live dashboards, collaborative editing), tRPC's subscription model is meaningfully simpler than wiring SSE alongside Hono RPC.
Error Handling
tRPC: Typed Error Codes
import { TRPCError } from '@trpc/server'
throw new TRPCError({
code: 'NOT_FOUND', // Maps to HTTP 404
message: 'User not found',
cause: originalError
})
// Available: NOT_FOUND, UNAUTHORIZED, FORBIDDEN, BAD_REQUEST,
// CONFLICT, INTERNAL_SERVER_ERROR, TIMEOUT, METHOD_NOT_SUPPORTED
// Client-side — typed, no HTTP status checking
const { error } = trpc.users.getById.useQuery({ id: 'xyz' })
if (error?.data?.code === 'NOT_FOUND') {
// TypeScript knows about code, message, httpStatus
}Hono: Standard HTTP Responses
// Option 1: inline JSON response
if (!user) return c.json({ error: 'User not found' }, 404)
// Option 2: HTTPException for consistent error shape across the app
import { HTTPException } from 'hono/http-exception'
throw new HTTPException(404, { message: 'User not found' })
// Option 3: global error handler
app.onError((err, c) => {
if (err instanceof HTTPException) return err.getResponse()
return c.json({ error: 'Internal server error' }, 500)
})
// Client-side — check res.ok manually
const res = await client.users[':id'].$get({ param: { id } })
if (!res.ok) {
const error = await res.json()
// error shape depends on your route definition
}tRPC's error handling is consistent by design. Hono's is flexible — you decide the error shape, which means discipline is required to keep it consistent across routes.
The Same Endpoint, Both Tools
POST /users — validation, duplicate check, create, typed response:
// tRPC
create: protectedProcedure
.input(z.object({ name: z.string().min(1), email: z.string().email() }))
.mutation(async ({ input, ctx }) => {
const existing = await db.user.findUnique({ where: { email: input.email } })
if (existing) throw new TRPCError({ code: 'CONFLICT', message: 'Email already in use' })
return db.user.create({ data: { ...input, createdBy: ctx.userId } })
}),
// Hono RPC
.post(
'/',
requireAuth,
zValidator('json', z.object({ name: z.string().min(1), email: z.string().email() })),
async (c) => {
const body = c.req.valid('json')
const existing = await db.user.findUnique({ where: { email: body.email } })
if (existing) return c.json({ error: 'Email already in use' }, 409)
const user = await db.user.create({ data: body })
return c.json(user, 201)
}
)tRPC is terser for the mutation logic. Hono is more explicit about the HTTP semantics — status codes, response shape. Both give you full type safety on the client.
Decision Framework
| Situation | Choose |
|---|---|
| Next.js app, React-heavy frontend | tRPC |
| Already using React Query | tRPC |
| Need real-time subscriptions | tRPC |
| Non-React clients (Vue, Svelte, React Native) | Hono RPC |
| Edge deployment (Cloudflare Workers, Bun, Deno) | Hono RPC |
| Server-to-server API calls | Hono RPC |
| Multi-runtime backend | Hono RPC |
| Monorepo with mixed frontend frameworks | Hono RPC |
The honest summary: tRPC wins when you're fully committed to React and want React Query's caching built in. Hono RPC wins when you need portability — non-React clients, edge runtimes, or a lighter dependency surface.
Both are better than the alternative of maintaining a separate OpenAPI schema or a shared types package that drifts out of sync. The choice between them is about what already exists in your stack, not which one has the cleaner API surface.
For a deeper look at Hono beyond RPC, including performance benchmarks against Express and Fastify, see the Hono vs Express vs Fastify comparison. If you're going deeper on tRPC with Next.js — setup, middleware, context — the tRPC + Next.js guide has the full picture. For the database layer that works with both, Drizzle ORM requires no adapter changes when switching between tRPC and Hono.