· trpc / nextjs / typescript

How to set up tRPC with Next.js App Router (2026)

tRPC v11 works with Next.js 15 App Router. Covers fetchRequestHandler, SSR prefetch, the QueryClient pattern, and five gotchas that catch first-timers.

By

2,002 words · 11 min read

tRPC v11 works with Next.js 15 App Router, but the setup is different enough from Pages Router that the official docs leave several gaps. This guide covers the full path from install to a working prefetch pattern — exact package versions, every file, and the failure modes that waste an afternoon.

The short version: the App Router uses fetchRequestHandler from @trpc/server/adapters/fetch, not @trpc/next. The client uses @trpc/tanstack-react-query, not @trpc/react-query. Get those two wrong and nothing works.

Who this is for

Developers who know REST or OpenAPI and are asking whether tRPC simplifies their Next.js stack. This guide assumes TypeScript strict mode and familiarity with React Query concepts. If you’ve never used React Query before, read the TanStack Query quickstart first — tRPC’s client layer is built on top of it.

If you’re still weighing tRPC against GraphQL before committing to the setup, see tRPC vs GraphQL first.

Prerequisites

  • Node.js 18 or higher
  • TypeScript 5.7+ with "strict": true in tsconfig.json — tRPC’s inference degrades without it
  • Next.js 15 project with App Router enabled — if you’re still on Pages Router, the Pages Router to App Router migration guide has the full checklist
  • pnpm, npm, or bun

Step 1: Install packages

npm install @trpc/[email protected] @trpc/[email protected] @trpc/[email protected] @tanstack/[email protected] zod server-only client-only

The package names matter here. @trpc/tanstack-react-query is the App Router integration — it targets React Query v5 and exposes the useTRPC hook pattern. @trpc/next is for the Pages Router. @trpc/react-query is the old v4-based package. Both of those are wrong for App Router.

server-only and client-only are small npm packages that throw at build time if a file marked server-only is imported on the client, or vice versa. Use them as hard guards on your tRPC server files.

Failure mode: installing @trpc/react-query instead of @trpc/tanstack-react-query gives you React Query v4 types that are incompatible with v5 and missing the queryOptions() method the App Router pattern depends on.

Step 2: Create the folder structure

src/
├── app/
│   ├── api/trpc/[trpc]/route.ts
│   ├── layout.tsx
│   └── page.tsx
└── trpc/
    ├── init.ts
    ├── query-client.ts
    ├── server.tsx
    ├── client.tsx
    └── routers/
        └── _app.ts

Keep all tRPC files under trpc/. The split between server.tsx (server-only helpers) and client.tsx (client provider) is intentional — it prevents server code from leaking into the client bundle.

Step 3: Initialize the tRPC backend

// trpc/init.ts
import { initTRPC } from '@trpc/server';
import { cache } from 'react';

export const createTRPCContext = cache(async (opts: { headers: Headers }) => {
  // Add auth here — e.g. read session from cookies
  return { userId: 'user_123', headers: opts.headers };
});

const t = initTRPC
  .context<Awaited<ReturnType<typeof createTRPCContext>>>()
  .create();

export const createTRPCRouter = t.router;
export const createCallerFactory = t.createCallerFactory;
export const baseProcedure = t.procedure;

React’s cache() deduplicates the context function per request. Without it, multiple server components calling createTRPCContext in the same render hit your auth provider once each. With it, they all share the same result.

Step 4: Define the router

// trpc/routers/_app.ts
import { z } from 'zod';
import { baseProcedure, createTRPCRouter } from '../init';

export const appRouter = createTRPCRouter({
  hello: baseProcedure
    .input(z.object({ text: z.string() }))
    .query(({ input }) => ({
      greeting: `hello ${input.text}`,
    })),
});

export type AppRouter = typeof appRouter;

The AppRouter type export is what threads end-to-end type safety from server to client. Every client file imports this type — never the runtime router object.

Step 5: Mount the HTTP adapter

// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '~/trpc/routers/_app';
import { createTRPCContext } from '~/trpc/init';

const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext: () => createTRPCContext({ headers: req.headers }),
  });

export { handler as GET, handler as POST };

App Router uses the standard Web Fetch API. fetchRequestHandler handles that. The Pages Router used createNextApiHandler from @trpc/next, which relied on Node.js IncomingMessage — that won’t work here. Export both GET and POST; tRPC uses GET for queries and POST for mutations by default.

