DEV Community

Atlas Whoff
Atlas Whoff

Posted on

tRPC v11 + Next.js App Router: End-to-End Type Safety Without REST

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 } });
});
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

5. Client Setup

// lib/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/root';

export const trpc = createTRPCReact<AppRouter>();
Enter fullscreen mode Exit fullscreen mode
// 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
  • caller API 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} />;
}
Enter fullscreen mode Exit fullscreen mode

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)