Introduction: Why Type Safety Matters More Than Ever
Here's something that happened at my last startup: an engineer pushed a change that removed a field from an API response. Seemed simple enough. The frontend didn't break immediately because JavaScript doesn't care about missing fields. But two weeks later, a customer hit an edge case, the code tried to access that field, and the entire checkout flow went down for twelve hours.
That incident cost us money. More importantly, it made me realize something fundamental: the gap between backend and frontend is where most bugs hide.
Enter tRPC. It's a framework that sounds boring until you understand what it actually does: it gives you end-to-end type safety across your entire application without any code generation, schema definitions, or middleware boilerplate. Your frontend and backend share types directly. If you change a return type on the server, your IDE screams at you on the client. Before the code even runs.
Unlike REST APIs where you're essentially guessing what the response looks like, or GraphQL where you're writing separate schema definitions, tRPC treats your backend procedures like local functions. Except they run over the network. It's a weird mental shift at first, but once you get it, you can't go back.
In this guide, we're going deep. We'll cover everything from setting up your first tRPC server to scaling it across multiple teams, handling real-world complexity, and integrating it with modern deployment platforms. Whether you're building a side project or a production application, understanding tRPC in 2025 is becoming table stakes for TypeScript developers.
TL; DR
- Type safety across the wire: tRPC infers types directly from your backend procedures, eliminating the gap between frontend and backend type definitions.
- Zero-cost abstraction: No runtime validation overhead, no schema compilation step, just pure TypeScript inference.
- Perfect for full-stack TypeScript: Works seamlessly with Next.js, Remix, Vite, and other modern frameworks.
- Built-in error handling: Standardized error responses with full type inference on the client.
- Production-ready: Companies like Vercel, Supabase, and startups across TechCrunch are shipping with tRPC.


tRPC's request batching reduces network requests from 3 to 1, and caching strategies can reduce data payloads by approximately 70%. Estimated data.
What Is tRPC and Why It's Different
The Problem It Solves
Let's talk about the traditional API problem. You write a REST endpoint that returns a user object. The response looks like this:
json{
"id": "user_123",
"email": "alice@example.com",
"profile": {
"first Name": "Alice",
"last Name": "Smith",
"bio": "TypeScript enthusiast"
}
}
On the frontend, you write a type definition:
typescriptinterface User {
id: string;
email: string;
profile: {
first Name: string;
last Name: string;
bio: string;
};
}
Now what happens when you add a new field on the backend? You have to remember to update that interface. If you don't, your TypeScript won't catch it, and you get a runtime error in production.
With GraphQL, you solve this by defining a schema separately from your code:
graphqltype User {
id: ID!
email: String!
profile: Profile!
}
type Profile {
first Name: String!
last Name: String!
bio: String
}
Now your frontend can query exactly what it needs. But you're maintaining a separate schema file, writing resolvers, setting up validation, and dealing with the N+1 query problem. It's powerful but complex.
With tRPC, you don't have a separate schema. You just write TypeScript procedures:
typescriptexport const userRouter = router({
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
const user = await db.user.findUnique({
where: { id: input.id },
include: { profile: true }
});
return user;
})
});
On the frontend, you don't need to define any types. They're inferred automatically:
typescriptconst user = await trpc.user.getById.query({ id: "user_123" });
// ✅ TypeScript knows user.profile.first Name is a string
// ✅ If the backend removes it, TypeScript errors immediately
This is the magic. Your types are the source of truth. Everything else is derived.
How tRPC Actually Works Under the Hood
The mental model is simple: your tRPC server exposes procedures (think of them as functions). Your client calls them. The magic is in how the types flow.
When you define a procedure:
typescriptconst userProcedure = publicProcedure
.input(z.object({ id: z.string().uuid() }))
.output(z.object({ id: z.string(), email: z.string() }))
.query(async ({ input }) => {
// input is typed as { id: string }
// return value must match output schema
return { id: "...", email: "..." };
});
The TypeScript compiler extracts the types. When your frontend imports the app router:
typescripttype AppRouter = typeof appRouter;
const trpc = createTRPCClient<AppRouter>({
links: [/* httpLink configuration */]
});
Now trpc.user.getById.query({ id: "..." }) knows exactly what types to expect. If you change the output schema, the frontend code immediately shows type errors. No runtime validation on the client side needed.
Comparing tRPC to Alternatives
You might be asking: "How does tRPC compare to REST + OpenAPI, GraphQL, or something like Prisma's client-only approach?"
tRPC vs REST APIs: REST requires manual type definitions on both sides. You get HTTP semantics, which is good for caching and CDNs, but you lose type safety at the boundary. tRPC sacrifices REST semantics for full type safety.
tRPC vs GraphQL: GraphQL gives you precise data fetching and a language-agnostic schema. But you're maintaining a separate schema file, dealing with resolver complexity, and handling N+1 query problems. tRPC is simpler if your frontend and backend are both TypeScript.
tRPC vs OpenAPI/Swagger: OpenAPI solves the "multiple language" problem beautifully. If your backend is in Go and your frontend is in TypeScript, you need OpenAPI. If everything is TypeScript, tRPC is lighter.
tRPC vs Remix/Astro serverless functions: These frameworks blur the line between frontend and backend code. You can define server functions that the frontend calls directly. It's a similar pattern to tRPC but less flexible for complex API architectures.
The pattern emerging in 2025 is clear: if you're building a modern TypeScript full-stack application, tRPC handles the "happy path" with zero overhead. For more complex scenarios (multiple backends, different languages, public APIs), you might still need REST or GraphQL.

