DEV Community

Atlas Whoff
Atlas Whoff

Posted on

tRPC v11 + Next.js App Router: end-to-end type safety in 2026

tRPC v11 with Next.js App Router gives you something no other API approach does: your frontend and backend share the same TypeScript types, automatically, with zero code generation.

No OpenAPI specs. No GraphQL schemas. No fetch wrapper types you maintain by hand. Change a backend function's return type and your frontend gets a type error instantly.

Here's how to set it up in 2026 and where it actually beats REST.

The setup

npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query zod
Enter fullscreen mode Exit fullscreen mode

1. Define your router (server-side):

// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';

const t = initTRPC.context<{ userId?: string }>().create();

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
// server/routers/posts.ts
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';

export const postsRouter = router({
  list: publicProcedure
    .input(z.object({
      limit: z.number().min(1).max(100).default(10),
      cursor: z.string().optional(),
    }))
    .query(async ({ input }) => {
      const posts = await db.post.findMany({
        take: input.limit + 1,
        cursor: input.cursor ? { id: input.cursor } : undefined,
        orderBy: { createdAt: 'desc' },
      });

      let nextCursor: string | undefined;
      if (posts.length > input.limit) {
        const next = posts.pop();
        nextCursor = next?.id;
      }

      return { posts, nextCursor };
    }),

  create: protectedProcedure
    .input(z.object({
      title: z.string().min(1).max(200),
      content: z.string().min(1),
      published: z.boolean().default(false),
    }))
    .mutation(async ({ input, ctx }) => {
      return db.post.create({
        data: { ...input, authorId: ctx.userId },
      });
    }),

  delete: protectedProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ input, ctx }) => {
      const post = await db.post.findUnique({ where: { id: input.id } });
      if (post?.authorId !== ctx.userId) {
        throw new TRPCError({ code: 'FORBIDDEN' });
      }
      return db.post.delete({ where: { id: input.id } });
    }),
});
Enter fullscreen mode Exit fullscreen mode

2. Create the App Router handler:

// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers';
import { getServerSession } from 'next-auth';

const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext: async () => {
      const session = await getServerSession();
      return { userId: session?.user?.id };
    },
  });

export { handler as GET, handler as POST };
Enter fullscreen mode Exit fullscreen mode

3. Use it in React components:

'use client';
import { trpc } from '@/lib/trpc-client';

export function PostsList() {
  const { data, isLoading, fetchNextPage, hasNextPage } = 
    trpc.posts.list.useInfiniteQuery(
      { limit: 10 },
      { getNextPageParam: (lastPage) => lastPage.nextCursor }
    );

  const createPost = trpc.posts.create.useMutation({
    onSuccess: () => {
      // Automatically invalidate and refetch
      utils.posts.list.invalidate();
    },
  });

  if (isLoading) return <div>Loading...</div>;

  return (
    <div>
      {data?.pages.flatMap(page => page.posts).map(post => (
        <PostCard key={post.id} post={post} />
        {/* post is fully typed — post.title, post.content, post.createdAt */}
      ))}

      {hasNextPage && (
        <button onClick={() => fetchNextPage()}>Load more</button>
      )}

      <button onClick={() => createPost.mutate({
        title: 'New Post',    // TypeScript enforces this matches the Zod schema
        content: 'Content',
        published: true,
      })}>
        Create Post
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Where tRPC beats REST

1. Refactoring is safe

Rename a field in your database schema:

// Before: post.title
// After: post.headline

// tRPC: every component using post.title gets a TypeScript error immediately
// REST: you find out at runtime, maybe in production
Enter fullscreen mode Exit fullscreen mode

2. Autocomplete everywhere

Type trpc.posts. and your IDE shows you every available procedure: list, create, delete. Type .create.mutate({ and you see every field with its type. This is real — not generated types, not a separate step. The types flow from server to client through TypeScript's type inference.

3. Input validation is the type definition

With REST, you define types AND validation separately:

// REST: define type + validate separately
interface CreatePostInput { title: string; content: string; }
// Plus: validate at runtime with a separate schema
Enter fullscreen mode Exit fullscreen mode

With tRPC + Zod:

// tRPC: Zod schema IS the type AND the validation
.input(z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1),
}))
// TypeScript infers the type. Zod validates at runtime. One source of truth.
Enter fullscreen mode Exit fullscreen mode

Where REST still wins

1. Public APIs consumed by third parties

tRPC is TypeScript-to-TypeScript. If your API is consumed by mobile apps, Python services, or external developers, REST with OpenAPI is the better choice. tRPC clients need TypeScript and your router type.

2. Simple CRUD with no business logic

If your API is just forwarding data between a database and a UI with no complex logic, tRPC's setup overhead isn't worth it. A few fetch calls with typed responses work fine.

3. Teams where not everyone knows TypeScript

tRPC's value proposition is TypeScript inference. If half your team works in a different language, the end-to-end type safety doesn't help them.

Subscriptions (real-time)

tRPC v11 supports WebSocket subscriptions:

// server
export const appRouter = router({
  onNewPost: publicProcedure.subscription(async function* () {
    while (true) {
      const post = await waitForNewPost(); // Your event source
      yield post;
    }
  }),
});

// client
trpc.onNewPost.useSubscription(undefined, {
  onData: (post) => {
    // New post arrived in real-time
    queryClient.setQueryData(['posts'], (old) => [...old, post]);
  },
});
Enter fullscreen mode Exit fullscreen mode

Performance: batching

tRPC automatically batches multiple procedure calls made in the same render cycle into a single HTTP request:

// These three calls become ONE HTTP request
const posts = trpc.posts.list.useQuery({ limit: 10 });
const user = trpc.users.me.useQuery();
const stats = trpc.analytics.dashboard.useQuery();
Enter fullscreen mode Exit fullscreen mode

No manual batching logic. No GraphQL-style query composition. tRPC handles it transparently.

The practical pattern

For most SaaS apps, this is the structure that works:

server/
  trpc.ts              # tRPC init, middleware
  routers/
    index.ts           # Root router (merges sub-routers)
    posts.ts           # Posts CRUD
    users.ts           # User management
    billing.ts         # Stripe integration
    analytics.ts       # Dashboard data
app/
  api/trpc/[trpc]/
    route.ts           # Single API handler
lib/
  trpc-client.ts       # Client setup (React Query integration)
Enter fullscreen mode Exit fullscreen mode

Each router file is self-contained: inputs, outputs, business logic, authorization. Adding a new API endpoint is adding a new procedure to a router file. No route registration, no controller boilerplate, no separate type files.


The AI SaaS Starter Kit is built on this exact pattern — tRPC v11 with Next.js App Router, Prisma for the database layer, and Zod for validation. Every API call is fully typed end-to-end, from the Prisma model to the React component. Zero manual type synchronization.

tRPC docs: trpc.io. The "Quickstart" guide for Next.js App Router is the best place to start.

Top comments (0)