Next.js APIs with tRPC: A Developer's Guide to End-to-End Type Safety

·8 min read

blog/nextjs-apis-with-trpc

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:

  1. Type definitions are duplicated between frontend and backend
  2. API responses aren’t automatically validated against your types
  3. No autocomplete for API endpoints or their parameters
  4. 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:

  1. Your API types are always in sync
  2. You get full autocomplete support
  3. Type errors are caught during development
  4. 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 fetching
  • zod: 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:

  1. Start with new features
  2. Gradually migrate existing endpoints
  3. Run both tRPC and REST endpoints in parallel
  4. 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!

Enjoyed this article? Subscribe for more!

Stay Updated

Get my new content delivered straight to your inbox. No spam, ever.

Related PostsTags: tRPC, Development, Nextjs, API

© 2024 Comyoucom Ltd. Registered in England & Wales