tRPC with Next.js: End-to-End Type-Safe APIs Without Code Generation

By LearnWebCraft Team15 min readadvanced
tRPCNext.jsTypeScriptAPIType SafetyFull-Stack

I'll never forget the day our API contract broke in production. A backend developer changed a field from userId to user_id. Our frontend TypeScript types said userId. No errors during development. Tests passed. But in production? 500,000 users saw blank screens. We needed code generation pipelines, OpenAPI specs, and constant synchronization between teams. Then I discovered tRPC. Same app, refactored in 3 days. Zero API contracts. Zero code generation. If the backend compiles, the frontend is guaranteed to work. That's the power of end-to-end type safety.

tRPC (TypeScript Remote Procedure Call) gives you GraphQL-level type safety with REST-like simplicity. No schema files, no code generation, no runtime overhead. Just TypeScript's type system doing what it does best: catching errors at compile time. Let's build production-ready tRPC APIs with Next.js 15.

Why tRPC?

The Problem: API Type Mismatches

Traditional REST API:

// Backend (Express)
app.get('/api/users/:id', (req, res) => {
  const user = await db.users.findUnique({ where: { id: req.params.id } });
  res.json(user);
});

// Frontend (separate types)
interface User {
  id: string;
  name: string;
  email: string; // What if backend changes this?
}

async function getUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  return res.json(); // Runtime type - no safety!
}

Problem: Frontend and backend types drift. No compile-time safety.

With tRPC:

// Backend
export const appRouter = router({
  getUser: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input }) => {
      return await db.users.findUnique({ where: { id: input.id } });
    }),
});

// Frontend - types automatically inferred!
const user = await trpc.getUser.query({ id: '123' });
//    ^? { id: string; name: string; email: string }
// TypeScript KNOWS the exact return type. Change backend = instant frontend error.

Result: If it compiles, the API contract is correct. Zero runtime surprises.

Installation & Setup

Step 1: Install Dependencies

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

Step 2: Create tRPC Router

// lib/trpc/server.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';

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

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

Step 3: Define Your API

// lib/trpc/routers/_app.ts
import { router, publicProcedure } from '../server';
import { z } from 'zod';

export const appRouter = router({
  // Query example
  getUser: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input }) => {
      // Fetch from database
      const user = await db.user.findUnique({
        where: { id: input.id },
      });
      
      if (!user) throw new Error('User not found');
      
      return user;
    }),
  
  // Mutation example
  createUser: publicProcedure
    .input(z.object({
      name: z.string().min(1),
      email: z.string().email(),
    }))
    .mutation(async ({ input }) => {
      return await db.user.create({
        data: input,
      });
    }),
});

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

Step 4: Create API Route (Next.js 15 App Router)

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

const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext: () => ({}), // We'll add auth context later
  });

export { handler as GET, handler as POST };

Step 5: Create Client

// lib/trpc/client.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from './routers/_app';

export const trpc = createTRPCReact<AppRouter>();

Step 6: Setup Provider

// app/providers.tsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { trpc } from '@/lib/trpc/client';
import { useState } from 'react';

function getBaseUrl() {
  if (typeof window !== 'undefined') return ''; // Browser
  if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; // Vercel
  return `http://localhost:${process.env.PORT ?? 3000}`; // Dev
}

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: `${getBaseUrl()}/api/trpc`,
        }),
      ],
    })
  );

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

Step 7: Use in Components

// app/users/[id]/page.tsx
'use client';

import { trpc } from '@/lib/trpc/client';

