Server router() procedure() Client useQuery() useMutation() Types tRPC End-to-End Type Safety
Backend

tRPC: End-to-End Type-Safe APIs

Mayur Dabhi
Mayur Dabhi
June 16, 2026
14 min read

Traditional API development creates a dangerous gap between server and client code. You build a REST endpoint that returns a user object, but your frontend has no idea what shape that object will have at compile time. Rename user.name to user.fullName on the server and TypeScript won't warn you — you'll only discover the bug when users hit that broken component in production. tRPC eliminates this problem entirely by letting TypeScript types flow seamlessly from your server to your client, with no code generation, no schemas, and no extra tooling. What you define on the server is exactly what the client sees — instantly, automatically, and with full editor autocomplete.

Why tRPC is Taking Over TypeScript Stacks

tRPC has over 35,000 GitHub stars and is the backbone of create-t3-app — the community's most popular full-stack TypeScript starter. Companies like Cal.com, Ping.gg, and many others have adopted it because it eliminates an entire category of frontend/backend contract bugs with zero runtime overhead.

What is tRPC?

tRPC stands for TypeScript Remote Procedure Call. It's a library that lets you invoke server-side functions from the client as if they were local functions, with full end-to-end TypeScript type inference. Unlike REST — which requires you to manually maintain type definitions for every endpoint — or GraphQL — which needs a schema language and a code generation step — tRPC uses TypeScript's own type system to propagate types automatically from server to client.

The core idea is simple: you define procedures (functions) on the server inside a router. The router's TypeScript type is exported and shared with the client. The client uses that type to get full autocompletion and type-checking with zero extra work. There is no JSON Schema, no OpenAPI spec, no GraphQL SDL — just TypeScript.

Server Router appRouter.ts procedure definitions Zod input schemas Type Inference AppRouter type exported shared to client zero runtime cost Client Hooks trpc.users.getAll .useQuery() full autocomplete typeof inferred HTTP transport (JSON over fetch) — invisible to developer

tRPC's type safety flows from server definition to client without code generation

Setting Up Your tRPC Project

tRPC works best in a monorepo where both server and client share the same TypeScript project. The most common setup is Next.js, where API routes and frontend components live in the same codebase.

1

Create a Next.js TypeScript project

Start with a fresh Next.js app, or use create-t3-app for an opinionated full setup with tRPC, Prisma, and NextAuth pre-configured.

Terminal
# Option 1: Plain Next.js
npx create-next-app@latest my-trpc-app --typescript --app

# Option 2: create-t3-app (recommended — includes tRPC + Prisma + Auth)
npm create t3-app@latest my-t3-app
2

Install tRPC and dependencies

Install the core tRPC packages, React Query adapter, and Zod for input validation.

Terminal
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next
npm install @tanstack/react-query zod
npm install @tanstack/react-query-devtools --save-dev
3

Initialize tRPC on the server

Create a central trpc.ts file that exports your procedure builders and router factory.

4

Create a client-side tRPC instance

Export a typed React client that mirrors your server's router type, giving you full autocomplete in components.

Building Your First Router

The server setup involves two files: a trpc.ts initializer that creates your procedure builders, and one or more router files that define your procedures. This separation keeps your code clean as the API grows.

server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { type Context } from './context';

// Initialize tRPC with your context type
const t = initTRPC.context<Context>().create();

// Export reusable router and procedure builders
export const router = t.router;
export const publicProcedure = t.procedure;
export const middleware = t.middleware;

// Reusable auth middleware
const isAuthed = middleware(({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({
      code: 'UNAUTHORIZED',
      message: 'You must be signed in to perform this action',
    });
  }
  return next({
    ctx: {
      ...ctx,
      // Narrows user type to non-null after this middleware
      user: ctx.session.user,
    },
  });
});

// Protected procedure — requires authentication
export const protectedProcedure = t.procedure.use(isAuthed);

Now define your routers. tRPC encourages splitting your API into focused sub-routers (users, posts, etc.) that are merged into one root router:

server/routers/user.ts
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';
import { db } from '../db';

