0Pricing

Mastering tRPC: Best Practices for Robust & Type-Safe APIs (Post 2/5)

Dive into best practices for building robust, scalable, and end-to-end type-safe APIs with tRPC, covering modularization, validation, context management, error handling, and client-side tips.

T
tRPC End-to-End Type Safe APIs · 8 min read · 1,601 words

Welcome back, CoddyKit learners! In our first post, we introduced tRPC and explored how it revolutionizes API development by providing end-to-end type safety without code generation. You learned the basics of setting it up and creating your first procedures. Now that you're familiar with the 'what' and 'how' of getting started, it's time to elevate your tRPC game.

This second installment in our 5-part series is all about best practices and tips. Building an API isn't just about making it work; it's about making it maintainable, scalable, and a joy to work with for you and your team. tRPC's unique approach offers specific opportunities for optimization and organization that we'll explore today.

1. Modularization: Keep Your Router Tidy

As your application grows, a single, monolithic tRPC router can quickly become unwieldy. Just like you'd organize your frontend components or backend services into logical units, your tRPC procedures deserve the same treatment. The key is to break down your root router into smaller, domain-specific sub-routers.

Why Modularize?

  • Improved Readability: It's easier to find procedures related to a specific domain (e.g., users, posts, authentication).
  • Enhanced Maintainability: Changes in one domain are less likely to impact others, making debugging and updates simpler.
  • Better Collaboration: Multiple developers can work on different parts of the API simultaneously with fewer merge conflicts.

How to Modularize:

Create separate files for each domain's router, then merge them into your main appRouter. Here's a common structure:

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

export const userRouter = router({
  getById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(({ input }) => {
      // Imagine fetching user from DB
      return { id: input.id, name: `User ${input.id}` };
    }),
  updateName: publicProcedure
    .input(z.object({ id: z.string(), name: z.string().min(3) }))
    .mutation(({ input }) => {
      // Imagine updating user in DB
      return { id: input.id, name: input.name, success: true };
    }),
});

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

export const postRouter = router({
  getAll: publicProcedure
    .query(() => {
      return [{ id: '1', title: 'My First Post' }];
    }),
});

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

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

export type AppRouter = typeof appRouter;

Now, on the client, you can call trpc.user.getById() and trpc.post.getAll(), enjoying full type safety across your modularized API.

2. Input Validation with Zod: Your API's First Line of Defense

tRPC's native integration with Zod for input validation is one of its superpowers. Don't just validate; validate effectively and consistently.

Tips for Zod Validation:

  • Granular Validation: Validate every input field, not just the presence of an object. Use Zod's rich schema capabilities (.min(), .max(), .email(), .uuid(), .refine(), etc.).
  • Reusable Schemas: Define common input schemas once and reuse them. For example, a userIdSchema can be imported across multiple procedures.
  • Transformations: Zod can also transform data. For instance, converting a string 'true'/'false' to a boolean, or trimming whitespace from strings.
// src/server/schemas.ts
import { z } from 'zod';

export const userIdSchema = z.object({
  id: z.string().uuid('Invalid user ID format. Must be a UUID.'),
});

export const postCreationSchema = z.object({
  title: z.string().min(5, 'Title must be at least 5 characters long.').max(100),
  content: z.string().optional(),
  published: z.preprocess(val => String(val).toLowerCase() === 'true', z.boolean()).default(false),
});

// src/server/routers/post.ts (using reusable schemas)
import { publicProcedure, router } from '../trpc';
import { postCreationSchema } from '../schemas';

export const postRouter = router({
  create: publicProcedure
    .input(postCreationSchema)
    .mutation(({ input }) => {
      // ... create post with validated and transformed input
      return { ...input, id: 'new-post-id' };
    }),
});

3. Context Management: The Heart of Your Request

The tRPC context is where you store request-specific data that needs to be accessible by your procedures. This typically includes database connections, authenticated user information, and other services.

Best Practices for Context:

  • Keep it Lean: Only put what's absolutely necessary for procedures to function. Overloading the context can lead to unnecessary complexity and performance issues.
  • Type Safety: Ensure your context is fully type-safe. tRPC's generic createContext function makes this straightforward.
  • Lazy Initialization: For expensive resources (like a database connection that might not be used by every procedure), consider lazy initialization within the context function.
// src/server/context.ts
import type { CreateNextContextOptions } from '@trpc/server/adapters/next';
import { prisma } from './db'; // Your Prisma client instance

interface CreateContextOptions {
  // Add any server-side specific data here
}

export const createContext = async (opts: CreateNextContextOptions) => {
  // This is where you'd typically parse headers, cookies, etc.
  // For example, extracting a user session:
  const { req } = opts;
  const user = await getUserFromSession(req); // Hypothetical function

  return {
    user,
    prisma, // Your database client
    // Other services like a logger, external API clients, etc.
  };
};

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