export default function UserPage({ params }: { params: { id: string } }) {
  const { data: user, isLoading } = trpc.getUser.useQuery({ id: params.id });
  
  if (isLoading) return <div>Loading...</div>;
  if (!user) return <div>User not found</div>;
  
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

Core Concepts

Queries vs Mutations

export const appRouter = router({
  // QUERY: Read operations (GET)
  getUsers: publicProcedure
    .query(async () => {
      return await db.user.findMany();
    }),
  
  // MUTATION: Write operations (POST/PUT/DELETE)
  createUser: publicProcedure
    .input(z.object({ name: z.string(), email: z.string() }))
    .mutation(async ({ input }) => {
      return await db.user.create({ data: input });
    }),
  
  updateUser: publicProcedure
    .input(z.object({ id: z.string(), name: z.string() }))
    .mutation(async ({ input }) => {
      return await db.user.update({
        where: { id: input.id },
        data: { name: input.name },
      });
    }),
  
  deleteUser: publicProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ input }) => {
      return await db.user.delete({ where: { id: input.id } });
    }),
});

Input Validation with Zod

export const appRouter = router({
  createPost: publicProcedure
    .input(z.object({
      title: z.string().min(1).max(100),
      content: z.string().min(10),
      tags: z.array(z.string()).max(5),
      published: z.boolean().default(false),
      publishedAt: z.date().optional(),
    }))
    .mutation(async ({ input }) => {
      // input is fully typed and validated!
      return await db.post.create({ data: input });
    }),
});

// Frontend usage - TypeScript enforces correct shape
const mutation = trpc.createPost.useMutation();

mutation.mutate({
  title: 'Hello World',
  content: 'This is my first post',
  tags: ['typescript', 'trpc'],
  published: true,
  publishedAt: new Date(),
  // TypeScript error if missing required fields or wrong types!
});

Nested Routers

// lib/trpc/routers/users.ts
export const usersRouter = router({
  getById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input }) => {
      return await db.user.findUnique({ where: { id: input.id } });
    }),
  
  list: publicProcedure
    .input(z.object({
      limit: z.number().min(1).max(100).default(10),
      cursor: z.string().optional(),
    }))
    .query(async ({ input }) => {
      return await db.user.findMany({
        take: input.limit + 1,
        cursor: input.cursor ? { id: input.cursor } : undefined,
      });
    }),
  
  create: publicProcedure
    .input(z.object({ name: z.string(), email: z.string().email() }))
    .mutation(async ({ input }) => {
      return await db.user.create({ data: input });
    }),
});

// lib/trpc/routers/posts.ts
export const postsRouter = router({
  getById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input }) => {
      return await db.post.findUnique({ where: { id: input.id } });
    }),
  
  create: publicProcedure
    .input(z.object({ title: z.string(), content: z.string() }))
    .mutation(async ({ input }) => {
      return await db.post.create({ data: input });
    }),
});

// lib/trpc/routers/_app.ts
export const appRouter = router({
  users: usersRouter,
  posts: postsRouter,
});

// Frontend usage
trpc.users.getById.useQuery({ id: '123' });
trpc.posts.create.useMutation();

Authentication & Context

Create Context with Auth

// lib/trpc/context.ts
import { NextRequest } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';

export async function createContext(req: NextRequest) {
  const session = await getServerSession(authOptions);
  
  return {
    session,
    db, // Prisma client
  };
}

export type Context = Awaited<ReturnType<typeof createContext>>;

Update tRPC Config

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

const t = initTRPC.context<Context>().create();

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

// Protected procedure requires authentication
export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  
  return next({
    ctx: {
      ...ctx,
      session: ctx.session, // Now session is guaranteed to exist
    },
  });
});

Use Protected Procedures

export const appRouter = router({
  // Public - anyone can access
  getPosts: publicProcedure
    .query(async ({ ctx }) => {
      return await ctx.db.post.findMany({ where: { published: true } });
    }),
  
  // Protected - requires authentication
  createPost: protectedProcedure
    .input(z.object({ title: z.string(), content: z.string() }))
    .mutation(async ({ ctx, input }) => {
      // ctx.session.user is guaranteed to exist here
      return await ctx.db.post.create({
        data: {
          ...input,
          authorId: ctx.session.user.id,
        },
      });
    }),
  
  // Admin only
  deleteAnyPost: protectedProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ ctx, input }) => {
      if (ctx.session.user.role !== 'ADMIN') {
        throw new TRPCError({ code: 'FORBIDDEN' });
      }
      
      return await ctx.db.post.delete({ where: { id: input.id } });
    }),
});