export const userRouter = router({
  // Query: fetch all users (public)
  getAll: publicProcedure.query(async () => {
    return db.user.findMany({
      select: { id: true, name: true, email: true, createdAt: true },
    });
  }),

  // Query: fetch one user by ID (public)
  getById: publicProcedure
    .input(z.object({ id: z.string().cuid() }))
    .query(async ({ input }) => {
      const user = await db.user.findUnique({ where: { id: input.id } });
      if (!user) throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' });
      return user;
    }),

  // Mutation: update profile (protected)
  updateProfile: protectedProcedure
    .input(z.object({
      name: z.string().min(1).max(100),
      bio: z.string().max(500).optional(),
    }))
    .mutation(async ({ input, ctx }) => {
      return db.user.update({
        where: { id: ctx.user.id },
        data: input,
      });
    }),
});
server/routers/app.ts
import { router } from '../trpc';
import { userRouter } from './user';
import { postRouter } from './post';

// Root router — merge all sub-routers
export const appRouter = router({
  users: userRouter,
  posts: postRouter,
});

// Export the type — this is what the client uses for inference
export type AppRouter = typeof appRouter;
The Key Insight

export type AppRouter = typeof appRouter — this single line is why tRPC works. The client imports this type (not the implementation), and TypeScript infers every procedure's input and output types automatically. No runtime cost, no sync required.

Queries, Mutations, and Subscriptions

tRPC has three procedure types that map cleanly to common API patterns. Understanding when to use each one makes your API intuitive to consume.

Queries fetch data and are called with .useQuery() on the client. They are cached by React Query and re-fetched automatically when the cache is stale.

// Server — define the query
getAll: publicProcedure.query(async () => {
  return db.post.findMany({ orderBy: { createdAt: 'desc' } });
}),

// Client — consume the query
function PostList() {
  const { data: posts, isLoading, error } = trpc.posts.getAll.useQuery();

  if (isLoading) return <Spinner />;
  if (error) return <Error message={error.message} />;

  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

Mutations modify data and are called with .useMutation(). They do not run automatically — you call mutate() or mutateAsync() imperatively.

// Server — define the mutation
create: protectedProcedure
  .input(z.object({
    title: z.string().min(1).max(200),
    content: z.string().min(10),
    published: z.boolean().default(false),
  }))
  .mutation(async ({ input, ctx }) => {
    return db.post.create({
      data: { ...input, authorId: ctx.user.id },
    });
  }),

// Client — consume the mutation
function CreatePost() {
  const utils = trpc.useUtils();
  const createPost = trpc.posts.create.useMutation({
    onSuccess: () => {
      // Invalidate the list cache so it re-fetches
      utils.posts.getAll.invalidate();
    },
  });

  return (
    <button
      onClick={() => createPost.mutate({ title: 'Hello', content: '...' })}
      disabled={createPost.isPending}
    >
      {createPost.isPending ? 'Creating...' : 'Create Post'}
    </button>
  );
}

Subscriptions stream data over WebSockets using .useSubscription(). They require a WebSocket-capable server (like a standalone Node.js server, not Next.js API routes).

// Server — define the subscription (requires WS adapter)
import { observable } from '@trpc/server/observable';

onNewMessage: publicProcedure
  .input(z.object({ roomId: z.string() }))
  .subscription(({ input }) => {
    return observable<Message>(emit => {
      const handler = (message: Message) => {
        if (message.roomId === input.roomId) {
          emit.next(message);
        }
      };
      messageEmitter.on('message', handler);
      // Clean up on unsubscribe
      return () => messageEmitter.off('message', handler);
    });
  }),

// Client — consume the subscription
function ChatRoom({ roomId }: { roomId: string }) {
  const [messages, setMessages] = useState<Message[]>([]);

  trpc.chat.onNewMessage.useSubscription(
    { roomId },
    {
      onData: (message) => {
        setMessages(prev => [...prev, message]);
      },
    }
  );

  return <MessageList messages={messages} />;
}

Input Validation with Zod

tRPC uses Zod for input validation by design. Zod schemas serve two purposes simultaneously: they validate incoming data at runtime (guarding against bad or malicious input) and they provide TypeScript types automatically without any separate type declaration.

Always Validate Inputs

Even though tRPC provides type safety between your own code, clients can always send arbitrary HTTP requests. Zod validation runs on the server at runtime, ensuring that data actually matches the expected shape before your procedure logic executes. Never skip .input() on procedures that accept user data.

Zod Schema Examples
import { z } from 'zod';

// Basic object schema
const createUserInput = z.object({
  name: z.string().min(1, 'Name is required').max(100),
  email: z.string().email('Invalid email address'),
  age: z.number().int().min(13).max(120).optional(),
  role: z.enum(['user', 'admin', 'moderator']).default('user'),
});

// Nested schemas
const createPostInput = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(10),
  tags: z.array(z.string().max(30)).max(10).default([]),
  metadata: z.object({
    slug: z.string().regex(/^[a-z0-9-]+$/),
    published: z.boolean().default(false),
    publishedAt: z.date().optional(),
  }),
});