// src/server/trpc.ts (updated to use Context)
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;
export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  return next({ ctx: { ...ctx, user: ctx.user } }); // Refine ctx type
});

4. Robust Error Handling with TRPCError

Errors are inevitable. How you handle them defines the robustness of your API. tRPC provides TRPCError for structured error reporting.

Tips for Error Handling:

  • Use TRPCError: Always throw new TRPCError({ code: 'YOUR_CODE', message: 'User-friendly message' }). This ensures consistent error responses that your client can easily parse.
  • Map HTTP Status Codes: TRPCError codes (e.g., NOT_FOUND, UNAUTHORIZED, BAD_REQUEST) map directly to standard HTTP status codes, which is crucial for client-side handling.
  • Avoid Leaking Sensitive Info: Never expose raw database errors or internal server details directly to the client. Use a generic message for unexpected errors and log the full error server-side.
  • Global Error Handler: tRPC allows you to define a global error handler in createTRPCContext or createHTTPServer to catch and log unhandled errors gracefully.
// Inside a procedure
protectedProcedure
  .input(userIdSchema)
  .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.`,
      });
    }
    return user;
  });

5. Client-Side Best Practices: Optimizing User Experience

While tRPC shines on the backend, optimizing its usage on the client (especially with React Query/TanStack Query) is crucial for a smooth user experience.

Client-Side Tips:

  • Query Invalidation: After a mutation (e.g., creating a post, updating a user), invalidate relevant queries to refetch fresh data. This is more efficient than manually updating the cache.
  • Optimistic Updates: For mutations that are likely to succeed, perform an optimistic update on the UI. This makes the application feel incredibly fast. If the mutation fails, revert the UI state.
  • Error Boundaries: Wrap parts of your UI with React Error Boundaries to gracefully handle errors from tRPC queries/mutations without crashing the entire application.
  • Loading States: Always provide clear loading states for queries and mutations to inform the user that something is happening.
// Client-side React component
import { trpc } from '../utils/trpc';
import { useQueryClient } from '@tanstack/react-query';

function UserProfile({ userId }: { userId: string }) {
  const queryClient = useQueryClient();
  const { data: user, isLoading, error } = trpc.user.getById.useQuery({ id: userId });

  const updateUserMutation = trpc.user.updateName.useMutation({
    onMutate: async (newUserData) => {
      // Optimistic update
      await queryClient.cancelQueries(['user', 'getById', { id: userId }]);
      const previousUserData = queryClient.getQueryData(['user', 'getById', { id: userId }]);
      queryClient.setQueryData(['user', 'getById', { id: userId }], (old) =>
        old ? { ...old, name: newUserData.name } : old
      );
      return { previousUserData };
    },
    onError: (err, newUserData, context) => {
      // Revert if mutation fails
      if (context?.previousUserData) {
        queryClient.setQueryData(['user', 'getById', { id: userId }], context.previousUserData);
      }
      console.error('Update failed:', err);
    },
    onSettled: () => {
      // Invalidate to refetch fresh data after mutation completes (success or failure)
      queryClient.invalidateQueries(['user', 'getById', { id: userId }]);
    },
  });

  if (isLoading) return <p>Loading user...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <div>
      <h1>{user?.name}</h1>
      <button onClick={() => updateUserMutation.mutate({ id: userId, name: 'Jane Doe' })}>
        Change Name to Jane Doe
      </button>
    </div>
  );
}

6. Middleware for Cross-Cutting Concerns

tRPC middleware is perfect for handling concerns that cut across multiple procedures, such as authentication, logging, and rate limiting. This keeps your procedures clean and focused on business logic.

// src/server/trpc.ts (building on previous context example)
import { initTRPC, TRPCError } from '@trpc/server';
import type { Context } from './context';

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

// A logging middleware
const loggingMiddleware = t.middleware(async ({ path, type, next }) => {
  const start = Date.now();
  const result = await next();
  const durationMs = Date.now() - start;
  console.log(`[${result.ok ? 'OK' : 'ERROR'}] ${type} ${path} - ${durationMs}ms`);
  return result;
});

// A protected procedure that requires authentication
export const protectedProcedure = t.procedure.use(loggingMiddleware).use(async ({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Not authenticated' });
  }
  return next({ ctx: { ...ctx, user: ctx.user } }); // Ensure 'user' is non-nullable downstream
});

export const publicProcedure = t.procedure.use(loggingMiddleware);
export const router = t.router;

Conclusion

By adopting these best practices, you'll not only build tRPC APIs that are type-safe and efficient but also highly maintainable and enjoyable to develop. Modularization keeps your codebase clean, robust validation protects your data, thoughtful context management streamlines data access, and smart client-side strategies deliver a superior user experience.

In our next post (Post 3/5), we'll shift gears and discuss common mistakes developers make when working with tRPC and, more importantly, how to avoid them. Stay tuned!

ProgrammingTutorialCoddyKit

Enjoyed this article?

Explore more tutorials and insights to level up your coding skills.

Browse All Articles →