Ask Runable forDesign-Driven General AI AgentTry Runable For Free
Runable
Back to Blog
Technology & Development30 min read

tRPC Guide: Building Type-Safe APIs in 2025

Master tRPC for end-to-end type safety in TypeScript applications. Learn how to build, deploy, and scale full-stack applications with zero runtime validation...

tRPCTypeScriptAPI developmentfull-stack developmentend-to-end type safety+10 more
tRPC Guide: Building Type-Safe APIs in 2025
Listen to Article
0:00
0:00
0:00

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.

TL; DR - visual representation
TL; DR - visual representation

Impact of tRPC Request Batching and Caching
Impact of tRPC Request Batching and Caching

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:

typescript
interface 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:

graphql
type 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:

typescript
export 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:

typescript
const 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.

QUICK TIP: If you're using TypeScript on both frontend and backend, tRPC eliminates the entire "maintaining separate type definitions" problem. That alone saves hours of debugging time per quarter.

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:

typescript
const 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:

typescript
type 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.

DID YOU KNOW: tRPC was inspired by the realization that if both your backend and frontend are TypeScript, you're essentially doing the type work twice. By 2025, tRPC has been adopted by over 50,000 developers monthly, with enterprise adoption growing by 40% year-over-year.

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.


What Is tRPC and Why It's Different - visual representation
What Is tRPC and Why It's Different - visual representation

Comparison of API Approaches: REST, GraphQL, and tRPC
Comparison of API Approaches: REST, GraphQL, and tRPC

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:

typescript
const 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:

typescript
const 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:

typescript
try {
  await trpc.post.create.mutate({ title: "x" });
} catch (error) {
  if (error.data?.code === "BAD_REQUEST") {
    // Handle validation error
    console.log(error.data.zodError);
  }
}
QUICK TIP: Use Zod schemas aggressively. They're cheap, they document your inputs, and they prevent entire categories of bugs. For every input type, write a schema. It takes 30 seconds and saves hours of debugging.

Building Routers for Organization

As your application grows, putting all procedures in one file becomes unmaintainable. tRPC routers let you organize procedures hierarchically.

typescript
const 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:

typescript
const 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:

typescript
const 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:

typescript
const 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:

typescript
const 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 in tRPC: Code that runs before your procedure handler. Use it for authentication, authorization, logging, rate limiting, and request tracking. Middleware can modify the context or throw errors to stop execution.

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.


Core Concepts: Routers, Procedures, and Middleware - visual representation
Core Concepts: Routers, Procedures, and Middleware - visual representation

Setting Up Your First tRPC Application

Step-by-Step: Backend Setup

Let's build a minimal tRPC server from scratch. First, install dependencies:

bash
npm install @trpc/server zod

Create your first router (server/trpc.ts):

typescript
import { 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):

