Intermediate
25 min
Full Guide

tRPC - End-to-End Type Safety

Build fully typesafe APIs without schemas or code generation using tRPC

What is tRPC?

tRPC (TypeScript Remote Procedure Call) enables you to build fully typesafe APIs without schemas or code generation. It leverages TypeScript's type inference to automatically share types between your server and client.

If you're building a TypeScript full-stack application (like Next.js), tRPC eliminates the traditional API layer complexity while providing complete type safety.

✨ Why tRPC?

🔒
End-to-End Type Safety

Types flow from server to client automatically

No Code Generation

No build step, instant type updates

🎯
Autocomplete Everywhere

Full IDE support for API calls

🚀
Tiny Bundle

~2KB client, no runtime overhead

Setting Up tRPC

// Installation
// npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod

// 1. Define your router (server/trpc.ts)
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';

// Initialize tRPC
const t = initTRPC.context().create();

// Export reusable router and procedure helpers
export const router = t.router;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(isAuthed);

// 2. Create your app router (server/routers/_app.ts)
import { router } from '../trpc';
import { userRouter } from './user';
import { postRouter } from './post';

export const appRouter = router({
  user: userRouter,
  post: postRouter,
});

// Export type definition of API
export type AppRouter = typeof appRouter;

Defining Procedures

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

export const userRouter = router({
  // Query - for fetching data (like GET)
  getById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input, ctx }) => {
      const user = await ctx.db.user.findUnique({
        where: { id: input.id }
      });
      
      if (!user) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: 'User not found'
        });
      }
      
      return user;
    }),

  // Query with no input
  list: publicProcedure
    .query(async ({ ctx }) => {
      return ctx.db.user.findMany({
        take: 10,
        orderBy: { createdAt: 'desc' }
      });
    }),

  // Mutation - for changing data (like POST/PUT/DELETE)
  create: protectedProcedure
    .input(z.object({
      name: z.string().min(2).max(100),
      email: z.string().email(),
      role: z.enum(['admin', 'user']).optional().default('user')
    }))
    .mutation(async ({ input, ctx }) => {
      // ctx.user is available because of protectedProcedure
      const user = await ctx.db.user.create({
        data: {
          ...input,
          createdBy: ctx.user.id
        }
      });
      
      return user;
    }),

  // Update mutation
  update: protectedProcedure
    .input(z.object({
      id: z.string(),
      name: z.string().min(2).optional(),
      email: z.string().email().optional()
    }))
    .mutation(async ({ input, ctx }) => {
      const { id, ...data } = input;
      return ctx.db.user.update({
        where: { id },
        data
      });
    }),

  // Delete mutation
  delete: protectedProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ input, ctx }) => {
      await ctx.db.user.delete({
        where: { id: input.id }
      });
      return { success: true };
    })
});

Context & Middleware

// server/context.ts
import { inferAsyncReturnType } from '@trpc/server';
import { getSession } from 'next-auth/react';

export async function createContext({ req, res }) {
  const session = await getSession({ req });
  
  return {
    req,
    res,
    user: session?.user ?? null,
    db: prisma // Your database client
  };
}

export type Context = inferAsyncReturnType;

// Middleware for protected routes
import { TRPCError } from '@trpc/server';

const isAuthed = t.middleware(({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({
      code: 'UNAUTHORIZED',
      message: 'You must be logged in'
    });
  }
  
  return next({
    ctx: {
      // Infers the user is non-null
      user: ctx.user
    }
  });
});

// Logging middleware
const loggerMiddleware = t.middleware(async ({ path, type, next }) => {
  const start = Date.now();
  const result = await next();
  const duration = Date.now() - start;
  
  console.log(`${type} ${path} - ${duration}ms`);
  
  return result;
});

// Apply middleware
export const loggedProcedure = t.procedure.use(loggerMiddleware);

React Client with React Query

// utils/trpc.ts - Client setup
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server/routers/_app';

export const trpc = createTRPCReact();

// app/providers.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { trpc } from '../utils/trpc';
import { useState } from 'react';

export function TRPCProvider({ children }) {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: '/api/trpc',
          headers() {
            return {
              authorization: getAuthToken()
            };
          }
        })
      ]
    })
  );

  return (
    
      
        {children}
      
    
  );
}

Using tRPC in Components

// components/UserList.tsx
import { trpc } from '../utils/trpc';