tRPC offers lower complexity and higher type safety compared to REST and GraphQL. Estimated data reflects typical perceptions of these technologies.
Core Concepts: Routers, Procedures, and Middleware
Understanding Procedures
A procedure is the atomic unit in tRPC. It's a single endpoint that can be either a query (read) or a mutation (write). Each procedure has three optional parts: input validation, middleware logic, and the actual handler.
Here's a practical example:
typescriptconst createPost = publicProcedure
.input(z.object({
title: z.string().min(5).max(200),
content: z.string().min(10),
tags: z.array(z.string()).max(5)
}))
.mutation(async ({ input, ctx }) => {
const post = await db.post.create({
data: {
title: input.title,
content: input.content,
tags: input.tags,
authorId: ctx.userId
}
});
return post;
});
Notice three things: (1) input validation happens via Zod schemas, (2) the context (ctx) gives you user info and database access, (3) the return type is inferred from what you actually return.
The frontend doesn't need to know about Zod. It just knows the types:
typescriptconst response = await trpc.post.create.mutate({
title: "My First Post",
content: "This is my first post",
tags: ["typescript", "trpc"]
});
// response is typed as { id: string, title: string, ... }
If you pass invalid input (title too short, tags too many), Zod catches it on the server and returns a structured error. On the client, you can handle it:
typescripttry {
await trpc.post.create.mutate({ title: "x" });
} catch (error) {
if (error.data?.code === "BAD_REQUEST") {
// Handle validation error
console.log(error.data.zodError);
}
}
Building Routers for Organization
As your application grows, putting all procedures in one file becomes unmaintainable. tRPC routers let you organize procedures hierarchically.
typescriptconst postRouter = router({
list: publicProcedure.query(async () => {
return await db.post.findMany();
}),
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return await db.post.findUnique({
where: { id: input.id }
});
}),
create: protectedProcedure
.input(z.object({ title: z.string(), content: z.string() }))
.mutation(async ({ input, ctx }) => {
return await db.post.create({
data: { ...input, authorId: ctx.userId }
});
})
});
const userRouter = router({
profile: protectedProcedure.query(async ({ ctx }) => {
return await db.user.findUnique({
where: { id: ctx.userId }
});
}),
update: protectedProcedure
.input(z.object({ bio: z.string() }))
.mutation(async ({ input, ctx }) => {
return await db.user.update({
where: { id: ctx.userId },
data: { bio: input.bio }
});
})
});
const appRouter = router({
post: postRouter,
user: userRouter
});
export type AppRouter = typeof appRouter;
On the frontend:
typescriptconst posts = await trpc.post.list.query();
const user = await trpc.user.profile.query();
await trpc.post.create.mutate({ title: "...", content: "..." });
The router structure reflects your API structure. Nested routers create nested client calls. For large applications, you might have dozens of routers, each responsible for one domain.
Middleware: The Control Layer
Middleware in tRPC runs before your procedure logic. It's where you implement authentication checks, logging, rate limiting, and request monitoring.
Here's what middleware looks like:
typescriptconst isAuthenticated = t.middleware(({ ctx, next }) => {
if (!ctx.userId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
...ctx,
userId: ctx.userId // now guaranteed non-null
}
});
});
const protectedProcedure = t.procedure.use(isAuthenticated);
Now any procedure using protectedProcedure automatically has authentication baked in. If the user isn't authenticated, tRPC throws a standardized error.
You can chain middleware:
typescriptconst isAdmin = t.middleware(({ ctx, next }) => {
if (ctx.role !== "admin") {
throw new TRPCError({ code: "FORBIDDEN" });
}
return next();
});
const adminProcedure = t.procedure
.use(isAuthenticated)
.use(isAdmin);
Middleware also lets you add logging and monitoring:
typescriptconst loggingMiddleware = t.middleware(async ({ path, type, next }) => {
const start = Date.now();
const result = await next();
const ms = Date.now() - start;
console.log(`${path} (${type}) took ${ms}ms`);
return result;
});
Middleware is powerful because it's composable. You can build complex authorization policies by stacking middleware, and each procedure documents exactly which middleware it uses.