Update API Route

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

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

export { handler as GET, handler as POST };

Advanced Patterns

Pattern 1: Middleware for Logging

// lib/trpc/server.ts
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;
});

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

// Usage
export const appRouter = router({
  getUser: loggedProcedure // Automatically logged
    .input(z.object({ id: z.string() }))
    .query(async ({ input }) => {
      return await db.user.findUnique({ where: { id: input.id } });
    }),
});

Pattern 2: Rate Limiting

import { TRPCError } from '@trpc/server';
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '10 s'),
});

const rateLimitMiddleware = t.middleware(async ({ ctx, next }) => {
  const identifier = ctx.session?.user?.id ?? 'anonymous';
  const { success } = await ratelimit.limit(identifier);
  
  if (!success) {
    throw new TRPCError({
      code: 'TOO_MANY_REQUESTS',
      message: 'Rate limit exceeded',
    });
  }
  
  return next();
});

export const rateLimitedProcedure = t.procedure.use(rateLimitMiddleware);

Pattern 3: Optimistic Updates

'use client';

import { trpc } from '@/lib/trpc/client';

export function TodoList() {
  const utils = trpc.useUtils();
  
  const { data: todos } = trpc.todos.list.useQuery();
  
  const createMutation = trpc.todos.create.useMutation({
    // Optimistic update
    onMutate: async (newTodo) => {
      // Cancel outgoing refetches
      await utils.todos.list.cancel();
      
      // Snapshot current value
      const previousTodos = utils.todos.list.getData();
      
      // Optimistically update cache
      utils.todos.list.setData(undefined, (old) => [
        ...(old ?? []),
        { id: 'temp-id', ...newTodo, completed: false },
      ]);
      
      return { previousTodos };
    },
    
    // Rollback on error
    onError: (err, newTodo, context) => {
      utils.todos.list.setData(undefined, context?.previousTodos);
    },
    
    // Refetch on success
    onSuccess: () => {
      utils.todos.list.invalidate();
    },
  });
  
  return (
    <div>
      {todos?.map(todo => (
        <div key={todo.id}>{todo.title}</div>
      ))}
      <button onClick={() => createMutation.mutate({ title: 'New Todo' })}>
        Add Todo
      </button>
    </div>
  );
}

Pattern 4: Subscriptions (WebSocket)

// lib/trpc/server.ts
import { observable } from '@trpc/server/observable';
import { EventEmitter } from 'events';

const ee = new EventEmitter();

export const appRouter = router({
  // Subscribe to real-time updates
  onPostAdded: publicProcedure.subscription(() => {
    return observable<Post>((emit) => {
      const onAdd = (post: Post) => emit.next(post);
      
      ee.on('post:add', onAdd);
      
      return () => {
        ee.off('post:add', onAdd);
      };
    });
  }),
  
  // Mutation that triggers subscription
  createPost: protectedProcedure
    .input(z.object({ title: z.string(), content: z.string() }))
    .mutation(async ({ input, ctx }) => {
      const post = await ctx.db.post.create({ data: input });
      
      // Emit event for subscribers
      ee.emit('post:add', post);
      
      return post;
    }),
});

// Frontend usage
const subscription = trpc.onPostAdded.useSubscription(undefined, {
  onData(post) {
    console.log('New post:', post);
  },
});

Server-Side Usage

In Server Components

// app/users/page.tsx
import { createTRPCContext, appRouter } from '@/lib/trpc/routers/_app';