typescript
import { 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:

bash
node 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:

bash
npm install @trpc/client @trpc/react-query

Create a client (client/trpc.ts):

typescript
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "../server/trpc";

export const trpc = createTRPCReact<AppRouter>();

Set up the HTTP link:

typescript
import { 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:

typescript
function 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.

DID YOU KNOW: The tRPC httpBatchLink automatically batches requests. If you call multiple procedures within the same React render, tRPC makes a single HTTP request with all of them. This reduces network round trips by up to 80% in typical applications.

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:

typescript
import * 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):

typescript
import { 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:

typescript
import { 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.


Setting Up Your First tRPC Application - visual representation
Setting Up Your First tRPC Application - visual representation

Future Directions of tRPC by 2025
Future Directions of tRPC by 2025

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.

typescript
import { 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:

typescript
const 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:

typescript
const 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.

typescript
export 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:

typescript
const 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.

QUICK TIP: Use specific error codes (NOT_FOUND, CONFLICT, FORBIDDEN, etc.). On the frontend, switch on error codes rather than HTTP status codes. Your error handling becomes semantic and maintainable.

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:

typescript
const 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:

typescript
const 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:

typescript
const 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:

typescript
import { 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:

typescript
const { 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.


Advanced Patterns: Authentication, Error Handling, and Real-World Complexity - visual representation
Advanced Patterns: Authentication, Error Handling, and Real-World Complexity - visual representation

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):

bash
npm run dev

Next.js handles both frontend and backend. tRPC routes work immediately at /api/trpc/[trpc].

Separate Frontend/Backend:

Start the backend:

bash
node --watch server/index.ts

Start the frontend (Vite example):

bash
vite

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:

bash
vercel 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:

dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
EXPOSE 3000
CMD ["node", "server/index.ts"]

Then:

bash
railway deploy

Or use Fly.io:

bash
fly 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:

typescript
import { 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
typescript
const trpcClient = trpc.createClient({
  links: [
    httpBatchLink({
      url: import.meta.env.VITE_API_URL,
    }),
  ],
});
DID YOU KNOW: Over 30% of security breaches involve exposed API keys. tRPC doesn't prevent this, but putting environment variable validation in TypeScript catches configuration mistakes before deployment. Many teams have prevented incidents by catching missing DATABASE_URL before code reaches production.

Deploying tRPC: From Development to Production - visual representation
Deploying tRPC: From Development to Production - visual representation

Estimated Time to Set Up tRPC Application
Estimated Time to Set Up tRPC Application

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:

typescript
import { 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:

typescript
import { 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:

typescript
import { 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.


Testing tRPC Applications - visual representation
Testing tRPC Applications - visual representation

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.list call, 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:

typescript
const 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:

typescript
const analytics = trpc.analytics.dashboard.useQuery(undefined, {
  staleTime: 10 * 60 * 1000, // Fresh for 10 minutes
});

For real-time data, decrease it:

typescript
const 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.

QUICK TIP: Add query timeouts to prevent slow procedures from hanging users. Set a 10-second timeout on all mutations, 30 seconds on heavy queries. This forces you to optimize slow queries instead of letting them linger.

Monitoring and Observability

You can't optimize what you can't see. Add logging to your tRPC procedures:

typescript
const 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:

typescript
const 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.


Performance Optimization: Scaling tRPC - visual representation
Performance Optimization: Scaling tRPC - visual representation

Popularity of Deployment Platforms for tRPC
Popularity of Deployment Platforms for tRPC

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:

  1. Backend types defined in TypeScript
  2. Frontend automatically knows the types
  3. No separate API documentation
  4. Mutations trigger automatic refetch
  5. Error handling is built-in

Adding Real-Time Updates with WebSockets

For collaborative features, add WebSocket support:

typescript
import { 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:

typescript
const { 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.


Real-World Example: Building a Todo App - visual representation
Real-World Example: Building a Todo App - visual representation

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:

typescript
import { 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:

typescript
const 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.


Ecosystem and Community Tools - visual representation
Ecosystem and Community Tools - visual representation

Typical Usage Distribution in a Todo App
Typical Usage Distribution in a Todo App

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 } });
  });

Common Pitfalls and How to Avoid Them - visual representation
Common Pitfalls and How to Avoid Them - visual representation

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

Future of tRPC and TypeScript APIs - visual representation
Future of tRPC and TypeScript APIs - visual representation

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.


FAQ - visual representation
FAQ - visual representation

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.


Conclusion - visual representation
Conclusion - visual representation

Quick Navigation

Quick Navigation - visual representation
Quick Navigation - visual representation


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.

Related Articles

Cut Costs with Runable

Cost savings are based on average monthly price per user for each app.

Which apps do you use?

Apps to replace

ChatGPTChatGPT
$20 / month
LovableLovable
$25 / month
Gamma AIGamma AI
$25 / month
HiggsFieldHiggsField
$49 / month
Leonardo AILeonardo AI
$12 / month
TOTAL$131 / month

Runable price = $9 / month

Saves $122 / month

Runable can save upto $1464 per year compared to the non-enterprise price of your apps.