Setting Up Your First tRPC Application
Step-by-Step: Backend Setup
Let's build a minimal tRPC server from scratch. First, install dependencies:
bashnpm install @trpc/server zod
Create your first router (server/trpc.ts):
typescriptimport { initTRPC } from "@trpc/server";
import { z } from "zod";
const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;
const appRouter = router({
greeting: publicProcedure
.input(z.object({ name: z.string() }))
.query(({ input }) => {
return `Hello, ${input.name}!`;
}),
});
export type AppRouter = typeof appRouter;
export default appRouter;
Now create an HTTP server (server/index.ts):
typescriptimport { createHTTPServer } from "@trpc/server/adapters/standalone";
import appRouter from "./trpc";
const server = createHTTPServer({
router: appRouter,
});
server.listen(3000);
console.log("✅ Server running at http://localhost:3000");
Run it:
bashnode server/index.ts
Now your tRPC server is live. The backend is done. That simple.
Step-by-Step: Frontend Setup
On the frontend, install the client:
bashnpm install @trpc/client @trpc/react-query
Create a client (client/trpc.ts):
typescriptimport { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "../server/trpc";
export const trpc = createTRPCReact<AppRouter>();
Set up the HTTP link:
typescriptimport { createTRPCReact } from "@trpc/react-query";
import { httpBatchLink } from "@trpc/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { AppRouter } from "../server/trpc";
const trpc = createTRPCReact<AppRouter>();
const queryClient = new QueryClient();
const trpcClient = trpc.createClient({
links: [
httpBatchLink({
url: "http://localhost:3000",
}),
],
});
export function TRPCProvider(props: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<trpc.Provider client={trpcClient} queryClient={queryClient}>
{props.children}
</trpc.Provider>
</QueryClientProvider>
);
}
Now use it in a component:
typescriptfunction App() {
const greeting = trpc.greeting.useQuery({ name: "Alice" });
return (
<div>
{greeting.isLoading && <p>Loading...</p>}
{greeting.error && <p>Error: {greeting.error.message}</p>}
{greeting.data && <p>{greeting.data}</p>}
</div>
);
}
That's the complete flow. Backend procedure → frontend hook. Types flow end-to-end.
Next.js Integration: The Easiest Path
If you're using Next.js, tRPC shines. You can define your router in the same codebase, and tRPC integrates with Next.js API routes automatically.
Create pages/api/trpc/[trpc].ts:
typescriptimport * as trpcNext from "@trpc/server/adapters/next";
import { appRouter } from "../../../server/trpc";
import { createContext } from "../../../server/context";
export default trpcNext.createNextApiHandler({
router: appRouter,
createContext,
});
In your context file (server/context.ts):
typescriptimport { inferAsyncReturnType } from "@trpc/server";
import { CreateNextContextOptions } from "@trpc/server/adapters/next";
export async function createContext(opts?: CreateNextContextOptions) {
return {
userId: opts?.req?.headers["x-user-id"] || null,
};
}
export type Context = inferAsyncReturnType<typeof createContext>;
Then in your Next.js app:
typescriptimport { trpc } from "../client/trpc";
export default function Home() {
const greeting = trpc.greeting.useQuery({ name: "Alice" });
return <div>{greeting.data}</div>;
}
Next.js handles the /api/trpc endpoint. tRPC handles routing. Everything is typed end-to-end. No separate API route files, no manual JSON parsing, no type mismatch bugs.