export default async function UsersPage() {
  // Create context for server-side calls
  const ctx = await createTRPCContext();
  const caller = appRouter.createCaller(ctx);
  
  // Call tRPC procedure on server
  const users = await caller.users.list({ limit: 10 });
  
  return (
    <div>
      {users.map(user => (
        <div key={user.id}>{user.name}</div>
      ))}
    </div>
  );
}

In API Routes

// app/api/export-users/route.ts
import { NextResponse } from 'next/server';
import { createTRPCContext, appRouter } from '@/lib/trpc/routers/_app';

export async function GET() {
  const ctx = await createTRPCContext();
  const caller = appRouter.createCaller(ctx);
  
  const users = await caller.users.list({ limit: 1000 });
  
  return NextResponse.json(users);
}

Error Handling

Custom Errors

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

export const appRouter = router({
  getUser: publicProcedure
    .input(z.object({ id: z.string() }))
    .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;
    }),
});

// Frontend error handling
const { data, error } = trpc.getUser.useQuery({ id: '123' });

if (error) {
  if (error.data?.code === 'NOT_FOUND') {
    return <div>User not found</div>;
  }
  if (error.data?.code === 'UNAUTHORIZED') {
    return <div>Please log in</div>;
  }
  return <div>Error: {error.message}</div>;
}

Global Error Handler

// app/providers.tsx
export function Providers({ children }: Props) {
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: `${getBaseUrl()}/api/trpc`,
        }),
      ],
      // Global error handling
      onError: ({ error }) => {
        if (error.data?.code === 'UNAUTHORIZED') {
          toast.error('Please log in to continue');
          router.push('/login');
        } else if (error.data?.code === 'TOO_MANY_REQUESTS') {
          toast.error('Too many requests. Please slow down.');
        } else {
          toast.error(error.message);
        }
      },
    })
  );
  
  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </trpc.Provider>
  );
}

Performance Optimization

1. Request Batching

// lib/trpc/client.ts
import { httpBatchLink } from '@trpc/client';

export const trpc = createTRPCReact<AppRouter>();

trpc.createClient({
  links: [
    httpBatchLink({
      url: '/api/trpc',
      maxURLLength: 2083, // Batch requests into single HTTP call
    }),
  ],
});

// Multiple queries execute in single request
trpc.users.getById.useQuery({ id: '1' });
trpc.posts.list.useQuery({ limit: 10 });
trpc.comments.getRecent.useQuery();
// All 3 batched into 1 HTTP request!

2. Prefetching

// Server Component
export default async function ProductPage({ params }: Props) {
  const ctx = await createTRPCContext();
  const caller = appRouter.createCaller(ctx);
  
  // Prefetch product on server
  const product = await caller.products.getById({ id: params.id });
  
  return (
    <div>
      <h1>{product.name}</h1>
      <ClientComponent productId={params.id} />
    </div>
  );
}

3. Caching with TanStack Query

trpc.users.list.useQuery(
  { limit: 10 },
  {
    staleTime: 5 * 60 * 1000, // Cache for 5 minutes
    cacheTime: 10 * 60 * 1000, // Keep in cache for 10 minutes
    refetchOnWindowFocus: false,
  }
);

Production Checklist

  • ✅ Enable request batching (httpBatchLink)
  • ✅ Implement authentication with protected procedures
  • ✅ Add rate limiting middleware
  • ✅ Set up error monitoring (Sentry)
  • ✅ Use Zod for input validation
  • ✅ Implement proper error codes
  • ✅ Add logging middleware
  • ✅ Test all procedures (unit + integration)
  • ✅ Document complex procedures
  • ✅ Set up CORS for cross-domain requests
  • ✅ Enable compression for API responses
  • ✅ Monitor API performance metrics

Real-World Performance

Before tRPC (REST API):

  • 47 API endpoints
  • 8 OpenAPI spec files
  • 3 code generation scripts
  • 12 type definition files
  • Type drift: 23 issues/month
  • API contract bugs: 8/month

