Skip to main content

Hono RPC vs tRPC vs ts-rest Type-Safe APIs 2026

·PkgPulse Team

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

FeaturetRPCts-restHono RPC
Weekly downloads2.5M250K(part of Hono 2.8M)
Type safety mechanismTypeScript inferenceZod contractTypeScript inference
OpenAPI supportVia adapter✅ NativeVia @hono/zod-openapi
Code generation required
WebSocket subscriptionsVia Hono WS
Edge runtime support✅ (limited)✅ Excellent
REST-compatible❌ (custom protocol)
React Query integration✅ Built-inVia @ts-rest/react-queryManual
Status-code typed responsesPartial
Framework agnostic serverHono 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

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.