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.
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-appinstalls today) @trpc/server,@trpc/client,@trpc/tanstack-react-query11.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
- Set up with Next.js App Router (TanStack React Query) — trpc.io
- TanStack React Query integration setup — trpc.io
- Fetch adapter — trpc.io
- create-next-app CLI reference — nextjs.org
- Installation and system requirements — nextjs.org
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
type safety across the wire is the dream, gonna spin this up this week
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.
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.
honestly been meaning to wire this up in my setup, tired of runtime surprises