// Use in a procedure
createPost: protectedProcedure
  .input(createPostInput)
  .mutation(async ({ input }) => {
    // input is fully typed as z.infer<typeof createPostInput>
    // Zod already validated it — safe to use directly
    return db.post.create({ data: input });
  }),

// Reuse Zod types in your components
type CreatePostInput = z.infer<typeof createPostInput>;

function PostForm() {
  const [formData, setFormData] = useState<CreatePostInput>({...});
  // TypeScript ensures form state matches the server's expectation
}

Custom Error Handling

tRPC provides a TRPCError class that maps to HTTP status codes automatically. Use it inside procedures to throw meaningful errors that the client receives with full type information:

Error Handling
import { TRPCError } from '@trpc/server';

getPost: publicProcedure
  .input(z.object({ slug: z.string() }))
  .query(async ({ input }) => {
    const post = await db.post.findUnique({ where: { slug: input.slug } });

    if (!post) {
      throw new TRPCError({
        code: 'NOT_FOUND',         // → 404
        message: `Post "${input.slug}" not found`,
      });
    }

    if (!post.published) {
      throw new TRPCError({
        code: 'FORBIDDEN',         // → 403
        message: 'This post is not published',
      });
    }

    return post;
  }),

// Available codes: BAD_REQUEST, UNAUTHORIZED, FORBIDDEN,
// NOT_FOUND, CONFLICT, TOO_MANY_REQUESTS, INTERNAL_SERVER_ERROR, etc.

Next.js Integration

Connecting tRPC to Next.js requires an API route handler that receives all tRPC calls, plus a client-side provider that wraps your app. The setup is a one-time investment that pays dividends on every procedure you write afterward.

app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '~/server/routers/app';
import { createContext } from '~/server/context';

const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext: () => createContext({ req }),
  });

export { handler as GET, handler as POST };
utils/trpc.ts — client setup
import { createTRPCReact } from '@trpc/react-query';
import { type AppRouter } from '~/server/routers/app';

// This is the typed client — import trpc from this file everywhere
export const trpc = createTRPCReact<AppRouter>();
app/providers.tsx — wrap the app
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { useState } from 'react';
import { trpc } from '~/utils/trpc';

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: '/api/trpc',
          // Optional: add auth headers
          headers: () => ({
            authorization: getAuthToken() ?? '',
          }),
        }),
      ],
    })
  );

  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </trpc.Provider>
  );
}

tRPC vs REST vs GraphQL

Choosing between tRPC, REST, and GraphQL depends primarily on your stack and whether you need a public API. Here's a practical comparison for TypeScript developers:

Feature tRPC REST GraphQL
Type Safety Automatic (TypeScript inference) Manual or via OpenAPI codegen Via schema + codegen (graphql-codegen)
Schema Language None — TypeScript is the schema OpenAPI / JSON Schema (optional) SDL (required)
Code Generation Not needed Optional Usually required for type safety
Public API Support No (TypeScript-only clients) Yes (any language) Yes (any language)
Over-fetching Controlled per procedure Common None (client specifies fields)
Real-time Subscriptions (WebSocket) SSE / WebSocket (manual) Subscriptions (WebSocket)
Learning Curve Low (TypeScript + functions) Very low High (SDL, resolvers, N+1 problem)
Best For TypeScript monorepos Public APIs, polyglot teams Complex data graphs, multiple clients
When NOT to Use tRPC