export function UserList() {
  // Queries - automatic caching & refetching
  const { data: users, isLoading, error } = trpc.user.list.useQuery();

  // Query with input
  const { data: user } = trpc.user.getById.useQuery(
    { id: '123' },
    { enabled: !!userId } // Only run if userId exists
  );

  // Mutations
  const utils = trpc.useUtils();
  
  const createUser = trpc.user.create.useMutation({
    onSuccess: () => {
      // Invalidate cache to refetch
      utils.user.list.invalidate();
    },
    onError: (error) => {
      alert(error.message);
    }
  });

  const handleSubmit = (data) => {
    createUser.mutate({
      name: data.name,
      email: data.email,
      // TypeScript knows exactly what fields are required!
    });
  };

  if (isLoading) return 
Loading...
; if (error) return
Error: {error.message}
; return (
{users?.map(user => (
{user.name} - {user.email}
))}
); } // Optimistic updates const updateUser = trpc.user.update.useMutation({ onMutate: async (newData) => { // Cancel outgoing refetches await utils.user.getById.cancel({ id: newData.id }); // Snapshot current value const previousUser = utils.user.getById.getData({ id: newData.id }); // Optimistically update utils.user.getById.setData({ id: newData.id }, (old) => ({ ...old, ...newData })); return { previousUser }; }, onError: (err, newData, context) => { // Rollback on error utils.user.getById.setData( { id: newData.id }, context?.previousUser ); } });

Next.js Integration

// pages/api/trpc/[trpc].ts (Pages Router)
import { createNextApiHandler } from '@trpc/server/adapters/next';
import { appRouter } from '../../../server/routers/_app';
import { createContext } from '../../../server/context';

export default createNextApiHandler({
  router: appRouter,
  createContext,
  onError: ({ error, type, path }) => {
    console.error(`tRPC Error on ${path}:`, error);
  }
});

// app/api/trpc/[trpc]/route.ts (App Router)
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
  });

export { handler as GET, handler as POST };

// Server Components (App Router) - Direct calls!
import { appRouter } from '@/server/routers/_app';
import { createContext } from '@/server/context';

export default async function UsersPage() {
  // Call tRPC directly on server - no HTTP!
  const caller = appRouter.createCaller(await createContext());
  const users = await caller.user.list();

  return (
    
{users.map(user => (
{user.name}
))}
); }

Subscriptions (Real-time)

// Server - WebSocket subscriptions
import { observable } from '@trpc/server/observable';
import { EventEmitter } from 'events';

const ee = new EventEmitter();

export const postRouter = router({
  onNewPost: publicProcedure
    .subscription(() => {
      return observable((emit) => {
        const onAdd = (post: Post) => emit.next(post);
        
        ee.on('newPost', onAdd);
        
        return () => {
          ee.off('newPost', onAdd);
        };
      });
    }),

  create: protectedProcedure
    .input(z.object({ title: z.string(), content: z.string() }))
    .mutation(async ({ input, ctx }) => {
      const post = await ctx.db.post.create({ data: input });
      
      // Emit to subscribers
      ee.emit('newPost', post);
      
      return post;
    })
});

// Client - Subscribe to updates
import { trpc } from '../utils/trpc';

function LiveFeed() {
  const [posts, setPosts] = useState([]);

  trpc.post.onNewPost.useSubscription(undefined, {
    onData: (post) => {
      setPosts((prev) => [post, ...prev]);
    },
    onError: (err) => {
      console.error('Subscription error:', err);
    }
  });

  return (
    
{posts.map(post => (
{post.title}
))}
); }

Error Handling

// Server-side errors
import { TRPCError } from '@trpc/server';

// Built-in error codes
throw new TRPCError({
  code: 'BAD_REQUEST',        // 400
  code: 'UNAUTHORIZED',       // 401
  code: 'FORBIDDEN',          // 403
  code: 'NOT_FOUND',          // 404
  code: 'CONFLICT',           // 409
  code: 'UNPROCESSABLE_CONTENT', // 422
  code: 'TOO_MANY_REQUESTS',  // 429
  code: 'INTERNAL_SERVER_ERROR', // 500
  message: 'Custom error message',
  cause: originalError // Optional
});

// Client-side handling
const mutation = trpc.user.create.useMutation({
  onError: (error) => {
    // error.data contains the error code
    if (error.data?.code === 'CONFLICT') {
      toast.error('User already exists');
    } else if (error.data?.code === 'UNAUTHORIZED') {
      router.push('/login');
    } else {
      toast.error(error.message);
    }
  }
});

// Zod validation errors are automatic!
// If input doesn't match schema, client gets detailed errors

tRPC vs REST vs GraphQL

Feature REST GraphQL tRPC
Type Safety Manual Codegen Automatic ✓
Schema OpenAPI SDL None needed ✓
Build Step Optional Required None ✓
Language Any Any TypeScript only
Learning Curve Low Medium Low ✓

💡 tRPC Best Practices

  • Use Zod for validation - Input validation and type inference
  • Split routers by domain - Keep routers focused and organized
  • Use middleware - Auth, logging, rate limiting
  • Leverage React Query - Caching, optimistic updates, prefetching
  • Error handling - Use TRPCError with proper codes
  • Server components - Call tRPC directly without HTTP