APIs
|stacknotice.com
13 min left|
0%
|2,600 words
APIs

tRPC vs Hono RPC (2026): End-to-End Type Safety Without the Setup Tax

Both give you type-safe APIs without code generation. tRPC integrates deeply with React Query. Hono RPC runs anywhere. Real code, real trade-offs, clear decision framework.

C
Carlos Oliva
Software Developer
August 10, 202613 min read
Share:
tRPC vs Hono RPC (2026): End-to-End Type Safety Without the Setup Tax

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 appRouter

The 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 app

The 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>
    </>
  )
}

useMutationonSuccessutils.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

SituationChoose
Next.js app, React-heavy frontendtRPC
Already using React QuerytRPC
Need real-time subscriptionstRPC
Non-React clients (Vue, Svelte, React Native)Hono RPC
Edge deployment (Cloudflare Workers, Bun, Deno)Hono RPC
Server-to-server API callsHono RPC
Multi-runtime backendHono RPC
Monorepo with mixed frontend frameworksHono 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.

#trpc#hono#typescript#apis#backend
Share:
C
Carlos Oliva
Software Developer · stacknotice.com

Software developer with hands-on experience building production apps with React, Next.js, Angular, TypeScript, and Spring Boot. I write practical guides on Claude Code, AI tools, and modern web development — covering the decisions and trade-offs that senior-level tutorials actually explain.

More about Carlos

Enjoyed this article?

Get weekly insights on Claude Code, React, and AI tools — practical guides for developers who build real things.

No spam. Unsubscribe anytime. By subscribing you agree to our Privacy Policy.