tRPC requires both client and server to be TypeScript. If you need a public API consumed by mobile apps, third parties, or non-TypeScript clients — use REST or GraphQL instead. tRPC is the right tool when you own both ends of the wire.

Advanced Patterns

Context and Authentication

Context is how you pass request-scoped data — like the current user's session — into your procedures. It's created per-request and injected automatically by tRPC:

server/context.ts
import { type inferAsyncReturnType } from '@trpc/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '~/server/auth';
import { db } from '~/server/db';

export async function createContext(opts: { req: Request }) {
  const session = await getServerSession(authOptions);

  return {
    session,
    user: session?.user ?? null,
    db,
  };
}

// Export the type so tRPC can infer it
export type Context = inferAsyncReturnType<typeof createContext>;

Procedure Chaining and Middleware

Middleware lets you add cross-cutting concerns like logging, rate limiting, or role checks without repeating code in every procedure:

Middleware examples
// Timing middleware — log slow procedures
const timingMiddleware = middleware(async ({ path, type, next }) => {
  const start = Date.now();
  const result = await next();
  const duration = Date.now() - start;

  if (duration > 500) {
    console.warn(`Slow procedure: ${type} ${path} took ${duration}ms`);
  }

  return result;
});

// Role-based access
const isAdmin = middleware(({ ctx, next }) => {
  if (ctx.user?.role !== 'admin') {
    throw new TRPCError({ code: 'FORBIDDEN', message: 'Admins only' });
  }
  return next({ ctx });
});

// Chain middleware on a procedure
export const adminProcedure = t.procedure
  .use(timingMiddleware)
  .use(isAuthed)
  .use(isAdmin);

Optimistic Updates

React Query's optimistic update pattern works seamlessly with tRPC, giving your UI instant feedback before the server confirms the change:

Optimistic update example
function LikeButton({ postId }: { postId: string }) {
  const utils = trpc.useUtils();

  const likeMutation = trpc.posts.like.useMutation({
    // Optimistically update the UI before server responds
    onMutate: async ({ postId }) => {
      await utils.posts.getById.cancel({ id: postId });
      const prev = utils.posts.getById.getData({ id: postId });

      utils.posts.getById.setData({ id: postId }, old =>
        old ? { ...old, likeCount: old.likeCount + 1, liked: true } : old
      );

      return { prev };
    },
    // Roll back on error
    onError: (err, vars, context) => {
      utils.posts.getById.setData({ id: postId }, context?.prev);
    },
    // Always refetch after mutation settles
    onSettled: () => {
      utils.posts.getById.invalidate({ id: postId });
    },
  });

  return (
    <button onClick={() => likeMutation.mutate({ postId })}>
      Like
    </button>
  );
}
Component calls mutate() React Query optimistic update local cache tRPC Server validates input runs mutation Database persists data confirm or rollback optimistic update

Optimistic update flow: UI updates instantly, server confirms asynchronously

Key Takeaways and Conclusion

tRPC represents a fundamental shift in how TypeScript full-stack developers think about APIs. By leveraging TypeScript's type system as the contract between server and client, it eliminates an entire category of bugs — the mismatch between what the server actually returns and what the client code assumes it returns. The developer experience improvements compound over time: every new procedure you add is instantly type-safe, every input is validated, and every refactor is caught at compile time rather than in production.

When to Choose tRPC

  • TypeScript on both ends: You control both the server and client, and both use TypeScript
  • Monorepo structure: Frontend and backend share a single repository (Next.js, Turborepo)
  • Rapid iteration: You're changing the API often and need compile-time safety to catch regressions
  • Small to medium teams: The "no schema" approach is a feature, not a limitation, for teams that move fast
  • React Query users: The tRPC + React Query combination is one of the best data-fetching experiences available
"tRPC makes me feel like I'm calling local functions, not crossing a network boundary. The types just work, and that's the point."
— Alex Johansson, Creator of tRPC

Start with a simple Next.js project, add tRPC alongside your existing code, and migrate one endpoint at a time. The types propagate immediately, the React Query integration handles caching for free, and Zod validation gives you runtime safety at the boundary. Once you've experienced building an API where renaming a field on the server instantly shows a type error in your component, it's hard to go back.

tRPC TypeScript API Full-Stack Next.js React Query Zod
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.