Skip to content
Dev Tools Beginner Tutorial

End-to-End Type Safety with tRPC in a Next.js App

Wire tRPC 11 into Next.js 16 so API mismatches fail in tsc, not in production.

Rachel Goldstein
Rachel Goldstein
Dev Tools Editor · Aug 30, 2026 · 5 min read
End-to-End Type Safety with tRPC in a Next.js App

What you'll build

A Next.js App Router project where a client component calls a server procedure through tRPC, with the API's input and output types inferred end to end. Rename a field on the server and tsc fails in the component before anything ships.

Prerequisites

Verified on macOS with these versions on 2026-08-30:

  • Node.js 20.19 (Next.js 16 needs 20.9 or newer) and npm 10
  • Next.js 16.3.3, React 19.2.8, TypeScript 5.9.3 (what create-next-app installs today)
  • @trpc/server, @trpc/client, @trpc/tanstack-react-query 11.18.0
  • TanStack Query 5.102.8 and Zod 4.5.4

tRPC 11 requires TypeScript 5.7.2 or newer and TanStack Query 5.80.3 or newer. Windows and Linux work the same; only the shell commands below assume a POSIX shell.

1. Scaffold the app and install tRPC

--yes takes the defaults: TypeScript, App Router, Tailwind, ESLint, and the @/* import alias pointing at the project root.

npx create-next-app@latest trpc-demo --yes
cd trpc-demo
npm install @trpc/server @trpc/client @trpc/tanstack-react-query @tanstack/react-query zod client-only server-only

2. Define the router

Create trpc/init.ts. Wrapping createTRPCContext in React's cache dedupes it per request, which matters once you read headers or a session here.

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

export const createTRPCContext = cache(async () => {
  return { userId: 'user_123' };
});

const t = initTRPC.create();

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

Create trpc/routers/_app.ts. The exported AppRouter type is the whole trick: the client imports the type, never the code.

import { z } from 'zod';
import { baseProcedure, createTRPCRouter } from '../init';

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

export type AppRouter = typeof appRouter;

3. Expose it as a route handler

Create app/api/trpc/[trpc]/route.ts. tRPC ships a fetch adapter that speaks the same Request/Response API as App Router route handlers, so no extra glue is needed.

import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { createTRPCContext } from '@/trpc/init';
import { appRouter } from '@/trpc/routers/_app';

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

export { handler as GET, handler as POST };

4. Wire the client provider

Create trpc/query-client.ts:

import { QueryClient } from '@tanstack/react-query';

export function makeQueryClient() {
  return new QueryClient({
    defaultOptions: { queries: { staleTime: 30 * 1000 } },
  });
}

Create trpc/client.tsx. The browser reuses one QueryClient; the server makes a fresh one per render so requests never share cache.

'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() {
  const base = typeof window !== 'undefined' ? '' : 'http://localhost:3000';
  return `${base}/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>
  );
}

Replace app/layout.tsx:

import { TRPCReactProvider } from '@/trpc/client';
import './globals.css';

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

5. Call the API from a component

Create app/greeting.tsx. trpc.hello.queryOptions() returns a typed options object, so data is already { greeting: string; at: string } with no manual typing.

'use client';

import { useQuery } from '@tanstack/react-query';
import { useTRPC } from '@/trpc/client';

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

  if (isPending) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;
  return (
    <p>
      {data.greeting} (served at {data.at})
    </p>
  );
}

Replace app/page.tsx:

import { Greeting } from './greeting';

export default function Home() {
  return (
    <main style={{ padding: 32 }}>
      <Greeting />
    </main>
  );
}

Verify it works

Type-check, then start the dev server:

npx tsc --noEmit && npm run dev

Open http://localhost:3000. After a brief "Loading..." you'll see hello world (served at 2026-08-30T11:36:55.246Z) with the current timestamp. Hit the endpoint directly to see the wire format:

curl 'http://localhost:3000/api/trpc/hello?input=%7B%22name%22%3A%22world%22%7D'
{"result":{"data":{"greeting":"hello world","at":"2026-08-30T11:36:55.246Z"}}}

Now prove the type safety. In app/greeting.tsx, change { name: 'world' } to { username: 'world' } and run npx tsc --noEmit:

app/greeting.tsx(9,31): error TS2769: No overload matches this call.
    Object literal may only specify known properties, and 'username' does not exist in type '{ name: string; }'.

Change data.greeting to data.greetings and you get error TS2551: Property 'greetings' does not exist on type '{ greeting: string; at: string; }'. Did you mean 'greeting'?. Revert both. That's the whole pitch: an API mismatch is a compile error, not a 3 a.m. page.

Troubleshooting

Error: useTRPC() can only be used inside of a <TRPCProvider>. A component calls useTRPC() but sits outside TRPCReactProvider. Check that app/layout.tsx wraps {children} with it and that you didn't render the component from a second root layout.

"code":"BAD_REQUEST","httpStatus":400 with Invalid input: expected string, received number. The request reached the server but Zod rejected it. This only happens from curl or a non-TypeScript caller; in your own components the same mistake fails in tsc instead.

"message":"No procedure found on path \"nope\"","code":"NOT_FOUND". The procedure name in the URL doesn't match a key in appRouter. Also check endpoint: '/api/trpc' in route.ts matches the folder path exactly; a mismatch there gives a plain Next.js 404 instead.

Module not found: Can't resolve '@/trpc/init'. You created the project with --src-dir, so the alias resolves to src/. Move the trpc folder under src/ or change the import paths.

Next steps

Add a mutation with baseProcedure.input(...).mutation(...) and call it with useMutation(trpc.x.mutationOptions()). Then read tRPC's Server Components guide to prefetch queries in a server component and stream them into HydrationBoundary, which removes the "Loading..." flash. When you add auth, put the session on the context object in init.ts and gate procedures with a t.middleware.

Sources & further reading

  1. Set up with Next.js App Router (TanStack React Query) — trpc.io
  2. TanStack React Query integration setup — trpc.io
  3. Fetch adapter — trpc.io
  4. create-next-app CLI reference — nextjs.org
  5. Installation and system requirements — nextjs.org
Rachel Goldstein
Written by
Rachel Goldstein · Dev Tools Editor

Rachel has been embedded in the developer tooling ecosystem for nearly eight years, covering everything from IDE wars and package-manager drama to the quiet rise of AI-assisted coding. She has a soft spot for open-source maintainers and an unhealthy number of terminal emulators installed on a single laptop.

Discussion 4

Join the discussion

Sign in or create an account to comment and vote.

Cora Diaz @cloudnative_cora · 2 hours ago

type safety across the wire is the dream, gonna spin this up this week

Sam Cole @junior_dev_sam · 10 hours ago

spent like two hours last week debugging why my client was sending the wrong shape to my api endpoint, and i could've saved myself so much pain with this. did a refetch, console logged everything, realized i'd renamed a field on the server-side router but forgot to update the call site. with end-to-end types that would've just screamed at me immediately.

Priya Nair @k8s_whisperer · 18 hours ago

caught a production bug last week because a backend field rename didn't propagate to the client query—would've saved hours if we had this setup then. the type safety across the boundary is real and worth the small setup overhead, especially once you're beyond a toy project.

Tom Becker @terminal_tom · 20 hours ago

honestly been meaning to wire this up in my setup, tired of runtime surprises

Related Reading