Failure mode: exporting only POST breaks query procedures, which default to GET. You’ll see 405 Method Not Allowed in the browser console.

Step 6: Set up the QueryClient factory

// trpc/query-client.ts
import {
  defaultShouldDehydrateQuery,
  QueryClient,
} from '@tanstack/react-query';

export function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 30 * 1000,
      },
      dehydrate: {
        shouldDehydrateQuery: (query) =>
          defaultShouldDehydrateQuery(query) ||
          query.state.status === 'pending',
      },
    },
  });
}

Two things here: staleTime: 30 * 1000 prevents the client from immediately refetching data that was just prefetched on the server. Without it, every SSR-prefetched query gets refetched on mount — you pay the round-trip twice. The shouldDehydrateQuery extension includes pending queries so streaming hydration works: the server can start a query, ship a pending promise to the client, and the client continues from where the server left off.

Step 7: Build the server-side helpers

// trpc/server.tsx
import 'server-only';
import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query';
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { cache } from 'react';
import { headers } from 'next/headers';
import { createTRPCContext } from './init';
import { makeQueryClient } from './query-client';
import { appRouter } from './routers/_app';

export const getQueryClient = cache(makeQueryClient);

export const trpc = createTRPCOptionsProxy({
  ctx: async () => createTRPCContext({ headers: await headers() }),
  router: appRouter,
  queryClient: getQueryClient,
});

export const caller = appRouter.createCaller(
  async () => createTRPCContext({ headers: await headers() }),
);

export function HydrateClient({ children }: { children: React.ReactNode }) {
  const queryClient = getQueryClient();
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      {children}
    </HydrationBoundary>
  );
}

Three exports here serve different purposes:

  • trpc is the server-side proxy for prefetching — it populates the QueryClient with data before React renders the client tree.
  • caller skips HTTP entirely and calls procedures directly. Use it when you need server-side data that never needs to reach the client (metadata generation, static props, etc.).
  • HydrateClient wraps client subtrees and ships the prefetched QueryClient state over React’s hydration boundary.

import 'server-only' at the top throws a build error if this file is ever imported from a client component. That’s the guard you want — if server-side secrets or DB connections end up in this file, they can’t leak.

Step 8: Set up the client provider

// trpc/client.tsx
'use client';
import type { QueryClient } from '@tanstack/react-query';
import { QueryClientProvider } from '@tanstack/react-query';
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import { createTRPCContext } from '@trpc/tanstack-react-query';
import { useState } from 'react';
import { makeQueryClient } from './query-client';
import type { AppRouter } from './routers/_app';

export const { TRPCProvider, useTRPC } = createTRPCContext<AppRouter>();

let browserQueryClient: QueryClient | undefined;

function getQueryClient() {
  if (typeof window === 'undefined') return makeQueryClient();
  if (!browserQueryClient) browserQueryClient = makeQueryClient();
  return browserQueryClient;
}

