"We Already Have TypeScript, Why Does The API Still Feel Untyped?"
I keep hearing some version of this question in code reviews. The team writes TypeScript on the server. They write TypeScript on the client. They feel covered. Then someone renames a field on a response, ships it, and three components silently break in production because the network call between them was a fetch returning any.
The two ends of your stack are typed islands. The bytes traveling between them are not. Anything that crosses JSON.parse arrives as unknown in the honest reading and any in the lazy one. You can paper over it with hand-written response interfaces, but those drift the moment the server changes and nobody updates the client.
tRPC is one of the few tools that fixes this without adding a build step. If your frontend and backend live in the same repo, tRPC v11 (released stable in February 2025 and the current line) lets the client read the server's types directly. No schema file. No codegen. No GraphQL.
What tRPC Actually Is
The pitch in one sentence: you write a router on the server, export its type (not its code) to the client, and the client gets a fully typed function-call API for every procedure on the router.
The runtime behavior is unglamorous — it sends JSON over HTTP, batches requests, handles errors. The interesting part is what doesn't ship. There's no schema language, no generated client, no two sources of truth. The contract is the router type itself, which already exists because you wrote the router.
Three pieces matter:
initTRPC— bootstraps the server-side builder with your context type and any transformers (superjson, devalue) you want.- Procedures and routers — the building blocks. A procedure is a single endpoint; a router is a tree of them.
- A client adapter — for Next.js the tRPC team now recommends
@trpc/tanstack-react-query, a newer integration that returns native TanStack Query options (queryOptions,mutationOptions) instead of wrapping the hooks. The older@trpc/react-queryis still maintained and works fine on existing projects, but new code should reach for the TanStack-native client.
Defining A Router
Server side, in an App Router project. The shape is the same on the Pages Router, only the handler location differs.
// src/server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import superjson from 'superjson';
import type { Context } from './context';
const t = initTRPC.context<Context>().create({
transformer: superjson,
});
export const router = t.router;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.session?.userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({ ctx: { ...ctx, userId: ctx.session.userId } });
});
// src/server/routers/user.ts
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';
import { db } from '@/lib/db';
export const userRouter = router({
byId: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input }) => {
const user = await db.user.findUnique({ where: { id: input.id } });
if (!user) throw new TRPCError({ code: 'NOT_FOUND' });
return { id: user.id, name: user.firstName, role: user.role };
}),
updateName: protectedProcedure
.input(z.object({ name: z.string().min(1).max(80) }))
.mutation(async ({ input, ctx }) => {
return db.user.update({
where: { id: ctx.userId },
data: { firstName: input.name },
});
}),
});
export const appRouter = router({ user: userRouter });
export type AppRouter = typeof appRouter;
That last export type AppRouter line is the only thing the client needs from this file. It's a type, so it costs zero bytes at runtime — TypeScript erases it.
Wiring The Handler In App Router
A single fetch handler at app/api/trpc/[trpc]/route.ts is enough.
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers/user';
import { createContext } from '@/server/context';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: () => createContext(req),
});
export { handler as GET, handler as POST };
createContext is where you read cookies, look up sessions, and build the ctx object that every procedure receives. Treat it like middleware that runs once per request.
The Client Side With React Query
The recommended adapter for new apps is @trpc/tanstack-react-query. It plugs tRPC into TanStack Query v5 so you get caching, dedupe, invalidation, optimistic updates — all the things you'd otherwise wire up by hand. The example below uses the older @trpc/react-query API for clarity, since most existing tutorials still show that shape; the equivalent in the newer client returns queryOptions you pass to TanStack Query's useQuery directly.
// src/lib/trpc.ts
'use client';
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/routers/user';
export const trpc = createTRPCReact<AppRouter>();
// src/components/UserProfile.tsx
'use client';
import { trpc } from '@/lib/trpc';
export function UserProfile({ userId }: { userId: string }) {
const { data, isPending, error } = trpc.user.byId.useQuery({ id: userId });
if (isPending) return <div>Loading…</div>;
if (error) return <div>{error.message}</div>;
return (
<article>
<h1>{data.name}</h1>
<p>Role: {data.role}</p>
</article>
);
}
If you go back to the router and rename name to displayName, your editor underlines data.name here within seconds. No build step ran. The type traveled across the import graph because AppRouter is just a TypeScript symbol.
Server Components And Prefetching
App Router complicates the story slightly because Server Components don't use hooks. The pattern that works well is to call procedures directly on the server, then hydrate the React Query cache for any Client Components that re-fetch the same data.
// app/users/[id]/page.tsx
import { appRouter } from '@/server/routers/user';
import { createContext } from '@/server/context';
import { UserProfile } from '@/components/UserProfile';
export default async function UserPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const ctx = await createContext();
const caller = appRouter.createCaller(ctx);
const user = await caller.user.byId({ id });
return <UserProfile userId={id} initialData={user} />;
}
createCaller invokes a procedure as a plain async function — no HTTP round trip. Useful in Server Components, server actions, cron jobs, scripts.
Errors And Validation
tRPC errors are a single shape (TRPCError with a code like UNAUTHORIZED, BAD_REQUEST, NOT_FOUND). Combined with zod input validation, you get a clean separation: zod handles "the input was malformed," your procedure body handles "the input was valid but something went wrong." Zod errors arrive on the client with field-level messages intact, so form UIs render meaningful feedback without extra wiring.
Where tRPC Stops Being The Right Choice
The constraint is structural: tRPC needs both ends to import the same TypeScript symbol. That works when the frontend and backend live in one repo, or in a monorepo with a shared package. It does not work when:
- The backend is Go, Python, Rust, or anything that isn't TypeScript.
- You're shipping a public API that third parties consume.
- Mobile clients (Swift, Kotlin) need to call the same endpoints.
- The frontend and backend are deployed and versioned independently and you need a documented schema contract.
For those cases the answer is OpenAPI — generate a schema from your backend, generate a typed client on the frontend, accept the codegen step. Different tradeoffs, similar end result for the client.
A Mental Model
tRPC is the version of "fullstack TypeScript" where the network is a function call. You don't think about HTTP, you don't think about JSON, and you don't think about whether the response shape matches your interface — because there is no separate interface. The procedure return type is the response type, and your editor proves it every time you save the file.

![World map dissolving into folder tabs for en, de, uk, ja, fr feeding a /[locale] route, with Accept-Language, NEXT_LOCALE cookie, and URL prefix chips routed into a best-fit matcher.](/_next/image?url=%2Fassets%2Fimgs%2Farticles%2Finternationalization-in-nextjs-apps%2Fcover.png&w=2048&q=75)



