Loading...
Loading...
Full end-to-end tRPC setup for Next.js App Router. Covers route handler with fetchRequestHandler (GET + POST exports), TRPCProvider with QueryClientProvider, createTRPCOptionsProxy for RSC prefetching, HydrateClient/HydrationBoundary for hydration, useSuspenseQuery for Suspense, and server-side callers.
npx skill4agent add trpc/trpc nextjs-app-router.
├── app
│ ├── api/trpc/[trpc]
│ │ └── route.ts # tRPC HTTP handler
│ ├── layout.tsx # mount TRPCReactProvider
│ ├── page.tsx # server component (prefetch)
│ └── client-greeting.tsx # client component (consume)
├── trpc
│ ├── init.ts # initTRPC, createTRPCContext
│ ├── routers
│ │ └── _app.ts # main app router, AppRouter type
│ ├── query-client.ts # shared QueryClient factory
│ ├── client.tsx # client hooks & TRPCReactProvider
│ └── server.tsx # server-side proxy & helpers
└── ...npm install @trpc/server @trpc/client @trpc/tanstack-react-query @tanstack/react-query zod server-only client-onlyimport { initTRPC } from '@trpc/server';
export const createTRPCContext = async (opts: { headers: Headers }) => {
return { userId: 'user_123' };
};
const t = initTRPC
.context<Awaited<ReturnType<typeof createTRPCContext>>>()
.create();
export const createTRPCRouter = t.router;
export const createCallerFactory = t.createCallerFactory;
export const baseProcedure = t.procedure;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;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({ headers: req.headers }),
});
export { handler as GET, handler as POST };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',
},
},
});
}dehydrate.serializeDatahydrate.deserializeData'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, useTRPCClient } =
createTRPCContext<AppRouter>();
let browserQueryClient: QueryClient;
function getQueryClient() {
if (typeof window === 'undefined') {
return makeQueryClient();
}
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
}
function getUrl() {
const base = (() => {
if (typeof window !== 'undefined') return '';
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return 'http://localhost:3000';
})();
return `${base}/api/trpc`;
}
export function TRPCReactProvider(props: { children: React.ReactNode }) {
const queryClient = getQueryClient();
const [trpcClient] = useState(() =>
createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: getUrl(),
}),
],
}),
);
return (
<QueryClientProvider client={queryClient}>
<TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
{props.children}
</TRPCProvider>
</QueryClientProvider>
);
}import 'server-only';
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query';
import type { TRPCQueryOptions } from '@trpc/tanstack-react-query';
import { headers } from 'next/headers';
import { cache } from 'react';
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 function HydrateClient(props: { children: React.ReactNode }) {
const queryClient = getQueryClient();
return (
<HydrationBoundary state={dehydrate(queryClient)}>
{props.children}
</HydrationBoundary>
);
}
export function prefetch<T extends ReturnType<TRPCQueryOptions<any>>>(
queryOptions: T,
) {
const queryClient = getQueryClient();
if (queryOptions.queryKey[1]?.type === 'infinite') {
void queryClient.prefetchInfiniteQuery(queryOptions as any);
} else {
void queryClient.prefetchQuery(queryOptions);
}
}import { TRPCReactProvider } from '../trpc/client';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<TRPCReactProvider>{children}</TRPCReactProvider>
</body>
</html>
);
}import { HydrateClient, prefetch, trpc } from '../trpc/server';
import { ClientGreeting } from './client-greeting';
export default async function Home() {
prefetch(trpc.hello.queryOptions({ text: 'world' }));
return (
<HydrateClient>
<ClientGreeting />
</HydrateClient>
);
}'use client';
import { useQuery } from '@tanstack/react-query';
import { useTRPC } from '../trpc/client';
export function ClientGreeting() {
const trpc = useTRPC();
const greeting = useQuery(trpc.hello.queryOptions({ text: 'world' }));
if (!greeting.data) return <div>Loading...</div>;
return <div>{greeting.data.greeting}</div>;
}import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { HydrateClient, prefetch, trpc } from '../trpc/server';
import { ClientGreeting } from './client-greeting';
export default async function Home() {
prefetch(trpc.hello.queryOptions({ text: 'world' }));
return (
<HydrateClient>
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<Suspense fallback={<div>Loading...</div>}>
<ClientGreeting />
</Suspense>
</ErrorBoundary>
</HydrateClient>
);
}'use client';
import { useSuspenseQuery } from '@tanstack/react-query';
import { useTRPC } from '../trpc/client';
export function ClientGreeting() {
const trpc = useTRPC();
const { data } = useSuspenseQuery(trpc.hello.queryOptions({ text: 'world' }));
return <div>{data.greeting}</div>;
}// Add to existing server.tsx
export const caller = appRouter.createCaller(async () =>
createTRPCContext({ headers: await headers() }),
);import { caller } from '../trpc/server';
export default async function Home() {
const greeting = await caller.hello({ text: 'world' });
return <div>{greeting.greeting}</div>;
}callerprefetchQueryimport { getQueryClient, HydrateClient, trpc } from '../trpc/server';
import { ClientGreeting } from './client-greeting';
export default async function Home() {
const queryClient = getQueryClient();
const greeting = await queryClient.fetchQuery(
trpc.hello.queryOptions({ text: 'world' }),
);
// Use greeting on the server
console.log(greeting.greeting);
return (
<HydrateClient>
<ClientGreeting />
</HydrateClient>
);
}GETPOST// WRONG
export default function handler(req: Request) { ... }
// CORRECT
const handler = (req: Request) =>
fetchRequestHandler({ req, router: appRouter, endpoint: '/api/trpc', createContext });
export { handler as GET, handler as POST };QueryClient// WRONG
const queryClient = new QueryClient(); // shared across requests!
// CORRECT
export const getQueryClient = cache(makeQueryClient);cache()QueryClientshouldDehydrateQuery// WRONG
new QueryClient(); // default shouldDehydrateQuery skips pending
// CORRECT
new QueryClient({
defaultOptions: {
dehydrate: {
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
},
},
});useSuspenseQueryuseQuery