tRPC v11 + Next.js App Router: End-to-End Type Safety Without REST
If you're still writing REST endpoints and then duplicating types on the client, tRPC v11 eliminates that entire category of bugs.
What tRPC Actually Solves
The typical REST pain point:
// Server defines a type
type User = { id: string; email: string; plan: 'free' | 'pro' };
// Client guesses at the type (or imports from a shared package that drifts)
const res = await fetch('/api/user');
const user = res.json() as User; // ❌ cast — not verified at compile time
tRPC threads TypeScript inference from server to client automatically. No codegen, no OpenAPI spec, no shared package to maintain.
Setup with Next.js App Router
1. Install
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod
2. Create the tRPC Instance
// server/trpc.ts
import { initTRPC } from '@trpc/server';
import { ZodError } from 'zod';
const t = initTRPC.context<{ userId: string | null }>().create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
export const router = t.router;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.userId) throw new TRPCError({ code: 'UNAUTHORIZED' });
return next({ ctx: { userId: ctx.userId } });
});
3. Define Your Router
// server/routers/user.ts
import { router, protectedProcedure } from '../trpc';
import { z } from 'zod';
export const userRouter = router({
me: protectedProcedure.query(async ({ ctx }) => {
return db.user.findUnique({ where: { id: ctx.userId } });
}),
updatePlan: protectedProcedure
.input(z.object({ plan: z.enum(['free', 'pro', 'enterprise']) }))
.mutation(async ({ ctx, input }) => {
return db.user.update({
where: { id: ctx.userId },
data: { plan: input.plan },
});
}),
});
// server/root.ts
export const appRouter = router({ user: userRouter });
export type AppRouter = typeof appRouter;
4. App Router Handler
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/root';
import { auth } from '@/lib/auth';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: async () => {
const session = await auth();
return { userId: session?.user?.id ?? null };
},
});
export { handler as GET, handler as POST };
5. Client Setup
// lib/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/root';
export const trpc = createTRPCReact<AppRouter>();
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { trpc } from '@/lib/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>
);
}
6. Usage in Components
// app/dashboard/page.tsx
'use client';
import { trpc } from '@/lib/trpc';
export default function Dashboard() {
// Type is inferred from server — no cast, no guess
const { data: user } = trpc.user.me.useQuery();
const updatePlan = trpc.user.updatePlan.useMutation();
return (
<div>
<p>Plan: {user?.plan}</p> {/* autocomplete works */}
<button
onClick={() => updatePlan.mutate({ plan: 'pro' })}
disabled={updatePlan.isPending}
>
Upgrade
</button>
</div>
);
}
v11 New in 2026
-
useSuspenseQuery— first-class Suspense support, no more loading state juggling - Tanstack Router integration — type-safe route loaders that share the same query client
- Smaller bundle — tree-shakeable links, ~30% smaller than v10 client
-
callerAPI for server components — call procedures directly without HTTP in RSC
// app/dashboard/page.tsx (Server Component)
import { appRouter } from '@/server/root';
import { createCallerFactory } from '@trpc/server';
const createCaller = createCallerFactory(appRouter);
export default async function Dashboard() {
const caller = createCaller({ userId: await getUserId() });
const user = await caller.user.me(); // direct call, no HTTP
return <DashboardClient initialUser={user} />;
}
When to Use tRPC vs REST
| Scenario | tRPC | REST |
|---|---|---|
| Full-stack TS monorepo | ✅ | Overkill |
| External API for third parties | ❌ | ✅ |
| Mobile clients (non-TS) | ❌ | ✅ |
| Internal SaaS admin | ✅ | Overkill |
| Microservices between teams | Depends | ✅ |
tRPC is the right default for solo SaaS builders. You get the type safety of GraphQL with none of the schema overhead.
Ship Your SaaS Faster
The AI SaaS Starter Kit ships with tRPC v11 pre-wired to Next.js App Router, Clerk auth, and Drizzle ORM — auth-protected routes, type-safe API, and database queries ready on day one.
$99 one-time → whoffagents.com
Using tRPC in production? What pattern has saved you the most time? Drop it in the comments.
Top comments (0)