Estimated data shows that by 2025, tRPC will focus heavily on improving tooling, performance, and edge runtime support.
Advanced Patterns: Authentication, Error Handling, and Real-World Complexity
Building Authentication on tRPC
Authentication is where tRPC really shines. Instead of checking req.headers.authorization in every route, you build it once in middleware.
typescriptimport { TRPCError } from "@trpc/server";
import { verifyToken } from "./auth";
const t = initTRPC
.context<typeof createContext>()
.create();
const isAuthenticated = t.middleware(async ({ ctx, next }) => {
if (!ctx.token) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
const user = await verifyToken(ctx.token);
if (!user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
...ctx,
user, // Now guaranteed to exist
userId: user.id,
},
});
});
export const protectedProcedure = t.procedure.use(isAuthenticated);
Now any procedure using protectedProcedure automatically has auth. On the frontend, errors are typed:
typescriptconst profile = trpc.user.profile.useQuery(undefined, {
onError: (err) => {
if (err.data?.code === "UNAUTHORIZED") {
// Redirect to login
navigate("/login");
}
},
});
You could extend this with role-based access control:
typescriptconst isAdmin = t.middleware(({ ctx, next }) => {
if (ctx.user.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Admin access required",
});
}
return next();
});
export const adminProcedure = protectedProcedure.use(isAdmin);
Standardized Error Handling
With REST APIs, error handling is chaotic. Some endpoints return 400, some return 200 with an error field, some return 500 for everything. tRPC standardizes this.
typescriptexport const router = t.router({
user: t.router({
getById: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input }) => {
const user = await db.user.findUnique({
where: { id: input.id },
});
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: `User with ID ${input.id} not found`,
});
}
return user;
}),
create: publicProcedure
.input(z.object({ email: z.string().email() }))
.mutation(async ({ input }) => {
const existing = await db.user.findUnique({
where: { email: input.email },
});
if (existing) {
throw new TRPCError({
code: "CONFLICT",
message: "Email already registered",
});
}
return await db.user.create({ data: input });
}),
}),
});
On the frontend, all errors have the same shape:
typescriptconst createUser = trpc.user.create.useMutation({
onError: (err) => {
switch (err.data?.code) {
case "CONFLICT":
console.log("Email already exists");
break;
case "UNAUTHORIZED":
console.log("Not authenticated");
break;
case "BAD_REQUEST":
console.log("Invalid input", err.data.zodError);
break;
default:
console.log("Unknown error");
}
},
});
This consistency eliminates massive swaths of error-handling code on the frontend.
Complex Data Loading and N+1 Prevention
One mistake developers make with tRPC is fetching related data naively, leading to N+1 queries.
Bad approach:
typescriptconst getPosts = publicProcedure.query(async () => {
const posts = await db.post.findMany();
// ❌ N+1: For each post, we fetch the author
return Promise.all(
posts.map(async (post) => ({
...post,
author: await db.user.findUnique({
where: { id: post.authorId },
}),
}))
);
});
Good approach:
typescriptconst getPosts = publicProcedure.query(async () => {
// ✅ Single query with eager loading
return await db.post.findMany({
include: { author: true },
});
});
If you're using Prisma, the pattern is consistent:
typescriptconst getPostsWithComments = publicProcedure.query(async () => {
return await db.post.findMany({
include: {
author: true,
comments: {
include: { author: true },
},
},
});
});
One query, no N+1, full type safety.
Handling File Uploads in tRPC
File uploads are weird in tRPC because HTTP/JSON doesn't natively support binary data. Common pattern is to use pre-signed URLs:
typescriptimport { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({});
const getUploadUrl = protectedProcedure
.input(z.object({ filename: z.string(), contentType: z.string() }))
.mutation(async ({ input, ctx }) => {
const key = `uploads/${ctx.userId}/${Date.now()}-${input.filename}`;
const command = new PutObjectCommand({
Bucket: process.env.AWS_BUCKET_NAME,
Key: key,
ContentType: input.contentType,
});
const uploadUrl = await getSignedUrl(s3, command, { expiresIn: 3600 });
return { uploadUrl, key };
});
On the frontend:
typescriptconst { uploadUrl, key } = await trpc.upload.getUrl.mutate({
filename: "avatar.jpg",
contentType: "image/jpeg",
});
await fetch(uploadUrl, {
method: "PUT",
body: file,
headers: { "Content-Type": "image/jpeg" },
});
// Now confirm the upload with the backend
await trpc.upload.confirm.mutate({ key });
This avoids uploading huge files through your Node server, keeping request handling fast.

Deploying tRPC: From Development to Production
Local Development Setup
During development, you'll want hot reloading on both frontend and backend. The pattern depends on your framework.
Next.js (easiest path):
bashnpm run dev
Next.js handles both frontend and backend. tRPC routes work immediately at /api/trpc/[trpc].
Separate Frontend/Backend:
Start the backend:
bashnode --watch server/index.ts
Start the frontend (Vite example):
bashvite
Update your client to point to http://localhost:3000 (your backend URL).
Deploying to Vercel
If you're using Next.js with tRPC, Vercel deployment is one command:
bashvercel deploy
Vercel automatically deploys your API routes (pages/api/) as serverless functions. Your tRPC procedures become serverless endpoints. Because tRPC batches requests, even function cold starts don't hurt too much.
Deploying to Railway, Render, or Fly.io
If you have a separate backend server, you'd deploy it like any Node app:
dockerfileFROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm install --production COPY . . EXPOSE 3000 CMD ["node", "server/index.ts"]
Then:
bashrailway deploy
Or use Fly.io:
bashfly deploy
Both handle scaling automatically. Your frontend can live on Vercel, backend on Fly.io, and tRPC handles the plumbing.
Environment Variables and Configuration
Stay paranoid about secrets. Use environment variables:
typescript// server/env.ts
import { z } from "zod";
const envSchema = z.object({
DATABASE_URL: z.string(),
JWT_SECRET: z.string(),
NODE_ENV: z.enum(["development", "production"]),
});
export const env = envSchema.parse(process.env);
Then import env instead of process.env:
typescriptimport { env } from "./env";
const client = new PrismaClient({
datasourceUrl: env.DATABASE_URL,
});
For the frontend, use process.env.REACT_APP_* or Vite's .env pattern:
bash# .env.local
VITE_API_URL=http://localhost:3000
typescriptconst trpcClient = trpc.createClient({
links: [
httpBatchLink({
url: import.meta.env.VITE_API_URL,
}),
],
});


Setting up a tRPC application can be completed in approximately 70 minutes, with backend setup taking around 30 minutes and frontend setup taking about 40 minutes. Estimated data.
Testing tRPC Applications
Unit Testing Procedures
Procedures are just functions. Testing them is straightforward:
typescriptimport { describe, it, expect } from "vitest";
import { appRouter } from "../server/trpc";
describe("user router", () => {
it("should return user by id", async () => {
const caller = appRouter.createCaller({
userId: null,
// mock db, etc.
});
const user = await caller.user.getById({
id: "user_123",
});
expect(user).toEqual({
id: "user_123",
email: "alice@example.com",
});
});
it("should throw NOT_FOUND for missing user", async () => {
const caller = appRouter.createCaller({
userId: null,
});
expect(() => caller.user.getById({ id: "nonexistent" })).rejects
.toThrow("NOT_FOUND");
});
});
You call procedures directly using createCaller(). No HTTP layer, no mocking fetch.
Integration Testing with Vitest
For integration tests, you might spin up a test database:
typescriptimport { beforeAll, afterAll, it, expect } from "vitest";
import { PrismaClient } from "@prisma/client";
import { appRouter } from "../server/trpc";
let db: PrismaClient;
let caller: ReturnType<typeof appRouter.createCaller>;
beforeAll(async () => {
db = new PrismaClient();
await db.$executeRawUnsafe("CREATE DATABASE test_db;");
caller = appRouter.createCaller({ db, userId: null });
});
afterAll(async () => {
await db.$executeRawUnsafe("DROP DATABASE test_db;");
await db.$disconnect();
});
it("should create and retrieve a post", async () => {
const post = await caller.post.create({
title: "Test Post",
content: "Test content",
});
const retrieved = await caller.post.getById({ id: post.id });
expect(retrieved.title).toBe("Test Post");
});
End-to-End Testing
For E2E tests, spin up the full server and test with the actual HTTP layer:
typescriptimport { expect, test } from "@playwright/test";
test("user can create and view a post", async ({ page, context }) => {
await page.goto("http://localhost:3000");
await page.fill('[data-testid="title"]', "My First Post");
await page.fill('[data-testid="content"]', "This is content");
await page.click('[data-testid="create-btn"]');
await expect(page.locator("text=My First Post")).toBeVisible();
});
Playwright tests your actual UI interacting with your actual tRPC API. This catches integration bugs.

Performance Optimization: Scaling tRPC
Request Batching and Deduplication
One of tRPC's killer features is automatic request batching. If you render multiple components that call different procedures:
typescript// Component A
const postsA = trpc.post.list.useQuery();
// Component B
const postsB = trpc.post.list.useQuery();
// Component C
const userProfile = trpc.user.profile.useQuery();
Without deduplication, that's three HTTP requests. With tRPC's built-in deduplication:
- Request 1: Batches all three procedures into one HTTP POST
- Request 2: For the duplicate
post.listcall, tRPC reuses the cache
You get one network round trip and shared cache. This reduces typical JSON payloads by 60-80%.
Caching Strategies
tRPC uses React Query under the hood (or TanStack Query on the frontend). You control caching via options:
typescriptconst posts = trpc.post.list.useQuery(undefined, {
staleTime: 60 * 1000, // Data fresh for 60 seconds
gcTime: 5 * 60 * 1000, // Keep in cache for 5 minutes
});
For expensive queries, increase staleTime:
typescriptconst analytics = trpc.analytics.dashboard.useQuery(undefined, {
staleTime: 10 * 60 * 1000, // Fresh for 10 minutes
});
For real-time data, decrease it:
typescriptconst liveOrders = trpc.order.active.useQuery(undefined, {
staleTime: 5 * 1000, // Fresh every 5 seconds
});
Database Query Optimization
Your database is almost always the bottleneck. Optimize your queries first:
typescript// ❌ Bad: Fetches all fields
const posts = await db.post.findMany();
// ✅ Good: Fetch only needed fields
const posts = await db.post.findMany({
select: {
id: true,
title: true,
createdAt: true,
},
});
// ✅ Better: Add pagination
const posts = await db.post.findMany({
select: { id: true, title: true, createdAt: true },
take: 20,
skip: (page - 1) * 20,
orderBy: { createdAt: "desc" },
});
With Prisma, use select to avoid over-fetching, and always paginate large result sets.
Monitoring and Observability
You can't optimize what you can't see. Add logging to your tRPC procedures:
typescriptconst t = initTRPC.context<typeof createContext>().create();
const loggerMiddleware = t.middleware(async ({ path, type, next }) => {
const start = Date.now();
const result = await next();
const duration = Date.now() - start;
console.log({
path,
type,
duration,
timestamp: new Date().toISOString(),
});
return result;
});
export const publicProcedure = t.procedure.use(loggerMiddleware);
Pipe this to a service like Datadog or New Relic:
typescriptconst loggerMiddleware = t.middleware(async ({ path, type, next }) => {
const start = Date.now();
const result = await next();
const duration = Date.now() - start;
sendMetric({
name: `trpc.${path}.${type}`,
value: duration,
});
return result;
});
Now you see which procedures are slow, which are called most often, and which have errors.


Vercel is estimated to be the most popular platform for deploying tRPC applications, particularly when using Next.js, due to its seamless integration and ease of use. (Estimated data)
Real-World Example: Building a Todo App
The Complete Application
Let's build a real app: a collaborative todo list. Backend first:
typescript// server/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import { z } from "zod";
import { db } from "./db";
import { verifyToken } from "./auth";
const t = initTRPC
.context<{ userId?: string }>()
.create();
const isAuth = t.middleware(async ({ ctx, next }) => {
if (!ctx.userId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next();
});
const protectedProcedure = t.procedure.use(isAuth);
export const appRouter = t.router({
todo: t.router({
list: protectedProcedure.query(async ({ ctx }) => {
return await db.todo.findMany({
where: { userId: ctx.userId },
orderBy: { createdAt: "desc" },
});
}),
create: protectedProcedure
.input(z.object({ title: z.string().min(1) }))
.mutation(async ({ input, ctx }) => {
return await db.todo.create({
data: {
title: input.title,
userId: ctx.userId,
},
});
}),
toggle: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input, ctx }) => {
const todo = await db.todo.findUnique({
where: { id: input.id },
});
if (!todo || todo.userId !== ctx.userId) {
throw new TRPCError({ code: "NOT_FOUND" });
}
return await db.todo.update({
where: { id: input.id },
data: { completed: !todo.completed },
});
}),
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input, ctx }) => {
const todo = await db.todo.findUnique({
where: { id: input.id },
});
if (!todo || todo.userId !== ctx.userId) {
throw new TRPCError({ code: "FORBIDDEN" });
}
await db.todo.delete({ where: { id: input.id } });
}),
}),
});
export type AppRouter = typeof appRouter;
Now the frontend:
typescript// app.tsx
import { trpc } from "./trpc";
import { useState } from "react";
export function TodoApp() {
const [newTodoTitle, setNewTodoTitle] = useState("");
const todos = trpc.todo.list.useQuery();
const createTodo = trpc.todo.create.useMutation({
onSuccess: () => {
todos.refetch();
setNewTodoTitle("");
},
});
const toggleTodo = trpc.todo.toggle.useMutation({
onSuccess: () => todos.refetch(),
});
const deleteTodo = trpc.todo.delete.useMutation({
onSuccess: () => todos.refetch(),
});
const handleCreate = async () => {
if (!newTodoTitle.trim()) return;
await createTodo.mutateAsync({ title: newTodoTitle });
};
if (todos.isLoading) return <p>Loading...</p>;
return (
<div>
<h1>My Todos</h1>
<input
value={newTodoTitle}
onChange={(e) => setNewTodoTitle(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
placeholder="Add a new todo..."
/>
<button onClick={handleCreate}>Add</button>
<ul>
{todos.data?.map((todo) => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo.mutate({ id: todo.id })}
/>
{todo.title}
<button onClick={() => deleteTodo.mutate({ id: todo.id })}>
Delete
</button>
</li>
))}
</ul>
</div>
);
}
That's a complete full-stack app. Notice:
- Backend types defined in TypeScript
- Frontend automatically knows the types
- No separate API documentation
- Mutations trigger automatic refetch
- Error handling is built-in
Adding Real-Time Updates with WebSockets
For collaborative features, add WebSocket support:
typescriptimport { WebSocketServer } from "ws";
import { observable } from "@trpc/server/observable";
export const appRouter = t.router({
todo: t.router({
onUpdate: protectedProcedure.subscription(() => {
return observable<Todo>((emit) => {
const handler = (todo: Todo) => emit.next(todo);
events.on("todo:updated", handler);
return () => events.off("todo:updated", handler);
});
}),
}),
});
On the frontend:
typescriptconst { data: updatedTodo } = trpc.todo.onUpdate.useSubscription(undefined, {
onData: (todo) => {
console.log("Todo updated:", todo);
todos.refetch();
},
});
Now when someone updates a todo, everyone sees it in real-time.

