The Problem: Your API Lies to You
You rename a field on the backend. The database migration runs fine. The server starts without errors. Then, two days later, a bug report hits your inbox — the frontend is crashing because it still references the old field name.
This is not an edge case. It happens regularly in teams that maintain separate REST or GraphQL APIs alongside a TypeScript frontend. Types exist on the server. Types exist on the client. Nothing enforces they stay in sync. The compiler gives you a false sense of safety.
I hit this exact wall on a production Next.js project. We had a /api/users endpoint returning a user object, and I was consuming it on the frontend with hand-written TypeScript interfaces. One backend change slipped through code review, we deployed, and within 15 minutes users were seeing a blank profile page. That was the moment I started looking for something better.
Root Cause: The API Contract Lives in Two Places
This is not primarily a tooling problem — it is an architecture problem. When your backend and frontend are separate type systems with a network boundary between them, you have two sources of truth for the same contract. REST APIs describe data through documentation or OpenAPI specs. GraphQL solves part of this with a schema, but you still need code generation and a build step before client types are usable.
Each approach adds its own friction:
- OpenAPI requires maintaining a spec file and running a generator after every route change
- GraphQL needs a schema, a codegen pipeline, and a separate client library
- Hand-written interfaces require discipline and trust — neither scales past a three-person team
What you actually want is a single source of truth that both sides of the network share — with no code generation step at all.
Solutions Compared
Option 1: OpenAPI + Code Generation
Write an OpenAPI YAML spec, run a generator, get TypeScript types on the client. It works. The catch: the spec is a third artifact that drifts from the actual implementation. A developer updates a route handler, forgets the spec, and now your generated types are wrong while the compiler stays silent.
Option 2: GraphQL with Apollo or urql
GraphQL enforces a schema at runtime and codegen produces typed queries. For public APIs with many external consumers, it is genuinely the right call. But inside a private Next.js app where you own both ends, the overhead stacks up fast: schema definition language, resolvers, codegen config, cache management. A lot of infrastructure for a problem that does not need it.
Option 3: tRPC — One Router, Two Ends
tRPC takes a fundamentally different approach. Define your API as a TypeScript router on the server. The client imports that router’s type — not the implementation — and gets full autocompletion and compile-time safety with zero code generation. Rename a procedure or change its output shape, and TypeScript errors appear immediately in every file that calls it.
This works because Next.js already runs both server and client code in the same monorepo. tRPC is built around that fact.
Setting Up tRPC in a Next.js Project
Below is the full setup from scratch. I have shipped this in two production apps — one with around 30 procedures, one with closer to 80. In both cases, zero API-contract bugs made it past local development.
Step 1: Install Dependencies
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query zod
Zod handles input validation and doubles as the schema layer for procedure inputs. Write validation once and get both runtime safety and TypeScript inference from the same definition.
Step 2: Initialize tRPC on the Server
Create src/server/trpc.ts:
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;
Step 3: Define Your Router
Create src/server/routers/user.ts:
import { z } from 'zod';
import { router, publicProcedure } from '../trpc';
export const userRouter = router({
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
// Replace with your actual DB call
return {
id: input.id,
name: 'Alice',
email: '[email protected]',
};
}),
create: publicProcedure
.input(
z.object({
name: z.string().min(1),
email: z.string().email(),
})
)
.mutation(async ({ input }) => {
// Insert into DB
return { id: 'new-id', ...input };
}),
});
Then assemble the root router in src/server/routers/_app.ts:
import { router } from '../trpc';
import { userRouter } from './user';
export const appRouter = router({
user: userRouter,
});
export type AppRouter = typeof appRouter;
That AppRouter type is the only thing crossing the server–client boundary. No implementation details leak through.
Step 4: Mount the API Handler in Next.js
Create src/pages/api/trpc/[trpc].ts for the Pages Router, or src/app/api/trpc/[trpc]/route.ts for the App Router:
// Pages Router version
import { createNextApiHandler } from '@trpc/server/adapters/next';
import { appRouter } from '../../../server/routers/_app';
export default createNextApiHandler({
router: appRouter,
createContext: () => ({}),
});
Step 5: Configure the Client
Create src/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',
}),
],
};
},
});
Step 6: Wrap Your App
In src/pages/_app.tsx:
import type { AppType } from 'next/app';
import { trpc } from '../utils/trpc';
const MyApp: AppType = ({ Component, pageProps }) => {
return <Component {...pageProps} />;
};
export default trpc.withTRPC(MyApp);
Step 7: Call Procedures from a Component
import { trpc } from '../utils/trpc';
export default function UserProfile({ userId }: { userId: string }) {
const { data, isLoading } = trpc.user.getById.useQuery({ id: userId });
if (isLoading) return <p>Loading...</p>;
return (
<div>
<h1>{data?.name}</h1>
<p>{data?.email}</p>
</div>
);
}
Now rename email to emailAddress in the router. Every component referencing data.email immediately shows a TypeScript error — before you run the app, before you open a browser, before any user sees a crash.
What You Get Out of the Box
- Autocomplete on procedure calls — your editor knows every available route and its expected inputs
- Compile-time errors on shape mismatches — rename a field server-side and all callers break at build time, not at runtime
- Automatic request batching — multiple
useQuerycalls in the same render cycle are combined into one HTTP request automatically - React Query integration — caching, refetching, and loading states come included via
@tanstack/react-query - Zero code generation — no YAML files, no build scripts, no generated folders cluttering your repo
When tRPC Is the Wrong Tool
tRPC shines when your backend and frontend share a TypeScript monorepo and one team owns both. It is the wrong pick for public APIs consumed by third-party clients, mobile apps built in Swift or Kotlin, or situations where the backend and frontend repos deploy on separate schedules. In those cases, OpenAPI or GraphQL remain the more practical choices.
For a Next.js app where a single team owns the full stack, though, tRPC cuts an entire class of bugs without adding meaningful complexity. Initial setup takes about 20 minutes. After that, the compiler enforces the contract — permanently, automatically, with no extra process required.

