What Is tRPC? End-to-End Typed APIs

Photo: Daniil Komov / Pexels
tRPC ("TypeScript Remote Procedure Call") is an open-source library that lets you build end-to-end type-safe APIs in TypeScript without code generation, schema files, or build steps. You define a procedure on the server, you call it on the client, and the types flow automatically — including inputs, outputs, and errors.
It was created by Alex Johansson (KATT) and first released in 2020. By 2026 it has become one of the most popular API choices for TypeScript-everywhere stacks, especially Next.js monorepos.
The Core Idea
If both your server and your client are written in TypeScript, the type information you already wrote should be available on both sides — without manually duplicating it in an OpenAPI spec, a GraphQL schema, or a hand-written client SDK.
tRPC achieves this by inferring the entire client API from the server's TypeScript types. There is no codegen step, no .proto file, no build pipeline. You import a type from the server file into the client. That's it.
A Minimal Example
Server (server/router.ts):
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const appRouter = t.router({
getUser: t.procedure.input(z.object({ id: z.string() })).query(async ({ input }) => {
return { id: input.id, name: 'Ada Lovelace' };
}),
createPost: t.procedure.input(z.object({ title: z.string().min(1) })).mutation(async ({ input }) => {
return { id: '1', title: input.title };
}),
});
export type AppRouter = typeof appRouter;
Client (client.ts):
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server/router';
const trpc = createTRPCProxyClient<AppRouter>({
links: [httpBatchLink({ url: '/api/trpc' })],
});
// Looks like a normal function call. Fully typed.
const user = await trpc.getUser.query({ id: '42' });
// ^? { id: string; name: string }
// Wrong input shape? TypeScript error at compile time.
await trpc.getUser.query({ id: 42 }); // Error: id must be string
The client never had to "know" the server's API ahead of time. It imported a type, and from that type the entire client surface was derived.
How It Actually Works
- Server defines a router of procedures (queries, mutations, subscriptions). Each procedure has an input validator (typically Zod) and a handler.
- Server exports the router's TypeScript type only, not its runtime code. That keeps your server bundle out of the client.
- Client creates a Proxy that intercepts property access (
trpc.getUser.query) and turns it into an HTTP request to the configured tRPC endpoint. - Runtime payload is JSON. The request says "call procedure
getUserwith this input." The server validates with Zod and runs the handler. - Type magic: because the proxy is typed by
AppRouter, the IDE sees every procedure, its input shape, its output shape, and its error type.
React Integration
The most common way to use tRPC in production is with TanStack React Query via @trpc/react-query. tRPC procedures become typed useQuery and useMutation hooks:
const { data, isLoading } = trpc.getUser.useQuery({ id: '42' });
const createPost = trpc.createPost.useMutation({
onSuccess: () => utils.posts.list.invalidate(),
});
You get React Query's caching, deduplication, and revalidation, plus full type safety on the procedure call.
Backend Support
tRPC works with most TypeScript HTTP backends:
- Next.js (App Router and Pages Router) — most common combination.
- Express, Fastify, Koa, Hono — standard Node frameworks.
- Bun, Deno — modern runtimes.
- AWS Lambda, Cloudflare Workers — serverless adapters.
Comparison: API Styles In 2026
| Approach | Type Safety | Codegen Needed | Best For | Worst At |
|---|---|---|---|---|
| tRPC | End-to-end automatic | No | TS-everywhere monorepos, internal APIs | Public APIs for non-TS clients |
| REST + hand-typed client | Manual duplication | No | Public APIs, polyglot clients | Keeping types in sync |
| OpenAPI + codegen | Generated from spec | Yes (build step) | Public APIs with multiple clients | Fast iteration; lag from regen |
| GraphQL | Generated from schema | Yes (codegen tools) | Many clients, complex query shapes | Simple CRUD; runtime overhead |
| gRPC / Protobuf | Generated from.proto | Yes (protoc) | High-performance service-to-service | Browser clients (needs grpc-web) |
| Next.js Server Actions | End-to-end (server fn passed to client) | No | Form submissions in Next.js apps | Non-React clients, mobile |
tRPC vs REST + Manual Types
REST works everywhere, but in a TS-only stack you write the same types twice:
// shared
type User = { id: string; name: string };
// server: returns User from /api/users/:id
// client: typed fetch wrapper hand-rolled to assert User
If the server changes the shape, the client's manual type drifts silently — runtime data no longer matches the type. tRPC closes that gap.
tRPC vs OpenAPI + Codegen
OpenAPI + codegen gives you typed clients for any language but requires a build step that lags behind code changes. tRPC types update the instant you save the server file — no regen, no stale client. The trade-off: OpenAPI is language-agnostic; tRPC clients must be TypeScript.
tRPC vs GraphQL
GraphQL solves a different problem: clients picking exactly which fields they want, often across many clients with different needs. For a single React frontend talking to a single Node backend, GraphQL is usually overkill — schema, resolvers, codegen, and a runtime parser to support a feature you barely use. tRPC is "GraphQL minus the parts you wish you didn't need," for the monorepo case. See our GraphQL vs REST vs tRPC deep dive for a fuller comparison.
tRPC vs Next.js Server Actions
Server Actions (introduced in Next.js 14) let you mark a server function with "use server" and call it directly from a client component. They overlap with tRPC mutations.
- Server Actions are best for forms and progressive enhancement — they integrate with
<form action={...}>and work without JavaScript. - tRPC is best for full RPC across the app, with React Query caching, batching, and explicit query/mutation semantics.
Many Next.js apps use both: Server Actions for form submissions, tRPC for everything else. They are complementary, not competing.
tRPC vs gRPC
Same first three letters, very different ecosystems. gRPC uses Protocol Buffers, supports many languages, and is excellent for service-to-service calls in a polyglot backend. It is not browser-friendly without grpc-web. tRPC is browser-first, TS-only, JSON-over-HTTP, and intended for client-server (not server-server) communication.
When To Choose tRPC
Choose tRPC when:
- The frontend and backend are both TypeScript.
- You are in a monorepo (or can share a types package).
- You want fast iteration without codegen overhead.
- The API is internal — your own frontend is the only consumer.
Avoid tRPC when:
- The API is public and will be consumed by Python, Go, mobile native apps, or third parties — they cannot import your TS types.
- You have a large team with contract-first workflows where OpenAPI or GraphQL schemas are the single source of truth.
- You need standard REST semantics (caching CDN behavior, browser dev tools, curl-friendly endpoints).
For backend data access alongside tRPC, see ORMs Prisma vs Drizzle vs Kysely — Drizzle pairs especially well because both lean into TypeScript inference.
tRPC Internals: How the Type Magic Works
The "type magic" is really TypeScript's type inference doing work. When you export type AppRouter = typeof appRouter, you're exporting the type of the router object — a TypeScript interface that describes every procedure, its input type, output type, and error type.
On the client, you import AppRouter and pass it as a generic type to createTRPCProxyClient:
const trpc = createTRPCProxyClient<AppRouter>({...})
The client library then uses TypeScript's conditional types, mapped types, and generics to build a Proxy type where:
trpc.getUseris correctly typed as a procedure.query()is available (becausegetUseris a query).query({ id: '42' })is type-checked against the input schema- The return value resolves to the output type
The Proxy object (at runtime) is thin. It intercepts every property access and method call, packages them as RPC requests, and sends HTTP POST to the server. The server validates inputs using Zod, runs the handler, and returns the output as JSON.
The magic is: there is no codegen. The types flow through TypeScript's built-in type system. The client doesn't import the server's runtime code (that would bloat the client bundle), only its type.
This is why tRPC only works when both sides are TypeScript — a Python or Go client cannot import TypeScript types at all.
Source: TypeScript Handbook (2024) — Conditional Types, Mapped Types, and Generics.
Error Handling and Typed Error Responses
tRPC distinguishes between input validation errors and handler errors. Both are type-safe.
Input validation uses Zod (or similar schema validator). If the client sends { id: 42 } to a procedure expecting { id: string }, the server rejects it automatically with a PARSE_ERROR:
// Client sends wrong type
await trpc.getUser.query({ id: 42 });
// Server responds with: { code: 'PARSE_ERROR', message: 'Input validation failed' }
Handler errors are caught and typed. You can throw a TRPCError with a code (INTERNAL_SERVER_ERROR, NOT_FOUND, etc.) and a message:
getUser: t.procedure.input(z.object({ id: z.string() })).query(async ({ input }) => {
const user = await db.users.findUnique({ where: { id: input.id } });
if (!user) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' });
}
return user;
})
The client can react to the error type:
try {
await trpc.getUser.query({ id: '42' });
} catch (err) {
if (err.data?.code === 'NOT_FOUND') {
// User not found
}
}
The error type is not automatically inferred from the handler (unlike the output type), so you must manually specify it if you want typed errors on the client. This is a limitation of current TypeScript and one area tRPC leaves unsolved.
Middleware, Authentication, and Access Control
tRPC procedures can have middleware — functions that run before the handler and can add context, validate permissions, or perform logging.
A common pattern is authentication middleware:
const isAuthed = t.middleware(async ({ next, ctx }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next();
});
export const protectedProcedure = t.procedure.use(isAuthed);
// Now this procedure requires auth
const appRouter = t.router({
getMyProfile: protectedProcedure.query(({ ctx }) => {
// ctx.user is typed and guaranteed to exist
return ctx.user;
}),
});
Middleware is composable and fully typed. If middleware adds data to ctx, subsequent middleware and handlers see it in their types.
This is where tRPC's type safety shines over REST. In REST, you manually check if (!user) in every protected endpoint and the type never reflects that. In tRPC, middleware can guarantee ctx.user is present at the type level.
Many teams build role-based access control (RBAC) this way, with middleware that checks permissions before running the handler.
Performance Considerations and Optimization
Batching is a key tRPC feature. When you call multiple procedures in quick succession, tRPC by default batches them into one HTTP request:
const [user, posts, comments] = await Promise.all([
trpc.getUser.query({ id: '42' }),
trpc.getPosts.query({ userId: '42' }),
trpc.getComments.query({ userId: '42' }),
]);
// All three are batched in one POST request
This reduces round-trip overhead significantly for pages that fetch multiple pieces of data.
Caching via React Query is automatic when you use @trpc/react-query. By default, successful query results are cached for 5 minutes, and calling the same query again returns the cached data without a network request.
Deduplication means if two components simultaneously call the same query, only one HTTP request is made. React Query deduplicates automatically.
Lazy evaluation is important for Next.js. If you use tRPC in a Server Component, the procedure runs on the server (in the same process), not over HTTP. No network round-trip. The types are still correct, but the execution is faster.
// Server Component
const user = await trpc.getUser.query({ id: '42' }); // Runs locally on server
For large data transfers, pagination is often needed. tRPC doesn't mandate a pattern but works well with cursor-based pagination:
posts: t.procedure
.input(z.object({ cursor: z.string().optional(), limit: z.number().max(100) }))
.query(async ({ input }) => {
const posts = await db.posts.findMany({
take: input.limit + 1,
skip: input.cursor ? 1 : 0,
cursor: input.cursor ? { id: input.cursor } : undefined,
});
return {
posts: posts.slice(0, input.limit),
nextCursor: posts.length > input.limit ? posts[input.limit].id : null,
};
}),
The tRPC Ecosystem: Extensions and Related Tools
Prisma integration is natural — Prisma queries are automatically typed, and tRPC uses those types. Many projects use Prisma + tRPC together seamlessly.
Zod is the most common schema validator. tRPC works with other validators too (Yup, Arktype, Valibot), but Zod's developer experience is best.
TanStack Query (React Query) is the standard caching layer.
Procedure builders are extensions. trpc.procedure is the default; teams often define custom procedure builders for common patterns:
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(isAuthed);
export const adminProcedure = t.procedure.use(isAdminMiddleware);
tRPC Labs experiments with emerging patterns. Recent additions include client-side caching strategies and experimental support for Server Actions integration.
Next.js integration has tRPC as a first-class citizen. The most common pattern is a single tRPC route (/api/trpc) that handles all procedure calls. With Next.js server/client component split, developers can call tRPC directly from server components without making network requests.
For a deeper comparison to alternatives, see GraphQL vs REST vs tRPC.
Subscriptions and Real-Time Updates
Beyond queries (read) and mutations (write), tRPC supports subscriptions — long-lived connections that stream data from server to client in real time.
A subscription is defined similar to a query but uses WebSockets instead of HTTP:
export const appRouter = t.router({
onUserUpdate: t.procedure
.input(z.object({ userId: z.string() }))
.subscription(async function* ({ input }) {
// Stream updates whenever user data changes
for await (const update of db.watchUser(input.userId)) {
yield update;
}
}),
});
On the client, subscriptions become typed event listeners:
trpc.onUserUpdate.subscribe({ userId: '42' }, {
onData: (user) => console.log('User updated:', user),
onError: (err) => console.error(err),
});
Use cases: Real-time notifications, collaborative editing (Figma-style), live dashboards, chat applications, multiplayer games. Subscriptions are powerful but require WebSocket infrastructure (more complex to deploy than HTTP-only APIs).
Database Integration Patterns
tRPC pairs naturally with type-safe databases. Two dominant patterns in 2026:
Prisma + tRPC (most common): Prisma's generated types align perfectly with tRPC's type inference. A Prisma query's return type becomes your tRPC procedure's output type automatically.
const user = await db.user.findUnique({ where: { id: '42' } });
// Prisma types it as { id: string; name: string; ... }
// tRPC inherits that type automatically
return user; // Type-safe without manual typing
Drizzle + tRPC (emerging): Drizzle (an alternative ORM) also emphasizes type inference. Some developers prefer Drizzle's API and find the Drizzle+tRPC pairing even more ergonomic because both philosophies align: maximize TypeScript inference, minimize boilerplate.
Pattern: The best database for tRPC is one with a fully typed API that returns well-defined types. Avoid databases or ORMs that return any or force manual type assertion.
Testing tRPC Procedures: Unit and Integration Testing
tRPC procedures are functions, so they're testable as functions — no special mocking required for unit tests.
// Procedure
export const getUser = t.procedure
.input(z.object({ id: z.string() }))
.query(({ input }) => db.users.get(input.id));
// Unit test
import { describe, it, expect } from 'vitest';
it('getUser returns user by id', async () => {
const caller = appRouter.createCaller({ db: mockDb });
const user = await caller.getUser({ id: '42' });
expect(user.name).toBe('Ada Lovelace');
});
The caller API lets you invoke procedures in tests without going through HTTP, avoiding network overhead and complexity.
Integration tests (client + server together) require more setup but verify the entire flow:
// Spin up HTTP server, initialize client, test like production
const server = createHTTPServer(appRouter);
const client = createTRPCProxyClient({ ... });
const user = await client.getUser.query({ id: '42' });
expect(user).toBeDefined();
Frameworks like TRPCNext (for Next.js) simplify integration testing by running the server and client in the same Node process during tests.
Common Pitfalls and Gotchas
Overfetching without granular queries: A common mistake is grouping too much data in one procedure. If your frontend needs user + posts + comments, you might write one getUserFull query instead of three separate calls. This works but breaks the ability to cache individually. Better: make granular queries (getUser, getPosts, getComments) and let React Query deduplicate and cache.
Forgetting to serialize custom types: Dates, BigInts, and Maps don't serialize to JSON natively. Use superjson to automate serialization:
export const appRouter = t.router({...});
export const createCaller = t.createCallerFactory(appRouter);
// Dates serialize automatically with superjson
Without superjson, dates arrive on the client as strings, losing type information.
N+1 queries: tRPC doesn't prevent N+1 queries (fetching one parent, then one query per child). Use DataLoader or batch endpoints to solve this. The responsibility is still on the developer, not the framework.
Sharing database connections across requests: If your database connection pool is small and concurrent requests spike, they can exhaust the pool. Use connection pooling (most databases handle this) and monitor connection limits in production.
Type mismatches between client and server: Despite tRPC's type safety, this is rare but possible when server code changes and client hasn't redeployed. Keep server and client deployments in sync, or version your API.
Real-World Applications and Case Studies
Early adopters (2020–2023) were mostly startups using Next.js monorepos: t3.gg community, Stripe's internal tools, and countless app studios building for clients.
Enterprise adoption (2024–2026) is ramping. Companies like Figma and Vercel (internally) have adopted tRPC-like patterns for their internal APIs. Large organizations using tRPC include teams at GitHub and GitLab (for portions of their API).
Constraint: tRPC shines for internal APIs. Public APIs consumed by non-TypeScript clients are rare users of tRPC because those clients can't import TypeScript types. This limits tRPC to TS-only ecosystems — teams fully committed to TypeScript across the stack.
The trajectory is clear: tRPC is becoming the default for TypeScript monorepos the way GraphQL became default for polyglot APIs. By 2027, most new Next.js + TypeScript projects will likely start with tRPC as the default API choice.
The Takeaway
tRPC eliminates a whole category of boilerplate and bugs in TypeScript-full-stack development. By leveraging TypeScript's type inference, tRPC makes end-to-end type-safe APIs possible without codegen or schema files. The framework works best in monorepos where frontend and backend deploy together and both are TypeScript. For public APIs or polyglot backends, REST or GraphQL remain better choices. But for internal APIs in TS-everywhere teams, tRPC's combination of developer experience, performance, and type safety makes it hard to beat in 2026.
Frequently Asked Questions
Q: Who created tRPC and when?
A: Alex Johansson (KATT) released the first version in 2020. The project is open source under the MIT license and is maintained by a team including KATT and several core contributors. It has grown significantly in adoption since 2022, especially in the Next.js ecosystem.
Q: Does tRPC require a build step or code generation?
A: No. That is its defining feature. The client infers types directly from the server's exported router type at TypeScript compile time. There is no .proto, no OpenAPI spec, and no separate codegen step — just TypeScript's built-in type inference.
Q: Does tRPC use JSON or a binary protocol?
A: JSON over HTTP by default. There is an optional superjson transformer to serialize types JSON cannot represent natively (Date, BigInt, Map, Set). Binary protocols are not used; tRPC prioritizes simplicity and debuggability over raw bandwidth efficiency.
Q: Can I use tRPC from a non-TypeScript client?
A: Technically yes — tRPC procedures are reachable as HTTP POST/GET endpoints with a JSON payload — but you lose all type safety and must manually build request/response models. If non-TS clients matter, prefer REST, GraphQL, or OpenAPI for better compatibility.
Q: Does tRPC work with React Query and caching?
A: Yes, that is the recommended setup. @trpc/react-query wraps each procedure as a typed useQuery or useMutation hook with full React Query caching, deduplication, and invalidation support built-in.
Q: Is tRPC slower than REST because of the proxy object?
A: No. The "proxy" is a thin TypeScript construct at compile time and a small wrapper at runtime. Under the hood it is still an HTTP request with a JSON body — identical network cost to REST. Batching often makes it faster by reducing round-trips.
Sources
- tRPC Official Documentation, trpc.io.
- tRPC GitHub repository, github.com/trpc/trpc.
- Alex Johansson, "Building tRPC," conference talks 2022–2024.
- TanStack Query Documentation, "tRPC integration."
Follow new articles
Add SciHub101 to your feed reader to see new science and technology stories.
About the Author
SciHub101 is an independent science and technology publication founded and edited by Buğra Sözeri, a software engineer and app developer based in Türkiye. AI tools assist with drafting and research. Source documentation in the existing archive is uneven, and articles are being reviewed and corrected. The Editorial Team byline identifies editorial responsibility; it does not certify specialist or independent human review. Where we compare products, anything built by our own team is labelled as such — see the editorial guidelines and About page for how corrections and disclosures work.
Related Articles

What Is Drizzle ORM? TypeScript SQL Builder
Drizzle ORM is a lightweight, type-safe TypeScript ORM with SQL-like syntax. Schema, queries, migrations, and comparison with Prisma.

What Is shadcn/ui? Copy-Paste UI Components
shadcn/ui is a CLI that copies React + Tailwind + Radix UI components into your codebase so you own and control the source code.

GraphQL vs REST vs tRPC 2026: API Architecture Pick
GraphQL, REST and tRPC compared on caching, type safety, security, versioning and tooling, with a decision guide for public, mobile and TypeScript APIs.

What Is Hono? Edge-First Web Framework
Hono is a fast, tiny TypeScript web framework that runs on Cloudflare Workers, Deno, Bun, and Node. How it works and when to use it.

Bun vs Node.js vs Deno — 2026 Comparison
Bun is fastest, Node is bulletproof, Deno is the most secure. A 2026 comparison of the three major JavaScript runtimes with benchmarks and trade-offs.

ORMs 2026 — Prisma vs Drizzle vs Kysely Honest Comparison
Real comparison of TypeScript ORMs in 2026 — Prisma, Drizzle, Kysely, TypeORM — for type safety, performance, edge compatibility, and migration story.