Ecosystem and Community Tools
tRPC-Related Libraries
The tRPC ecosystem is growing. Key integrations include:
OpenAPI/Swagger Generation: Automatically generate OpenAPI specs from your tRPC router:
typescriptimport { generateOpenApiDocument } from "trpc-openapi";
const openApiDoc = generateOpenApiDocument({
router: appRouter,
title: "Todo API",
version: "1.0.0",
baseUrl: "http://localhost:3000",
});
This is useful for documentation, client generation, and integration with third-party tools.
Database Integrations: Libraries exist for connecting tRPC to various databases. Prisma is the most popular, but you can use raw SQL, MongoDB, etc.
Form Validation: Libraries like react-hook-form + zod integrate cleanly with tRPC:
typescriptconst form = useForm<z.infer<typeof createTodoInput>>({
resolver: zodResolver(createTodoInput),
});
const onSubmit = (data) => {
createTodo.mutate(data); // TypeScript ensures data matches expected input
};
Community Packages
Check out awesome-trpc on GitHub for community packages. Popular ones include rate limiting, file upload helpers, and admin panels.


Estimated data shows that listing todos is the most common action, followed by creating, toggling, and deleting todos.
Common Pitfalls and How to Avoid Them
Mistake 1: Exposing Sensitive Data
Because tRPC is so permissive about what you return, it's easy to accidentally expose sensitive data:
typescript// ❌ Bad: Returns user password hash
const getUser = publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return await db.user.findUnique({
where: { id: input.id },
});
});
// ✅ Good: Selects specific fields
const getUser = publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return await db.user.findUnique({
where: { id: input.id },
select: {
id: true,
email: true,
name: true,
// Explicitly exclude passwordHash, apiKey, etc.
},
});
});
Mistake 2: Missing Input Validation
tRPC makes validation easy but it's easy to skip:
typescript// ❌ Bad: No validation
const createPost = publicProcedure
.mutation(async ({ input }: { input: any }) => {
return await db.post.create({ data: input });
});
// ✅ Good: Validation schema
const createPost = publicProcedure
.input(z.object({
title: z.string().min(5).max(200),
content: z.string().min(10),
published: z.boolean().optional(),
}))
.mutation(async ({ input }) => {
return await db.post.create({ data: input });
});
Mistake 3: Ignoring Error Codes
Using generic error messages on the frontend makes debugging hard:
typescript// ❌ Bad: Generic message
if (error) {
alert("Something went wrong");
}
// ✅ Good: Handle specific codes
if (error?.data?.code === "UNAUTHORIZED") {
navigate("/login");
} else if (error?.data?.code === "BAD_REQUEST") {
showValidationErrors(error.data.zodError);
} else {
alert("Unexpected error: " + error?.message);
}
Mistake 4: Not Using Middleware for Cross-Cutting Concerns
Duplicated code across procedures is a sign you need middleware:
typescript// ❌ Bad: Repeating auth check in every procedure
const deletePost = publicProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input, ctx }) => {
if (!ctx.userId) throw new Error("Unauthorized");
const post = await db.post.findUnique({ where: { id: input.id } });
if (post.authorId !== ctx.userId) throw new Error("Forbidden");
await db.post.delete({ where: { id: input.id } });
});
// ✅ Good: Use middleware
const deletePost = protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input, ctx }) => {
const post = await db.post.findUnique({ where: { id: input.id } });
if (post.authorId !== ctx.userId) {
throw new TRPCError({ code: "FORBIDDEN" });
}
await db.post.delete({ where: { id: input.id } });
});

