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()noraxiosverifies the wire payload at compile time. TypeScript simply trusts youras UserProfilecast.
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 viat.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.

