tRPC + Next App Router
RSC prefetch, hydration, useQuery on the client.
tRPC works in any TS runtime, but it has an especially clean story in Next.js App Router. You get one route handler that serves every procedure, a way to prefetch queries inside Server Components so the data ships with the HTML, and a client where useQuery and useMutation feel like local function calls. This lesson wires up all of that and ends with a working optimistic update.
Step 1: the route handler
Every tRPC request enters through a single Next route. Use thefetchRequestHandler adapter, which speaks the modern Web Fetch Request / Response API.
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@/server/routers/_app";
import { createContext } from "@/server/context";
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: () => createContext({ req } as any),
});
export const GET = handler;
export const POST = handler;The bracketed segment [trpc]is just a catch for the procedure path that tRPC encodes into the URL. You don't write any handler code per procedure.
Step 2: the client (browser)
For client-side use, tRPC integrates with TanStack Query (React Query). You set up a provider once and use the resulting trpc object for hooks.
"use client";
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink, createTRPCReact } from "@trpc/react-query";
import superjson from "superjson";
import type { AppRouter } from "@/server/routers/_app";
export const trpc = createTRPCReact<AppRouter>();
export function TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [client] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: "/api/trpc",
transformer: superjson,
}),
],
}),
);
return (
<trpc.Provider client={client} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
);
}Drop the provider into your root layout:
import { TRPCProvider } from "@/lib/trpc";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html><body><TRPCProvider>{children}</TRPCProvider></body></html>
);
}Step 3: prefetch in RSCs, hydrate on the client
For the best UX you want server-rendered HTML with the data already inlined, then take over with React Query on the client. tRPC ships a server-side caller plus a HydrateClient helper for exactly this pattern.
import "server-only";
import { cache } from "react";
import { headers } from "next/headers";
import { createHydrationHelpers } from "@trpc/react-query/rsc";
import { createQueryClient } from "./query-client";
import { appRouter } from "@/server/routers/_app";
import { createContext } from "@/server/context";
const getQueryClient = cache(createQueryClient);
const caller = appRouter.createCaller(async () => {
return createContext({ req: { headers: await headers() } } as any);
});
export const { trpc, HydrateClient } = createHydrationHelpers<typeof appRouter>(
caller,
getQueryClient,
);import { trpc, HydrateClient } from "@/lib/trpc-server";
import { PostList } from "./post-list";
export default async function PostsPage() {
// Prefetch on the server: response goes into the React Query cache.
void trpc.post.list.prefetch();
return (
<HydrateClient>
{/* Client renders instantly using the prefetched data. */}
<PostList />
</HydrateClient>
);
}"use client";
import { trpc } from "@/lib/trpc";
export function PostList() {
// No loading state: the query is already populated from the RSC prefetch.
const [posts] = trpc.post.list.useSuspenseQuery();
return (
<ul>
{posts.map((p) => <li key={p.id}>{p.title}</li>)}
</ul>
);
}useSuspenseQuery in the client. The cache hit is synchronous, so the client component never shows a loading state, and once mounted, React Query takes over for refetching/invalidation.useQuery: client-only reads
For data that doesn't need to ship with the HTML (search results triggered by a keystroke, hovered tooltip data), useuseQuery directly. It returns the familiar React Query shape.
"use client";
import { trpc } from "@/lib/trpc";
export function UserDetails({ id }: { id: string }) {
const { data, isLoading, error } = trpc.user.byId.useQuery({ id });
if (isLoading) return <p>Loading...</p>;
if (error) return <p>{error.message}</p>;
return <h2>{data?.name}</h2>;
}Mutations with optimistic updates
The whole reason you wrap tRPC around React Query: real cache control. Here's the canonical optimistic-update flow.
"use client";
import { useState } from "react";
import { trpc } from "@/lib/trpc";
export function NewPostForm() {
const [title, setTitle] = useState("");
const utils = trpc.useUtils();
const createPost = trpc.post.create.useMutation({
async onMutate(newPost) {
// Cancel any outgoing list refetches so they don't overwrite our optimistic update.
await utils.post.list.cancel();
// Snapshot the previous value.
const previous = utils.post.list.getData() ?? [];
// Optimistically update the cache.
utils.post.list.setData(undefined, (old = []) => [
{ id: "temp-" + Date.now(), title: newPost.title, createdAt: new Date() },
...old,
]);
return { previous };
},
onError(_err, _newPost, ctx) {
// Roll back.
if (ctx?.previous) utils.post.list.setData(undefined, ctx.previous);
},
onSettled() {
// Refetch to reconcile with the server.
void utils.post.list.invalidate();
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
createPost.mutate({ title });
setTitle("");
}}
>
<input value={title} onChange={(e) => setTitle(e.target.value)} />
<button type="submit" disabled={createPost.isPending}>
{createPost.isPending ? "Saving..." : "Post"}
</button>
</form>
);
}Four hooks for one feature, but each does exactly one thing: onMutate paints, onError rolls back,onSettledreconciles, the form does the submit. If you've done React Query mutations before, this is the same playbook with tRPC method names.
trpc.useContext(). In the current API it's trpc.useUtils(). Same helpers (cancel, getData, setData,invalidate), better name.Mental map of the pieces
- Route handler at
app/api/trpc/[trpc]/route.tsaccepts all procedure calls. - Client provider wraps the app and gives you the React hooks.
- Server caller (
createCaller) lets RSCs run procedures directly without HTTP. - HydrateClient ships the prefetched React Query cache to the browser.
- useSuspenseQuery on the client reads from that cache instantly; useQuery for purely client-driven loads; useMutation for writes.
Quiz
What's the role of HydrateClient + prefetch in an RSC?
Recap
- One route handler at
app/api/trpc/[trpc]/route.tsserves every procedure viafetchRequestHandler. - The client uses
createTRPCReact<AppRouter>()+ a provider withhttpBatchLink. - In RSCs, prefetch with the server caller and wrap children in HydrateClient; the matching
useSuspenseQueryon the client reads synchronously. - For client-driven reads use
useQuery; for writes useuseMutation. - Optimistic updates use the React Query playbook through
trpc.useUtils().