Build End-to-End Type-Safe APIs with tRPC and Next.js

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Real-World Problem: The Cost of Fractured API Contracts

During a sprint shipping a multi-currency checkout redesign, our team hit a classic production snag. A backend engineer renamed a response field from userId to accountId. Unit tests passed. Integration suites turned green. The CI/CD pipeline deployed without a blip.

Then Sentry started screaming. In less than 20 minutes, 140 checkout attempts failed with TypeError: Cannot read properties of undefined. Client components still expected response.userId.

Both our Next.js frontend and Node.js backend ran on TypeScript. Yet runtime errors slipped through anyway. Writing TypeScript on both ends doesn’t mean your network boundary is type-safe.

Root Cause Analysis: Why Full-Stack TypeScript Fails at the Boundary

Most teams bridge the client-server gap with manual schema copies or drift-prone generators. The everyday workflow looks like this:

  • Backend engineers build endpoints and export response DTOs.
  • Frontend engineers copy those interfaces over manually, or pull them from an OpenAPI spec that someone forgot to regenerate before merging a PR.
  • Neither native fetch() nor axios verifies the wire payload at compile time. TypeScript simply trusts your as UserProfile cast.

That false sense of security burns teams. When you cast a response type, you strip away the compiler’s safety net. Even when your code lives in the same Next.js repository, client and server still behave like strangers talking past each other.

Solutions Compared: REST, GraphQL, and tRPC

How do modern architectures handle full-stack communication? Here is how the top three stack up.

1. REST with OpenAPI Codegen

You write endpoints, document them in a 600-line YAML file, and run CLI scripts to generate frontend SDKs.

  • Pros: Universal standard; language-agnostic; decoupled architectures.
  • Cons: Tedious maintenance. Update a field on the server, and your frontend types stay stale until you re-run generation. Feedback is sluggish.

2. GraphQL with Apollo or Relay

GraphQL curbs over-fetching through a rigid schema definition language (SDL) and client-driven queries.

  • Pros: Flexible querying; built-in introspection; battle-tested on massive data graphs across multiple client platforms.
  • Cons: Steep boilerplate penalty. You need schemas, resolvers, client queries, and build-time tooling like graphql-codegen. If your stack is TypeScript end-to-end, this is an unnecessary tax.

3. tRPC (TypeScript Remote Procedure Call)

tRPC skips schema files and intermediate build steps entirely. Instead, your frontend directly infers types from your backend router.

  • Pros: Zero code generation. Instant auto-completion. Immediate compiler errors when a backend route signature changes.
  • Cons: Tied directly to TypeScript on both client and server. Not meant for public or third-party APIs.

Hands-On: Implementing tRPC with Next.js App Router

Pairing tRPC with Zod in Next.js creates an instant safety net. Change a backend field, and your frontend build breaks locally before you push to staging.

Step 1: Install Dependencies

Add the necessary packages to your Next.js project:

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

Step 2: Initialize Context and Router

Create an entry point (for instance, src/server/trpc.ts) to initialize the instance and export reusable procedure builders:

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

const t = initTRPC.create();

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

Step 3: Define Procedures and Input Schemas

Build your API router inside src/server/routers/app.ts. Zod handles runtime validation on input, while TypeScript infers return shapes automatically:

import { z } from 'zod';
import { router, publicProcedure } from '../trpc';

export const appRouter = router({
  getUser: publicProcedure
    .input(
      z.object({
        id: z.string().uuid(),
      })
    )
    .query(async ({ input }) => {
      // Simulated database lookup
      return {
        id: input.id,
        name: 'Alex Rivera',
        role: 'lead-engineer',
        activeProjects: 4,
      };
    }),
});

// Export only the type definition, never the implementation code
export type AppRouter = typeof appRouter;

Exporting type AppRouter = typeof appRouter is the secret sauce. Next.js leaves your server code, database logic, and secrets out of the client bundle. Only lightweight type signatures cross the wire.

Step 4: Mount the Route Handler

In the Next.js App Router, mount the fetch adapter under src/app/api/trpc/[trpc]/route.ts:

import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers/app';

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

export { handler as GET, handler as POST };

Step 5: Query from the Client

Hook up your React client helper and run the query inside a component:

'use client';

import { trpc } from '@/utils/trpc';

export function UserProfileCard({ userId }: { userId: string }) {
  const { data, isLoading, error } = trpc.getUser.useQuery({ id: userId });

  if (isLoading) return <p>Loading profile...</p>;
  if (error) return <p>Error loading user: {error.message}</p>;
  if (!data) return null;

  return (
    <div className="card">
      <h3>{data.name}</h3>
      <p>Role: {data.role}</p>
      <span>Active Projects: {data.activeProjects}</span>
    </div>
  );
}

Rename activeProjects to projectCount on your backend. Immediately, VS Code draws a red squiggly line right under data.activeProjects. You catch the regression within milliseconds—no test runs or deployment post-mortems required.

Battle-Tested Production Practices

  • Split Routers Early: Organize routes by domain (e.g., authRouter, billingRouter) and merge them into a single root router via t.mergeRouters().
  • Centralize Middleware: Chain reusable middleware to enforce authentication and rate limiting instead of scattering checks across individual queries.
  • Sanitize Inputs with Zod: Use .transform() to coerce URL query strings and ISO date stamps cleanly before they reach your handler logic.
Share: