As Next.js developers, we’ve all been there: spending hours debugging runtime errors because of mismatched types between our frontend and backend, or maintaining endless TypeScript interfaces that need to stay synchronized across our codebase. What if I told you there’s a better way? Enter tRPC – a game-changing approach to building type-safe APIs in your Next.js applications.
The Problem: Building APIs Today
When building APIs in Next.js, we typically create separate endpoint files and their corresponding frontend code. Here’s what this usually looks like:
// pages/api/users.ts
export type User = {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
};
export default async function handler(req, res) {
const users: User[] = await db.users.findMany();
res.json(users);
}
// pages/users.tsx
import type { User } from '../pages/api/users';
async function fetchUsers(): Promise<User[]> {
const res = await fetch('/api/users');
return res.json();
}
Seems fine, right? But here’s where things get messy:
- Type definitions are duplicated between frontend and backend
- API responses aren’t automatically validated against your types
- No autocomplete for API endpoints or their parameters
- Runtime errors only surface when actually calling the API
Enter tRPC: A Better Way
Before we dive into the code, let’s understand what tRPC brings to the table. tRPC creates a direct connection between your frontend and backend code, ensuring that:
- Your API types are always in sync
- You get full autocomplete support
- Type errors are caught during development
- API inputs are validated automatically
Setting Up tRPC
First, you’ll need to install tRPC and its dependencies:
npm install @trpc/server @trpc/client @trpc/react-query zod
The key packages here are:
@trpc/server
: For creating your API endpoints@trpc/client
: For calling your API from the frontend@trpc/react-query
: React hooks for data fetchingzod
: For input validation (we’ll explain this shortly)
Before we start writing our API, we need two configuration files:
// pages/api/trpc/[trpc].ts
import { createNextApiHandler } from '@trpc/server/adapters/next';
import { appRouter } from '../../../server/routers/_app';
export default createNextApiHandler({
router: appRouter,
createContext: () => ({})
});
// utils/trpc.ts
import { createTRPCNext } from '@trpc/next';
import { httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server/routers/_app';
export const trpc = createTRPCNext<AppRouter>({
config() {
return {
links: [
httpBatchLink({
url: '/api/trpc',
}),
],
};
},
});
Creating Your First tRPC API
Let’s rebuild our users API using tRPC:
// server/trpc.ts
import { initTRPC } from '@trpc/server';
// Create a new tRPC instance
export const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;
// server/routers/user.ts
import { router, publicProcedure } from '../trpc';
import { z } from 'zod'; // Zod helps validate our inputs
const userRouter = router({
// Simple query to get all users
getUsers: publicProcedure.query(async () => {
const users = await db.users.findMany();
return users;
}),
// Mutation to create a user with input validation
createUser: publicProcedure
.input(z.object({ // Zod schema defines what inputs are valid
name: z.string().min(2),
email: z.string().email(),
role: z.enum(['admin', 'user'])
}))
.mutation(async ({ input }) => {
const user = await db.users.create({ data: input });
return user;
})
});
// server/routers/_app.ts
import { router } from '../trpc';
import { userRouter } from './user';
export const appRouter = router({
users: userRouter,
});
export type AppRouter = typeof appRouter;
Using Your tRPC API in React
Here’s how you use this API in your frontend code:
// pages/users.tsx
import { trpc } from '../utils/trpc';
function UsersPage() {
// This gives you type-safe access to your API
const { data: users } = trpc.users.getUsers.useQuery();
const createUser = trpc.users.createUser.useMutation();
return (
<div>
{users?.map(user => <UserCard key={user.id} user={user} />)}
<button
onClick={() => createUser.mutate({
name: "John Doe",
email: "john@example.com",
role: "user" // TypeScript ensures this is 'admin' or 'user'
})}
>
Add User
</button>
</div>
);
}
Understanding Zod
Zod is a TypeScript-first schema validation library that tRPC uses to validate API inputs. Think of it as a way to define what data your API accepts:
import { z } from 'zod';
// This creates a schema that validates an object
const userSchema = z.object({
name: z.string().min(2), // Must be string, at least 2 chars
email: z.string().email(), // Must be valid email
age: z.number().min(0).max(120) // Must be number between 0-120
});
// TypeScript automatically creates a type from this schema!
type User = z.infer<typeof userSchema>;
Why tRPC Changes Everything
1. End-to-End Type Safety
The most immediate benefit is complete type safety across your entire application. Your IDE knows exactly:
- What procedures exist
- What input they expect
- What they return
- All without any manual type definitions or code generation
2. Automatic Input Validation
By integrating with Zod, tRPC provides runtime validation out of the box:
const createPostProcedure = publicProcedure
.input(z.object({
title: z.string().min(5).max(100),
content: z.string().min(10),
publishDate: z.date().optional()
}))
.mutation(async ({ input }) => {
// Input is fully typed AND validated!
const post = await db.posts.create({ data: input });
return post;
});
3. Enhanced Developer Experience
- Full autocompletion for procedure names and parameters
- Inline error messages for type mismatches
- Jump-to-definition for procedures
- Automatic documentation through TypeScript types
Real-World Implementation
Let’s build a complete feature to see how tRPC shines in practice. We’ll create a blog post management system:
// server/routers/post.ts
import { router, protectedProcedure, publicProcedure } from '../trpc';
import { z } from 'zod';
const postRouter = router({
// List all posts
list: publicProcedure
.input(z.object({
limit: z.number().min(1).max(100).default(10),
cursor: z.string().optional()
}))
.query(async ({ input }) => {
const posts = await db.posts.findMany({
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
orderBy: { createdAt: 'desc' }
});
let nextCursor: typeof input.cursor = undefined;
if (posts.length > input.limit) {
const nextItem = posts.pop();
nextCursor = nextItem!.id;
}
return {
items: posts,
nextCursor
};
}),
// Create a new post
create: protectedProcedure
.input(z.object({
title: z.string().min(5).max(100),
content: z.string().min(10),
tags: z.array(z.string()).max(5)
}))
.mutation(async ({ input, ctx }) => {
const post = await db.posts.create({
data: {
...input,
authorId: ctx.user.id
}
});
return post;
}),
// Delete a post
delete: protectedProcedure
.input(z.string().uuid())
.mutation(async ({ input: postId, ctx }) => {
const post = await db.posts.findUnique({
where: { id: postId }
});
if (!post) throw new TRPCError({
code: 'NOT_FOUND',
message: 'Post not found'
});
if (post.authorId !== ctx.user.id) throw new TRPCError({
code: 'FORBIDDEN',
message: 'Not authorized'
});
await db.posts.delete({
where: { id: postId }
});
return { success: true };
})
});
And here’s how we’d use it in our frontend:
// components/PostList.tsx
import { useState } from 'react';
import { trpc } from '../utils/trpc';
function PostList() {
const utils = trpc.useContext();
const [cursor, setCursor] = useState<string | undefined>(undefined);
const { data, hasNextPage, fetchNextPage } =
trpc.posts.list.useInfiniteQuery(
{ limit: 10 },
{ getNextPageParam: (lastPage) => lastPage.nextCursor }
);
const createPost = trpc.posts.create.useMutation({
onSuccess: () => {
// Invalidate and refetch
utils.posts.list.invalidate();
}
});
const deletePost = trpc.posts.delete.useMutation({
onSuccess: () => {
utils.posts.list.invalidate();
}
});
return (
<div>
{data?.pages.map((page) =>
page.items.map((post) => (
<PostCard
key={post.id}
post={post}
onDelete={() => deletePost.mutate(post.id)}
/>
))
)}
{hasNextPage && (
<button onClick={() => fetchNextPage()}>
Load More
</button>
)}
</div>
);
}
Advanced Patterns
1. Middleware for Authentication
const isAuthed = t.middleware(({ next, ctx }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
user: ctx.user
}
});
});
const protectedProcedure = t.procedure.use(isAuthed);
2. Error Handling
try {
await trpc.posts.create.mutate({
title: "My Post",
content: "Content",
tags: ["nextjs", "trpc"]
});
} catch (error) {
if (error instanceof TRPCError) {
switch (error.code) {
case 'UNAUTHORIZED':
// Handle unauthorized
break;
case 'BAD_REQUEST':
// Handle validation errors
break;
default:
// Handle other errors
}
}
}
When to Use tRPC
tRPC is ideal for:
- Monolithic Next.js applications
- Full-stack TypeScript projects
- Teams that value type safety and developer experience
- Projects where API performance is critical
However, consider alternatives if:
- You need to expose your API to external clients (consider REST or GraphQL)
- You’re working with multiple programming languages (consider GraphQL)
- Your team isn’t using TypeScript (consider REST or GraphQL)
Migration Strategy
If you’re considering migrating an existing Next.js application to tRPC, here’s a recommended approach:
- Start with new features
- Gradually migrate existing endpoints
- Run both tRPC and REST endpoints in parallel
- Remove old endpoints once all consumers are migrated
Conclusion
tRPC represents a paradigm shift in how we build APIs in Next.js applications. By leveraging TypeScript’s type system to its fullest extent, it eliminates an entire category of bugs and significantly improves the developer experience. While it may require a mental model shift, the benefits of end-to-end type safety and improved developer productivity make it a compelling choice for modern Next.js applications.
The best part? This is just scratching the surface. tRPC also supports subscriptions for real-time updates, batching for performance optimization, and much more. As your application grows, tRPC grows with you, providing a solid foundation for building type-safe, maintainable APIs.
Ready to get started? Check out the official tRPC documentation and join the growing community of developers building better APIs with tRPC.
Have you tried tRPC in your Next.js applications? I’d love to hear about your experiences in the comments below!