Future of tRPC and TypeScript APIs
Where tRPC is Heading
As of 2025, tRPC is moving toward:
1. Better tooling and DevX: More plugins, better error messages, improved debugging.
2. Edge runtime support: Running tRPC procedures on edge networks (Cloudflare Workers, Deno Deploy) for ultra-low latency.
3. WebSocket improvements: Better real-time support out of the box without needing subscription setup boilerplate.
4. Performance enhancements: Streaming responses, incremental data fetching, and server component integration in Next.js.
Competing Technologies
Other frameworks are emerging in this space:
Blitz.js: A full-stack framework built on Next.js that predates tRPC but similar philosophy.
Nuxt's server routes: Vue/Nuxt's take on full-stack TypeScript.
Fresh (Deno): Deno's full-stack framework with island architecture.
Hono: A lightweight web framework gaining traction for edge deployment.
But tRPC remains the most TypeScript-native, framework-agnostic option. It's language binding that lets you use your favorite frameworks on both ends.
Integration with AI and Automation
In 2025, developers are using tRPC with Runable and similar AI automation platforms to generate boilerplate code. You describe your data model, and AI generates your tRPC routers and Zod schemas. This is reducing setup time from hours to minutes.
Use Case: Generate tRPC routers and Zod validation schemas from your Prisma schema automatically, reducing boilerplate time by 80%.
Try Runable For Free
FAQ
What is tRPC and how is it different from REST?
tRPC is a framework for building end-to-end type-safe APIs in TypeScript. Unlike REST, which uses HTTP semantics and separate schema definitions, tRPC treats backend procedures like local TypeScript functions that run over the network. Types flow automatically from server to client with zero code generation, giving you type safety without the boilerplate of REST, GraphQL, or OpenAPI.
Do I need to use Next.js to use tRPC?
No. tRPC works with any JavaScript framework on the frontend (React, Vue, Svelte) and any Node.js backend framework. Next.js integration is just particularly smooth because Next.js API routes handle the HTTP layer automatically. You can run a standalone tRPC server on Express, Fastify, or any Node server.
How does tRPC handle real-time updates?
tRPC supports WebSocket subscriptions. You define subscription procedures that emit data over time. Clients connect via WebSocket and receive updates as they happen. This is useful for collaborative features like live notifications, real-time dashboards, or shared editing.
Is tRPC suitable for public APIs?
tRPC is primarily designed for full-stack applications where frontend and backend are both TypeScript. For public APIs serving multiple languages and platforms, REST or GraphQL are better choices because they're language-agnostic. You can generate OpenAPI specs from tRPC to serve public clients, but that loses the type safety benefit.
How do I handle authentication in tRPC?
Use middleware. Create an auth middleware that checks the request context (usually extracted from cookies or headers), verifies the user, and either passes the user info to the procedure or throws an UNAUTHORIZED error. Then use protectedProcedure for any route requiring authentication.
What's the performance impact of tRPC?
tRPC has minimal overhead. It's just a thin wrapper around HTTP. The httpBatchLink automatically batches requests to a single HTTP call, actually reducing network traffic. Database and business logic are the bottleneck, not tRPC. Proper indexing and query optimization matter far more than choosing tRPC vs REST.
Can I use tRPC with my existing REST API?
Yes. You can run tRPC alongside a REST API. Some teams migrate incrementally, starting with new features in tRPC while keeping legacy REST endpoints. You can even call REST endpoints from tRPC procedures if needed.
How do I test tRPC applications?
tRPC procedures are just functions. You can unit test them directly by calling them with createCaller(). For integration tests, use a test database. For E2E tests, test your actual UI interacting with your actual server. No special testing library needed beyond what you'd use for any Node/React code.

