Skip to main content
โšก Calmops

Building Scalable APIs with tRPC: Type-Safe End-to-End 2026

Introduction

tRPC brings end-to-end type safety to your API without code generation or schema duplication. If you use TypeScript, tRPC provides the best developer experience for building APIs. This guide covers everything you need to know.


Why tRPC?

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚              tRPC Value Proposition                           โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚                                                             โ”‚
โ”‚  Traditional API Development:                                 โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”     โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”     โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”        โ”‚
โ”‚  โ”‚ Backend  โ”‚โ”€โ”€โ”€โ”€โ–ถโ”‚   JSON   โ”‚โ”€โ”€โ”€โ”€โ–ถโ”‚ Frontend โ”‚        โ”‚
โ”‚  โ”‚  Types   โ”‚     โ”‚   Types  โ”‚     โ”‚  Types   โ”‚        โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜     โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜     โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜        โ”‚
โ”‚       โ”‚                โ”‚                โ”‚                โ”‚
โ”‚       โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                โ”‚
โ”‚                    MANUALLY SYNCED!                       โ”‚
โ”‚                    Drift = Bugs!                          โ”‚
โ”‚                                                             โ”‚
โ”‚  tRPC:                                                     โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”                                              โ”‚
โ”‚  โ”‚ Backend  โ”‚โ”€โ”€โ”€โ”€โ–ถ Full Type Safety!                       โ”‚
โ”‚  โ”‚  Types   โ”‚     No Manual Sync Needed                    โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                                              โ”‚
โ”‚       โ”‚                                                    โ”‚
โ”‚       โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถ Frontend Types           โ”‚
โ”‚                    SHARED!                                  โ”‚
โ”‚                                                             โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Core Concepts

Key Terminology

tRPC_terms:
  router:
    - "Collection of API procedures"
    - "Main entry point for your API"
    
  procedure:
    - "Individual API endpoint"
    - "query, mutation, or subscription"
    
  context:
    - "Data available to all procedures"
    - "Session, database connection, etc."
    
  middleware:
    - "Logic that runs before procedures"
    - "Authentication, validation, etc."

Setting Up tRPC

Project Structure

src/
โ”œโ”€โ”€ server/
โ”‚   โ”œโ”€โ”€ api/
โ”‚   โ”‚   โ”œโ”€โ”€ root.ts       # Main router
โ”‚   โ”‚   โ”œโ”€โ”€ trpc.ts       # tRPC initialization
โ”‚   โ”‚   โ””โ”€โ”€ routers/      # Route handlers
โ”‚   โ”‚       โ”œโ”€โ”€ user.ts
โ”‚   โ”‚       โ””โ”€โ”€ post.ts
โ”‚   โ””โ”€โ”€ db.ts             # Database client
โ”œโ”€โ”€ utils/
โ”‚   โ””โ”€โ”€ api.ts            # Client-side tRPC setup
โ””โ”€โ”€ app/
    โ””โ”€โ”€ page.tsx          # Using the API

Server Setup

// src/server/api/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { ZodError } from 'zod';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';

export const createTRPCContext = async (opts: { headers: Headers }) => {
  const session = await getServerSession(authOptions);
  
  return {
    prisma,
    session,
    ...opts,
  };
};

const t = initTRPC.context<typeof createTRPCContext>().create({
  transformer: superjson,
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError: error.cause instanceof ZodError 
          ? error.cause.flatten() 
          : null,
      },
    };
  },
});

export const createCallerFactory = t.createCallerFactory;
export const router = t.router;
export const publicProcedure = t.procedure;

Building Routers

Simple Router

// src/server/api/routers/user.ts
import { z } from 'zod';
import { createTRPCRouter, publicProcedure, protectedProcedure } from '../trpc';

export const userRouter = createTRPCRouter({
  // Query: Read data
  getAll: publicProcedure.query(async ({ ctx }) => {
    return ctx.prisma.user.findMany({
      orderBy: { createdAt: 'desc' },
    });
  }),

  // Query with input validation
  getById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ ctx, input }) => {
      const user = await ctx.prisma.user.findUnique({
        where: { id: input.id },
      });
      
      if (!user) {
        throw new TRPCError({ 
          code: 'NOT_FOUND',
          message: 'User not found' 
        });
      }
      
      return user;
    }),

  // Mutation: Create/Update/Delete
  create: protectedProcedure
    .input(z.object({
      name: z.string().min(1),
      email: z.string().email(),
    }))
    .mutation(async ({ ctx, input }) => {
      return ctx.prisma.user.create({
        data: {
          ...input,
          createdBy: ctx.session.user.id,
        },
      });
    }),

  // Protected mutation (auth required)
  update: protectedProcedure
    .input(z.object({
      id: z.string(),
      name: z.string().min(1).optional(),
      email: z.string().email().optional(),
    }))
    .mutation(async ({ ctx, input }) => {
      const { id, ...data } = input;
      return ctx.prisma.user.update({
        where: { id },
        data,
      });
    }),

  delete: protectedProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ ctx, input }) => {
      return ctx.prisma.user.delete({
        where: { id: input.id },
      });
    }),
});