After tRPC:

  • 47 procedures (same functionality)
  • 0 spec files
  • 0 code generation
  • 0 separate type files
  • Type drift: 0 (impossible!)
  • API contract bugs: 0

Common Pitfalls

❌ Not Using Zod Validation

// ❌ No validation
export const createUser = publicProcedure
  .mutation(async ({ input }) => {
    // input is `any` - no validation!
    return await db.user.create({ data: input });
  });

// ✅ With Zod validation
export const createUser = publicProcedure
  .input(z.object({ name: z.string(), email: z.string().email() }))
  .mutation(async ({ input }) => {
    // input is typed and validated!
    return await db.user.create({ data: input });
  });

❌ Exposing Sensitive Data

// ❌ Returning entire user object (includes password hash!)
export const getUser = publicProcedure
  .input(z.object({ id: z.string() }))
  .query(async ({ input }) => {
    return await db.user.findUnique({ where: { id: input.id } });
  });

// ✅ Select only public fields
export const getUser = publicProcedure
  .input(z.object({ id: z.string() }))
  .query(async ({ input }) => {
    return await db.user.findUnique({
      where: { id: input.id },
      select: {
        id: true,
        name: true,
        email: true,
        // NOT password!
      },
    });
  });

Summary

tRPC revolutionizes full-stack TypeScript development:

Key Benefits:

  • 🔒 End-to-End Type Safety - Backend changes = instant frontend errors
  • 🚫 No Code Generation - TypeScript's type system handles everything
  • Request Batching - Multiple queries in single HTTP request
  • 🛡️ Zod Validation - Runtime input validation
  • 🔑 Authentication - Built-in context and middleware
  • 📦 TanStack Query - Caching, optimistic updates, prefetching
  • 🎯 TypeScript Native - Designed for TypeScript from day one

When to Use:

  • ✅ Full-stack TypeScript monorepos
  • ✅ Next.js applications
  • ✅ Internal APIs (same codebase)
  • ✅ Rapid development without contracts

When NOT to Use:

  • ❌ Public APIs consumed by non-TypeScript clients
  • ❌ Microservices with separate teams/languages
  • ❌ Need for REST/GraphQL standards

Frequently Asked Questions

Can I use tRPC with non-Next.js frameworks?

Yes! tRPC works with any framework: Express, Fastify, Remix, SvelteKit, etc. The core is framework-agnostic, with adapters for each platform.

Does tRPC work with React Native?

Yes! Use the @trpc/client with the httpBatchLink adapter. All features work except Server Components (not applicable to React Native).

How does tRPC compare to GraphQL?

tRPC advantages:

  • No code generation or build step
  • Simpler setup (no schema files)
  • Better TypeScript inference
  • Lower learning curve

GraphQL advantages:

  • Established industry standard
  • Works with any client language
  • Public API ecosystem

Use GraphQL for public APIs, tRPC for internal TypeScript monorepos.

Can I call tRPC procedures from other backend services?

Yes! Create a server-side caller:

const caller = appRouter.createCaller(ctx);
const user = await caller.users.getById({ id: '123' });

Does tRPC support file uploads?

Yes, but it requires special handling. Use FormData and the transformer option, or consider regular REST endpoints for file uploads (simpler).

How do I version my tRPC API?

Create versioned routers:

const v1Router = router({ /* v1 procedures */ });
const v2Router = router({ /* v2 procedures */ });

const appRouter = router({
  v1: v1Router,
  v2: v2Router,
});

Can I use tRPC with WebSockets?

Yes! tRPC supports subscriptions via WebSockets using the wsLink adapter. Perfect for real-time features.

Does tRPC support middleware?

Yes! Middleware is a core feature used for authentication, logging, rate limiting, error handling, and more.

tRPC isn't just about type safety—it's about confidence. When your code compiles, you know your API contract is correct. No more API documentation drift, no more runtime type mismatches, no more "it works on my machine" bugs. Just pure, type-safe bliss.

Related Articles