Conclusion
In 2025, the TypeScript ecosystem has matured to the point where tRPC isn't a nice-to-have, it's table stakes for full-stack applications. The idea of maintaining separate type definitions on frontend and backend feels ancient. Type safety across the wire is now expected.
What makes tRPC special is how unglamorous it is. It's not solving a problem you didn't know you had. It's solving a problem you've felt every time you updated a backend response and forgot to update the frontend types. It's solving the problem the moment you realized your React component is accessing a property that no longer exists on the API response.
The friction cost of that mismatch, across a team over a year, is enormous. Hours lost to debugging. Features shipped with bugs. tRPC eliminates that entire category of errors.
Start small. Add tRPC to your next project. Wire up a simple query, see the types flow to your frontend. You'll understand why it's becoming the default choice for TypeScript developers.
If you're building a modern full-stack application in TypeScript, tRPC should be in your toolkit. Period.

Quick Navigation
- Understanding tRPC
- Getting Started
- Advanced Patterns
- Deployment Guide
- Performance
- Real-World Example

Key Takeaways
- tRPC gives end-to-end type safety for TypeScript applications without code generation or separate schema definitions.
- Types flow automatically from backend procedures to frontend client, eliminating the gap that causes bugs.
- Works seamlessly with Next.js, React, Remix, and other modern frameworks.
- Built-in error handling, middleware support, and request batching reduce boilerplate by 60-80%.
- Perfect for full-stack TypeScript teams; REST/GraphQL better for multi-language environments.



