Skip to main content

tRPC v11 vs ts-rest: Type-Safe APIs in TypeScript 2026

·PkgPulse Team
0

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

FeaturetRPC v11ts-rest
HTTP conventionsNo (POST for all)Yes (GET/POST/PUT/DELETE)
Standard URLsNo (/api/trpc/...)Yes (/users/:id)
OpenAPI compatibleNoYes (auto-generated)
RSC supportYes (v11)Via fetch
SSE streamingYes (v11)Limited
React Query integrationFirst-classYes
Non-TS consumersAwkwardExcellent
Contract sharing (npm)No (single repo)Yes (publish as package)
Framework adaptersNext.js, Express, FastifyNext.js, Express, Fastify, NestJS
Weekly downloads3.8M600K

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.

Middleware, Authentication, and Context Patterns

Both tRPC and ts-rest handle authentication through middleware patterns, but their approaches differ in ergonomics. tRPC's middleware system threads context through a chain — the isAuthenticated middleware narrows ctx.session from Session | null to Session using TypeScript's control flow analysis, and all subsequent procedures in the chain receive the narrowed type automatically. This means protected procedures genuinely cannot access ctx.session.userId as undefined — the type system enforces it. ts-rest handles authentication at the framework level: a Fastify preHandler, an Express middleware, or a Next.js Route Handler wrapper validates the token before calling the ts-rest router. The contract itself is authentication-agnostic, which is actually an advantage when the same contract is shared across authenticated and public endpoints — you add auth at the implementation layer without coupling it to the contract definition. For teams that need fine-grained per-endpoint authorization (not just "is authenticated" but "can this user access this resource"), tRPC's middleware composition model is more expressive.

Error Handling and Client-Side Error Discrimination

tRPC surfaces errors as TRPCError instances with standardized codes (NOT_FOUND, UNAUTHORIZED, BAD_REQUEST) that map cleanly to HTTP status codes when using the fetch adapter. On the client, trpc.user.getById.useQuery() returns errors typed as TRPCClientError, and you can check error.data?.code to branch on specific error types. ts-rest takes a different approach: error responses are part of the contract's response union. A route that returns 200: UserSchema | 404: z.object({ message: z.string() }) forces the client to discriminate on data.status before accessing data.body — you cannot access data.body.name without first asserting data.status === 200. This exhaustiveness check at the type level prevents a class of runtime errors that tRPC's exception-based model allows. For APIs with well-defined error response schemas (common in public APIs), ts-rest's approach produces more predictable client code. For internal APIs where you control both sides, tRPC's exception model is less ceremony.

Migration Paths and Incremental Adoption

Adopting tRPC or ts-rest in an existing REST API codebase does not require a full rewrite. ts-rest is particularly well-suited to incremental migration because the contract explicitly defines HTTP methods and URL paths — you can define a ts-rest contract that matches your existing REST endpoints exactly, add the server implementation gradually route by route, and your existing clients continue working unchanged because the URLs and methods are identical. tRPC is harder to adopt incrementally alongside an existing REST API because it uses non-standard URLs and requires the client to use the tRPC client library. The most pragmatic incremental tRPC adoption starts with new features: add a /api/trpc handler for all new functionality, leave existing REST endpoints untouched, and migrate old endpoints if and when you touch them. For teams considering a migration from tRPC to ts-rest (a common trajectory as products grow and need OpenAPI documentation), the logic layer is portable — only the routing layer and client setup change, not the business logic inside handlers.

Performance Characteristics and Bundle Size Impact

tRPC v11's React Query integration adds the full @tanstack/react-query bundle to your frontend — approximately 13 kB gzipped for React Query v5 plus the tRPC adapter. This is negligible for most applications but worth noting for edge-deployed frontends or pages where bundle size is tightly controlled. ts-rest's @ts-rest/react-query adapter is similarly sized. On the server side, neither library adds meaningful overhead to request handling — the type inference and contract validation happen at build time, not at runtime. tRPC's input validation via Zod does run at runtime on every request, which adds approximately 1–5 ms to handler latency depending on schema complexity. ts-rest's Zod validation is similarly positioned. For applications handling thousands of requests per second, this overhead is worth profiling; for typical SaaS throughput, it is not measurable in production metrics. The RSC caller pattern in tRPC v11 eliminates this overhead entirely for server-component data fetching since no HTTP serialization occurs.

Compare these packages on PkgPulse.

Compare Trpc-v11 and Ts-rest package health on PkgPulse.

See also: Next.js vs Remix and Next.js vs Nuxt.js, tRPC vs GraphQL: API Layer 2026.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.