tRPC + Next.js Guide: End-to-End Type Safety Without GraphQL
Build fully type-safe APIs in Next.js with tRPC — no schema files, no codegen, no GraphQL overhead. Here's exactly how to set it up.
Why tRPC Instead of GraphQL
GraphQL is great — until you're three months into a solo or small-team Next.js project and you're maintaining a schema file, a codegen config, resolvers, and a client query layer just to fetch a user object. That's a lot of ceremony for what amounts to a typed function call.
tRPC flips the model. You define procedures on the server in TypeScript, and the client imports the *types* directly — not generated code, not a separate schema, just the TypeScript type system doing what it was built for. The round-trip is your actual function. Honestly, the first time you see autocomplete on a server response in a React component without writing a single type annotation, it feels almost like cheating.
Worth noting: tRPC works best in monorepos or full-stack Next.js apps where the server and client live in the same repo. It doesn't replace GraphQL if you're building a public API consumed by third parties — for that, GraphQL or REST still makes more sense. But for internal full-stack apps in 2026? tRPC is genuinely the right default.
It ships with first-class support for React Query v5, Zod validation, and Next.js App Router. The ecosystem has matured significantly since the v10 release in 2022, and the v11 adapter for App Router specifically is solid.
Project Setup
Start with a fresh Next.js project or drop this into an existing one. You'll need a few packages:
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query zodCreate a server/trpc.ts file — this is your tRPC instance and where you'll define context (like database access or auth sessions). Keep it small and focused:
// 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;That's literally the base. No schema language, no SDL files, no npx graphql-codegen in your package.json. You're just writing TypeScript.
Building Your First Router
Routers in tRPC are just objects that group related procedures. Think of them like Express route groups, except they carry full type information end-to-end. Here's a minimal user router:
// server/routers/user.ts
import { router, publicProcedure } from '../trpc';
import { z } from 'zod';
export const userRouter = router({
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
// Your DB call here — Prisma, Drizzle, whatever
return { id: input.id, name: 'Ada Lovelace', email: 'ada@example.com' };
}),
create: publicProcedure
.input(z.object({
name: z.string().min(2),
email: z.string().email(),
}))
.mutation(async ({ input }) => {
// Insert into DB
return { id: 'new-id', ...input };
}),
});Zod handles runtime validation on inputs. If someone sends { id: 42 } to getById, tRPC rejects it before your resolver even runs — the schema is your contract and your validator at the same time. No separate validation middleware needed.
Merge multiple routers into an app router and export the type:
// 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 export is the entire bridge between your server and client. One type. That's it.
Wiring Up the Next.js App Router Handler
In the App Router, tRPC runs as a standard Route Handler. Create app/api/trpc/[trpc]/route.ts:
// 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 };Quick aside: the createContext function is where you'd inject auth — parse the session cookie, grab the user from the DB, and return it. Every procedure then gets access to ctx.user. Don't skip this in production; it's the right place to establish trust boundaries.
For the client side, create a tRPC client configured with your AppRouter type. Using React Query v5 here since that's what ships with tRPC v11:
// utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/routers/_app';
export const trpc = createTRPCReact<AppRouter>();Then wrap your app in the provider. In Next.js App Router, that means a client component — typically app/providers.tsx:
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { trpc } from '@/utils/trpc';
import { useState } from 'react';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({ url: '/api/trpc' }),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}Using tRPC in React Components
This is where it pays off. In any client component, you get full autocompletion on your API — procedure names, input shapes, response types — all inferred without a single explicit type annotation:
'use client';
import { trpc } from '@/utils/trpc';
export function UserCard({ userId }: { userId: string }) {
const { data, isLoading, error } = trpc.user.getById.useQuery({ id: userId });
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<h2>{data.name}</h2>
<p>{data.email}</p>
</div>
);
}Notice data.name and data.email are fully typed — TypeScript knows the shape from your server-side return type. If you rename name to fullName on the server, every client reference breaks at compile time. That's the whole point.
Mutations work the same way. You'd hook into trpc.user.create.useMutation() and TypeScript will enforce the input shape before it even hits the network. In practice, this catches a large class of bugs that would otherwise show up as runtime errors or failed API calls in production.
Look, you could get similar guarantees with GraphQL + codegen, but you'd have to run the codegen step, commit generated files, and keep them in sync. With tRPC you just write TypeScript and the types flow naturally. The 4-word version: it's just functions.
Protected Procedures and Middleware
Real apps need auth. tRPC middleware lets you create a protectedProcedure that enforces a valid session before the resolver runs — similar to Express middleware but typed end-to-end:
// server/trpc.ts (updated)
import { initTRPC, TRPCError } from '@trpc/server';
import { getServerSession } from 'next-auth';
interface Context {
session: Awaited<ReturnType<typeof getServerSession>> | null;
}
const t = initTRPC.context<Context>().create();
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({ ctx: { ...ctx, session: ctx.session } });
});
export const router = t.router;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(isAuthed);Now any procedure built on protectedProcedure gets the session in context with full types. No casting, no null checks scattered through your resolvers. The middleware handles it once at 0px overhead in your business logic.
You can stack middleware too — rate limiting, role checks, logging, whatever. Each layer is just a TypeScript function. That said, don't go overboard early; start with auth and add complexity only when you actually need it.
One more thing — tRPC errors map to HTTP status codes automatically. UNAUTHORIZED becomes 401, NOT_FOUND becomes 404, INTERNAL_SERVER_ERROR becomes 500. Your frontend's error handling stays clean without any manual status code checking.
Server Components + tRPC: The Caller Pattern
One pain point with tRPC in Next.js App Router is that useQuery only works in client components. For Server Components — which is where you *want* to fetch data in the App Router model — you need a direct server caller instead of going through HTTP:
// server/caller.ts
import { appRouter } from './routers/_app';
export const serverCaller = appRouter.createCaller({});
// or with auth context:
// export const createServerCaller = (ctx: Context) => appRouter.createCaller(ctx);Then in a Server Component you call it directly, no fetch, no HTTP round-trip:
// app/users/[id]/page.tsx (Server Component)
import { serverCaller } from '@/server/caller';
export default async function UserPage({ params }: { params: { id: string } }) {
const user = await serverCaller.user.getById({ id: params.id });
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}This is the pattern you actually want for initial page loads — zero network overhead, full type safety, works perfectly with Next.js static and dynamic rendering. Client components still use useQuery for interactive data. You end up with a clean split: server fetching for initial render, tRPC hooks for user-triggered updates.
Pair this approach with a well-structured UI component library — if you're building something that needs to look polished, browse Empire UI components to avoid rebuilding common patterns. The time you save on API boilerplate with tRPC is time you can spend on glassmorphism components or whatever your design system needs.
FAQ
Yes — tRPC v11 ships a fetch adapter that works as a standard Route Handler. You use fetchRequestHandler in app/api/trpc/[trpc]/route.ts for client-side queries, and the direct caller pattern for Server Components.
Technically no — tRPC supports other validators like Yup or Valibot. But Zod is the de facto standard and integrates the most cleanly. It handles runtime input validation and gives you inferred TypeScript types for free, so there's rarely a good reason to swap it out.
It doesn't, natively — tRPC is JSON over HTTP. For file uploads you'd use a regular Next.js Route Handler or a service like Uploadthing alongside your tRPC API. That said, you can trigger post-upload actions (like saving metadata) through tRPC mutations just fine.
Yes. It's been production-stable since v10 and the v11 release cleaned up the App Router story significantly. Large codebases use it; the main constraint is that it's best for monorepo setups where client and server share a TypeScript codebase.