tRPC v11 vs ts-rest: Type-Safe APIs in TypeScript 2026
tRPC v11 introduced React Server Component support — procedures can now be called directly from RSCs without an HTTP round-trip. ts-rest takes the opposite philosophy: define your API as a REST contract first, share it between client and server, and get full type safety without abandoning HTTP conventions. Both eliminate the runtime type errors that plague traditional REST APIs. The question is whether you want RPC-style procedures or REST-style contracts.
TL;DR
tRPC v11 for full-stack TypeScript monorepos where the frontend and backend share code — especially Next.js applications where you want zero-overhead RSC procedure calls. ts-rest when REST conventions matter: OpenAPI compatibility, public API exposure, or teams with non-TypeScript consumers who need a standard HTTP interface. For internal TypeScript APIs in a monorepo, tRPC is the faster path.
Key Takeaways
- tRPC v11: 3.8M weekly npm downloads, 36K GitHub stars, React Server Component support
- ts-rest: 600K weekly downloads, 4.6K GitHub stars — growing rapidly as REST-first alternative
- tRPC v11: SSE streaming with async generators, Suspense integration, RSC procedure calls
- ts-rest: OpenAPI spec generation, Zod contract sharing, framework adapters (Express, Fastify, Next.js)
- tRPC: No HTTP conventions (uses POST for all queries, custom URLs)
- ts-rest: Full REST (GET/POST/PUT/DELETE, standard URLs, OpenAPI compatible)
- Both: 100% end-to-end type safety from server handler to client call, zero code generation
The Problem Both Solve
Traditional REST APIs have a type gap:
// Server defines the API:
app.get('/users/:id', (req, res) => {
const user: User = await db.findUser(req.params.id);
res.json(user);
});
// Client uses the API (no type safety):
const response = await fetch('/users/123');
const user = await response.json(); // type: any
// TypeScript doesn't know this is a User
Both tRPC and ts-rest solve this by sharing types between server and client — eliminating the any type at the API boundary.
tRPC v11
Package: @trpc/server, @trpc/client, @trpc/react-query
Weekly downloads: 3.8M (combined packages)
GitHub stars: 36K
Creator: Alex Johansson
tRPC v11 is a backward-compatible upgrade from v10 with major additions: React Server Component support, Suspense integration, SSE streaming, and a cleaner API for edge runtimes.
Installation
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query
# Recommended: zod for input validation
npm install zod
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;
// server/routers/user.ts
import { router, publicProcedure } from '../trpc';
import { z } from 'zod';
export const userRouter = router({
// Query: GET-like operation
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
const user = await db.user.findUnique({ where: { id: input.id } });
if (!user) throw new TRPCError({ code: 'NOT_FOUND' });
return user;
}),
// Mutation: POST/PUT-like operation
create: publicProcedure
.input(z.object({
name: z.string().min(2),
email: z.string().email(),
}))
.mutation(async ({ input }) => {
return await db.user.create({ data: input });
}),
// List with pagination
list: publicProcedure
.input(z.object({
cursor: z.string().optional(),
limit: z.number().default(10),
}))
.query(async ({ input }) => {
const users = await db.user.findMany({
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
orderBy: { createdAt: 'desc' },
});
const nextCursor = users.length > input.limit ? users.pop()?.id : undefined;
return { users, nextCursor };
}),
});
// App router — combine all routers
export const appRouter = router({ user: userRouter });
export type AppRouter = typeof appRouter;
Next.js API Route Handler
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: () => ({}),
});
export { handler as GET, handler as POST };
Client Setup with React Query
// lib/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/routers';
export const trpc = createTRPCReact<AppRouter>();
// components/UserProfile.tsx
import { trpc } from '@/lib/trpc';
function UserProfile({ userId }: { userId: string }) {
// Fully typed — TypeScript knows the return type from the router definition
const { data: user, isLoading } = trpc.user.getById.useQuery({ id: userId });
if (isLoading) return <div>Loading...</div>;
// user is typed as { id: string; name: string; email: string; ... }
return <div>{user?.name}</div>;
}
tRPC v11: React Server Components
The major new feature in v11 — call tRPC procedures directly from RSCs:
// server/caller.ts
import { createCallerFactory } from '@trpc/server';
import { appRouter } from './routers';
const createCaller = createCallerFactory(appRouter);
export const caller = createCaller({ /* context */ });
// app/users/[id]/page.tsx (React Server Component)
import { caller } from '@/server/caller';
// No HTTP round-trip — direct function call in RSC
export default async function UserPage({ params }: { params: { id: string } }) {
const user = await caller.user.getById({ id: params.id });
// user is fully typed
return <div>{user.name}</div>;
}
tRPC v11: SSE Streaming
// server: streaming procedure
const streamRouter = router({
stream: publicProcedure
.subscription(async function* () {
// Async generator for real-time data
for await (const message of messageStream()) {
yield message;
}
}),
});
// client: consuming the stream
const subscription = trpc.stream.useSubscription(undefined, {
onData: (message) => console.log('Received:', message),
});
tRPC Middleware and Context
const t = initTRPC.context<{ session: Session | null }>().create();
const isAuthenticated = t.middleware(({ ctx, next }) => {
if (!ctx.session) throw new TRPCError({ code: 'UNAUTHORIZED' });
return next({ ctx: { session: ctx.session } });
});
const protectedProcedure = t.procedure.use(isAuthenticated);
const userRouter = router({
deleteAccount: protectedProcedure
.mutation(async ({ ctx }) => {
// ctx.session is typed and guaranteed non-null here
await db.user.delete({ where: { id: ctx.session.userId } });
}),
});
ts-rest
Package: @ts-rest/core, @ts-rest/next, @ts-rest/express, @ts-rest/react-query
Weekly downloads: 600K
GitHub stars: 4.6K
Creator: ts-rest team
ts-rest defines your API as a typed contract — a TypeScript object that describes every route's method, path, request shape, and response shape. The same contract is used by the server to implement the routes and the client to call them.
Installation
npm install @ts-rest/core @ts-rest/react-query zod
# Plus the adapter for your framework:
npm install @ts-rest/next # Next.js
npm install @ts-rest/express # Express
Contract Definition
// contracts/user.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(),
createdAt: z.string(),
});
export const userContract = c.router({
getUser: {
method: 'GET', // Real HTTP GET
path: '/users/:id', // Real REST URL
pathParams: z.object({ id: z.string() }),
responses: {
200: UserSchema,
404: z.object({ message: z.string() }),
},
},
createUser: {
method: 'POST', // Real HTTP POST
path: '/users',
body: z.object({
name: z.string().min(2),
email: z.string().email(),
}),
responses: {
201: UserSchema,
400: z.object({ errors: z.array(z.string()) }),
},
},
listUsers: {
method: 'GET',
path: '/users',
query: z.object({
page: z.number().optional(),
limit: z.number().optional(),
}),
responses: {
200: z.object({
users: z.array(UserSchema),
total: z.number(),
}),
},
},
});
Server Implementation (Next.js)
// app/api/users/[...path]/route.ts
import { createNextHandler } from '@ts-rest/next';
import { userContract } from '@/contracts/user.contract';
const handler = createNextHandler({
contract: userContract,
router: {
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 user = await db.user.create({ data: body });
return { status: 201, body: user };
},
listUsers: async ({ query }) => {
const page = query.page ?? 1;
const limit = query.limit ?? 10;
const [users, total] = await Promise.all([
db.user.findMany({ skip: (page - 1) * limit, take: limit }),
db.user.count(),
]);
return { status: 200, body: { users, total } };
},
},
});
export { handler as GET, handler as POST };
Client (React Query)
// lib/ts-rest.ts
import { initQueryClient } from '@ts-rest/react-query';
import { userContract } from '@/contracts/user.contract';
export const client = initQueryClient(userContract, {
baseUrl: 'http://localhost:3000',
baseHeaders: {},
});
// components/UserProfile.tsx
import { client } from '@/lib/ts-rest';
function UserProfile({ userId }: { userId: string }) {
// Fully typed — TypeScript knows the response shape from the contract
const { data } = client.getUser.useQuery(['user', userId], {
params: { id: userId },
});
// data.body is typed as User | { message: string } based on status
if (data?.status === 200) {
return <div>{data.body.name}</div>;
}
return <div>Not found</div>;
}
OpenAPI Generation
ts-rest's advantage: automatic OpenAPI spec from your contracts.
import { generateOpenApi } from '@ts-rest/open-api';
import { userContract } from './contracts/user.contract';
const openApiDocument = generateOpenApi(
{ users: userContract },
{
info: {
title: 'My API',
version: '1.0.0',
},
}
);
// Serve with Swagger UI:
app.get('/api-docs.json', (req, res) => res.json(openApiDocument));
This generates a full OpenAPI 3.0 document from your TypeScript contracts — usable by Swagger UI, Postman, code generators, and non-TypeScript consumers.
Feature Comparison
| Feature | tRPC v11 | ts-rest |
|---|---|---|
| HTTP conventions | No (POST for all) | Yes (GET/POST/PUT/DELETE) |
| Standard URLs | No (/api/trpc/...) | Yes (/users/:id) |
| OpenAPI compatible | No | Yes (auto-generated) |
| RSC support | Yes (v11) | Via fetch |
| SSE streaming | Yes (v11) | Limited |
| React Query integration | First-class | Yes |
| Non-TS consumers | Awkward | Excellent |
| Contract sharing (npm) | No (single repo) | Yes (publish as package) |
| Framework adapters | Next.js, Express, Fastify | Next.js, Express, Fastify, NestJS |
| Weekly downloads | 3.8M | 600K |
When to Use Each
Choose tRPC v11 if:
- Full-stack TypeScript monorepo where client and server share code
- Next.js application — RSC procedure calls and App Router integration are excellent
- Internal API consumed only by TypeScript frontends
- React Query integration and hooks are important
- You want SSE streaming for real-time features
Choose ts-rest if:
- REST conventions matter (GET for reads, POST for writes, standard URLs)
- You need OpenAPI documentation for external consumers or Postman collections
- Non-TypeScript teams or services consume your API
- The same API contract is shared across multiple services or packages
- You're adding type safety to an existing REST API without changing URL structure
2026 Ecosystem Position
tRPC v11 has addressed its biggest limitation: RSC support means you're not forced to add HTTP overhead for server-to-server calls in Next.js. The T3 stack (Next.js + tRPC + Prisma + Tailwind) continues to be a dominant pattern for TypeScript SaaS development in 2026.
ts-rest is the right call when REST principles are non-negotiable — and the OpenAPI auto-generation is a significant practical advantage for teams that need API documentation or non-TypeScript consumers.
Compare these packages on PkgPulse.
See the live comparison
View trpc v11 vs. ts rest on PkgPulse →