function getUrl() {
  if (typeof window !== 'undefined') return '/api/trpc';
  if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}/api/trpc`;
  return 'http://localhost:3000/api/trpc';
}

export function TRPCReactProvider({ children }: { children: React.ReactNode }) {
  const queryClient = getQueryClient();
  const [trpcClient] = useState(() =>
    createTRPCClient<AppRouter>({
      links: [httpBatchLink({ url: getUrl() })],
    }),
  );

  return (
    <QueryClientProvider client={queryClient}>
      <TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
        {children}
      </TRPCProvider>
    </QueryClientProvider>
  );
}

The getQueryClient function branches on typeof window: on the server it always creates a new instance (fresh per request), on the browser it reuses the singleton. This is the correct pattern — a server-side singleton would leak query cache between requests.

useTRPC is what client components import to get the type-safe tRPC proxy. It replaces the trpc.x.useQuery() pattern from v10.

Step 9: Mount the provider in layout

// app/layout.tsx
import { TRPCReactProvider } from '~/trpc/client';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <TRPCReactProvider>{children}</TRPCReactProvider>
      </body>
    </html>
  );
}

One provider wraps the whole app. That’s all layout.tsx needs to do.

Step 10: Prefetch in a Server Component

// app/page.tsx
import { getQueryClient, trpc, HydrateClient } from '~/trpc/server';

export default async function Home() {
  const queryClient = getQueryClient();
  void queryClient.prefetchQuery(trpc.hello.queryOptions({ text: 'world' }));

  return (
    <HydrateClient>
      <Greeting />
    </HydrateClient>
  );
}

void prefetchQuery(...) fires the query without blocking the RSC render. The data streams to the client via React’s hydration transport as it resolves. If you need the data before the page can render at all — no loading skeleton acceptable — use await queryClient.fetchQuery(...) instead. That blocks TTFB until the query settles.

Wrap the client subtree in <HydrateClient>. Without it, the prefetched data never reaches the client QueryClient and the client refetches anyway.

Step 11: Use tRPC in a Client Component

// components/greeting.tsx
'use client';
import { useQuery } from '@tanstack/react-query';
import { useTRPC } from '~/trpc/client';

export function Greeting() {
  const trpc = useTRPC();
  const { data } = useQuery(trpc.hello.queryOptions({ text: 'world' }));

  if (!data) return <div>Loading…</div>;
  return <div>{data.greeting}</div>;
}

The v11 pattern is useQuery(trpc.x.queryOptions(...)) — not trpc.x.useQuery(...) from v10. The queryOptions() method returns a standard TanStack Query options object, which means you can use all of React Query’s hooks directly: useQuery, useSuspenseQuery, useInfiniteQuery, useMutation.

If the parent RSC prefetched this query, the client component gets the data immediately from the hydration boundary — no loading state flicker.

// Suspense variant — no loading state needed when prefetched
import { useSuspenseQuery } from '@tanstack/react-query';

export function GreetingSuspense() {
  const trpc = useTRPC();
  const { data } = useSuspenseQuery(trpc.hello.queryOptions({ text: 'world' }));
  return <div>{data.greeting}</div>;
}

Pitfalls and gotchas

1. Wrong package. @trpc/next wraps Pages Router conventions (Node.js IncomingMessage, createNextApiHandler). It does not work in App Router. Use fetchRequestHandler from @trpc/server/adapters/fetch.

2. QueryClient singleton on the server. The makeQueryClient() factory must be called once per request on the server, not shared across requests. Sharing it leaks one user’s cached queries into another user’s response. The cache() wrapper in server.tsx ensures one instance per request.

3. Missing staleTime. React Query’s default staleTime is 0. Without setting it to at least 30 seconds in your QueryClient defaults, the client treats server-prefetched data as stale on mount and immediately refetches. The round-trip cost of SSR prefetch is paid for nothing.

4. shouldDehydrateQuery missing pending. The default dehydration filter only serializes settled queries. If you use void prefetch() (non-blocking), the query may still be pending when React serializes the hydration boundary. Without the pending extension, it gets dropped and the client starts the query from scratch.

5. Transformer location changed in v11. In tRPC v10, you passed superjson as a global transformer on createTRPCClient. In v11, transformers are configured per link: httpBatchLink({ url: '/api/trpc', transformer: superjson }). If you’re migrating from v10 and your Dates are arriving as strings, this is why.

Direct server-side calls (no HTTP)

When you don’t need data on the client — page metadata, fully static content — skip the QueryClient and call the router directly:

import { caller } from '~/trpc/server';

export async function generateMetadata() {
  const result = await caller.hello({ text: 'world' });
  return { title: result.greeting };
}

caller bypasses the HTTP layer entirely. There’s no network round-trip, no serialization overhead, and no need for <HydrateClient>. The result stays server-side.

Conclusion

That’s the full tRPC v11 + App Router stack. The setup is more spread out than v10 was — six files instead of two — but each file has a clear job: init.ts owns the backend, server.tsx owns SSR, client.tsx owns the client boundary. Once the structure is in place, adding procedures is a single file change.

For deployment, Vercel handles the stack with zero config — the fetchRequestHandler pattern is compatible with both serverless and edge runtimes. Cloudflare Workers also work via the same Fetch API adapter; the only caveat is ensuring server-only is compatible with your edge runtime configuration.

Since tRPC’s client layer runs on TanStack Query, if you’re still choosing between TanStack Query and SWR for data fetching elsewhere in your app, the TanStack Query vs SWR comparison has the breakdown.

References