Combining Routers

// src/server/api/root.ts
import { userRouter } from './routers/user';
import { postRouter } from './routers/post';
import { createCallerFactory } from './trpc';

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

export type AppRouter = typeof appRouter;
export const createCaller = createCallerFactory(appRouter);

Authentication with Middleware

Protected Procedure

// src/server/api/trpc.ts
const isAuthed = t.middleware(({ ctx, next }) => {
  if (!ctx.session || !ctx.session.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  
  return next({
    ctx: {
      session: { ...ctx.session, user: ctx.session.user },
    },
  });
});

export const protectedProcedure = t.procedure.use(isAuthed);

Role-Based Access

// Middleware for role checking
const isAdmin = t.middleware(({ ctx, next }) => {
  if (ctx.session?.user?.role !== 'ADMIN') {
    throw new TRPCError({ 
      code: 'FORBIDDEN',
      message: 'Admin access required'
    });
  }
  
  return next({ ctx });
});

export const adminProcedure = t.procedure.use(isAdmin);

// Usage
adminProcedure.mutation(async ({ ctx }) => {
  // Only admins can execute this
});

Client-Side Usage

Setup Provider

// src/utils/api.ts
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { createTRPCReact } from '@trpc/react-query';
import { useState } from 'react';
import superjson from 'superjson';
import type { AppRouter } from '@/server/api/root';

export const api = createTRPCReact<AppRouter>();

export function TRPCProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    api.createClient({
      links: [
        httpBatchLink({
          url: '/api/trpc',
          transformer: superjson,
        }),
      ],
    })
  );

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

Using in Components

// src/app/users/page.tsx
import { api } from '@/utils/api';

export default function UsersPage() {
  // Fully typed response!
  const { data: users, isLoading } = api.user.getAll.useQuery();
  
  // Mutation with typed input
  const createUser = api.user.create.useMutation({
    onSuccess: () => {
      // Refetch users after create
      void utils.user.getAll.invalidate();
    },
  });

  if (isLoading) return <div>Loading...</div>;

  return (
    <div>
      {users?.map(user => (
        <div key={user.id}>{user.name}</div>
      ))}
      
      <button 
        onClick={() => createUser.mutate({ 
          name: 'New User', 
          email: '[email protected]' 
        })}
      >
        Add User
      </button>
    </div>
  );
}

Error Handling

Custom Error Codes

// Throwing errors in procedures
const getUser = publicProcedure
  .input(z.object({ id: z.string() }))
  .query(async ({ ctx, input }) => {
    const user = await ctx.prisma.user.findUnique({
      where: { id: input.id },
    });

    if (!user) {
      throw new TRPCError({
        code: 'NOT_FOUND',
        message: `User with ID ${input.id} not found`,
        cause: 'USER_NOT_FOUND',
      });
    }

    return user;
  });

// Error formatting (already in trpc.ts)
errorFormatter({ shape, error }) {
  return {
    ...shape,
    data: {
      ...shape.data,
      code: error.code,
      cause: error.cause,
    },
  };
}

Testing tRPC

Unit Testing Procedures

import { describe, it, expect, beforeEach } from 'vitest';
import { appRouter } from '@/server/api/root';
import { createCaller } from '@/server/api/trpc';

describe('userRouter', () => {
  let caller: ReturnType<typeof createCaller>;
  
  beforeEach(() => {
    // Create caller with mock context
    caller = createCaller({
      session: null,
      prisma: mockPrisma,
    });
  });

  it('getAll returns users', async () => {
    const users = await caller.user.getAll();
    expect(users).toHaveLength(2);
  });

  it('create creates a user', async () => {
    const result = await caller.user.create({
      name: 'Test',
      email: '[email protected]',
    });
    
    expect(result.name).toBe('Test');
  });
});

Key Takeaways

  • Zero schema - TypeScript types are the schema
  • Type safety - End-to-end without code generation
  • Great DX - Full autocomplete, type hints
  • React Query - Built on TanStack Query
  • Flexible - Works with any backend, any frontend

External Resources

Comments