Hono RPC vs tRPC vs ts-rest Type-Safe APIs 2026
Hono RPC vs tRPC vs ts-rest Type-Safe APIs 2026
TL;DR
End-to-end type safety between your backend and frontend has become a table-stakes expectation in TypeScript monorepos. tRPC pioneered this space — define your API as TypeScript procedures, get a type-safe client automatically, no schema or code generation needed. Hono RPC extends Hono's ultra-fast HTTP framework with a type-safe client: define routes on the server, export the type, import the client. ts-rest takes the REST-first approach — define a contract (schema-per-route with Zod), implement it on the server, and use the same contract on the client. In 2026, tRPC wins for pure TypeScript monorepos; ts-rest wins when you need REST + OpenAPI; Hono RPC wins for edge deployments with Hono's performance profile.
Key Takeaways
- tRPC v11: 2.5M weekly downloads, 36K GitHub stars — the dominant choice for full-stack TypeScript, deeply integrated with Next.js, Tanstack Router
- ts-rest: 250K weekly downloads, 5.4K GitHub stars — defines API contracts with Zod schemas, generates OpenAPI spec, REST-compatible (any HTTP client can consume it)
- Hono RPC: ships with Hono (2.8M weekly downloads) — zero runtime overhead, ultra-minimal, built for edge runtimes (Cloudflare Workers, Deno Deploy)
- OpenAPI compatibility: ts-rest generates OpenAPI automatically; tRPC has a trpc-openapi adapter; Hono has @hono/zod-openapi
- Subscriptions/real-time: tRPC supports WebSocket subscriptions natively; ts-rest and Hono RPC are request/response only
- Learning curve: tRPC is the easiest for pure TS teams; ts-rest requires understanding contract patterns; Hono RPC requires learning Hono's routing API
The End-to-End Type Safety Problem
Without type-safe API layers, TypeScript teams face a documentation-and-faith system: backend developers write routes and hope frontend developers use them correctly. fetch('/api/users/123') returns any. The response shape is undocumented in code and discovered only at runtime (or in a Swagger page that's usually outdated).
The type-safe API layer pattern solves this: one source of truth for request/response types, shared between server and client, verified by TypeScript at compile time. If you change a response field on the server and the client references it, TypeScript errors before deployment.
tRPC
tRPC defines your API as TypeScript functions (procedures) grouped in a router. The client infers types directly from the router definition — no code generation, no schema files, no build step.
Server Setup
// server/trpc.ts
import { initTRPC } from '@trpc/server'
import { z } from 'zod'
const t = initTRPC.create()
export const router = t.router
export const publicProcedure = t.procedure
// Procedure with auth middleware
const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
if (!ctx.session) throw new TRPCError({ code: 'UNAUTHORIZED' })
return next({ ctx: { ...ctx, user: ctx.session.user } })
})
// server/routers/users.ts
import { router, publicProcedure, protectedProcedure } from '../trpc'
import { z } from 'zod'
export const usersRouter = router({
// Query
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
return ctx.db.user.findUnique({ where: { id: input.id } })
}),
// Mutation
create: protectedProcedure
.input(z.object({
name: z.string().min(1),
email: z.string().email(),
}))
.mutation(async ({ input, ctx }) => {
return ctx.db.user.create({ data: input })
}),
// Subscription (WebSocket)
onUpdate: publicProcedure
.subscription(() => {
return observable<User>((emit) => {
const unsubscribe = userUpdateEmitter.on('update', emit.next)
return () => unsubscribe()
})
}),
})
Client Usage
// src/utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query'
import type { AppRouter } from '../../server/root'
export const trpc = createTRPCReact<AppRouter>()
// src/components/UserProfile.tsx
import { trpc } from '../utils/trpc'
function UserProfile({ userId }: { userId: string }) {
// Fully typed — TypeScript knows the return type from server definition
const { data: user, isLoading } = trpc.users.getById.useQuery({ id: userId })
const createUser = trpc.users.create.useMutation({
onSuccess: () => trpc.useUtils().users.getById.invalidate(),
})
if (isLoading) return <Spinner />
return (
<div>
<h1>{user?.name}</h1>
{/* user.email, user.createdAt — all typed */}
</div>
)
}
tRPC ships built-in TanStack Query integration — useQuery, useMutation, useInfiniteQuery all work out of the box.
Next.js App Router Integration
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { appRouter } from '../../../../server/root'
import { createContext } from '../../../../server/context'
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext,
})
export { handler as GET, handler as POST }
ts-rest
ts-rest takes a contract-first approach. You define a typed contract (using Zod) that both the server and client implement. The contract is an npm package shared between your frontend and backend — not just a TypeScript type, but a runnable schema that generates OpenAPI specs.
Defining a Contract
// packages/api-contract/src/contract.ts
import { initContract } from '@ts-rest/core'
import { z } from 'zod'
const c = initContract()
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
createdAt: z.date(),
})
export const contract = c.router({
getUser: {
method: 'GET',
path: '/users/:id',
pathParams: z.object({ id: z.string() }),
responses: {
200: UserSchema,
404: z.object({ message: z.string() }),
},
summary: 'Get a user by ID',
},
createUser: {
method: 'POST',
path: '/users',
body: z.object({
name: z.string().min(1),
email: z.string().email(),
}),
responses: {
201: UserSchema,
409: z.object({ message: z.string() }),
},
},
})
Server Implementation
// apps/api/src/users/router.ts
import { createExpressEndpoints, initServer } from '@ts-rest/express'
import { contract } from '@acme/api-contract'
const s = initServer()
const usersRouter = s.router(contract, {
getUser: async ({ params }) => {
const user = await db.user.findUnique({ where: { id: params.id } })
if (!user) return { status: 404, body: { message: 'Not found' } }
return { status: 200, body: user }
},
createUser: async ({ body }) => {
const existing = await db.user.findUnique({ where: { email: body.email } })
if (existing) return { status: 409, body: { message: 'Email already exists' } }
const user = await db.user.create({ data: body })
return { status: 201, body: user }
},
})
createExpressEndpoints(contract, usersRouter, app)
TypeScript enforces that every contract route is implemented and that return types match the contract schemas. Returning { status: 200, body: { invalid: 'shape' } } is a compile error.
Client Usage
// apps/web/src/api/client.ts
import { initClient } from '@ts-rest/core'
import { contract } from '@acme/api-contract'
export const client = initClient(contract, {
baseUrl: 'http://localhost:3000',
baseHeaders: {},
})
// Usage in component
const result = await client.getUser({ params: { id: '123' } })
if (result.status === 200) {
console.log(result.body.name) // typed: string
console.log(result.body.email) // typed: string
} else if (result.status === 404) {
console.log(result.body.message) // typed: string
}
The discriminated union return type (by status code) forces you to handle each response case — no silent any slipping through.
OpenAPI Generation
import { generateOpenApi } from '@ts-rest/open-api'
import { contract } from '@acme/api-contract'
const openApiDocument = generateOpenApi(contract, {
info: { title: 'My API', version: '1.0.0' },
})
// Serve via Swagger UI or save to file
app.use('/docs', swaggerUi.serve, swaggerUi.setup(openApiDocument))
This is ts-rest's primary advantage over tRPC — the contract generates a valid OpenAPI spec that external teams, mobile clients, and third parties can consume.
Hono RPC
Hono is a fast, edge-native HTTP framework. Hono RPC extends it with a type-safe client by exporting the router's type and using it to construct a typed fetch client.
Server Definition
// src/server/routes/users.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
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({ message: 'Not found' }, 404)
return c.json(user, 200)
})
.post(
'/',
zValidator('json', z.object({
name: z.string().min(1),
email: z.string().email(),
})),
async (c) => {
const body = c.req.valid('json')
const user = await db.user.create({ data: body })
return c.json(user, 201)
}
)
export default users
export type UsersType = typeof users
Client Usage
// src/client.ts
import { hc } from 'hono/client'
import type { AppType } from './server'
const client = hc<AppType>('http://localhost:3000')
// Fully typed
const response = await client.users[':id'].$get({ param: { id: '123' } })
const user = await response.json() // typed from server return type
// POST
const createResponse = await client.users.$post({
json: { name: 'Alice', email: 'alice@example.com' }
})
const newUser = await createResponse.json()
Edge Deployment
Hono's primary advantage is deployment anywhere — Cloudflare Workers, Deno Deploy, Bun, Node.js, AWS Lambda Edge — with consistent performance:
// Cloudflare Worker (hono-rpc + edge)
export default {
fetch: app.fetch,
}
// Deno Deploy
Deno.serve(app.fetch)
// Node.js
serve(app)
No other option in this comparison runs natively on Cloudflare Workers with Hono's 0-overhead request handling.
Feature Comparison
| Feature | tRPC | ts-rest | Hono RPC |
|---|---|---|---|
| Weekly downloads | 2.5M | 250K | (part of Hono 2.8M) |
| Type safety mechanism | TypeScript inference | Zod contract | TypeScript inference |
| OpenAPI support | Via adapter | ✅ Native | Via @hono/zod-openapi |
| Code generation required | ❌ | ❌ | ❌ |
| WebSocket subscriptions | ✅ | ❌ | Via Hono WS |
| Edge runtime support | ✅ (limited) | ✅ | ✅ Excellent |
| REST-compatible | ❌ (custom protocol) | ✅ | ✅ |
| React Query integration | ✅ Built-in | Via @ts-rest/react-query | Manual |
| Status-code typed responses | ❌ | ✅ | Partial |
| Framework agnostic server | ✅ | ✅ | Hono only |
When to Choose Each
Choose tRPC if:
- Your team is TypeScript-first and doesn't need OpenAPI or external API consumers
- You want the best Next.js/React integration with TanStack Query built in
- You need WebSocket subscriptions for real-time features
- DX speed is the priority — tRPC has the fastest path from "define procedure" to "call from client"
Choose ts-rest if:
- You need OpenAPI generation for mobile clients, third-party consumers, or documentation
- You want REST semantics (standard HTTP methods and status codes)
- Your team comes from REST backgrounds and finds tRPC's procedure model unfamiliar
- You need exhaustive status-code handling at the type level
Choose Hono RPC if:
- You're deploying to Cloudflare Workers, Deno Deploy, or other edge runtimes
- Performance overhead is critical (Hono benchmarks as one of the fastest Node.js HTTP frameworks)
- You already use Hono for its middleware, validation, or edge features
- You want the smallest possible runtime footprint
Methodology
- npm download data from npmjs.com registry API, March 2026
- tRPC docs: trpc.io/docs
- ts-rest docs: ts-rest.com
- Hono docs: hono.dev
- Testing with TypeScript 5.4, Node.js 22, Next.js 15
Compare tRPC and related packages on PkgPulse.
Related: TanStack Query v5 vs SWR v3 vs RTK Query 2026 · @aws-sdk v3 Modular vs v2 Migration Guide 2026