webdev.complete
🔗 tRPC — End-to-End Type Safety
🚀Next.js & T3
Lesson 99 of 117
30 min

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.

app/api/trpc/[trpc]/route.ts
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.

lib/trpc.tsx
"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:

app/layout.tsx
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.

lib/trpc-server.ts
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,
);
app/posts/page.tsx (Server Component)
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>
  );
}
app/posts/post-list.tsx (Client Component)
"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>
  );
}
prefetch + useSuspenseQuery is the magic
Prefetch in the server component, then read with 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.

app/users/[id]/page.tsx (Client)
"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.

app/posts/new-post.tsx (Client)
"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&apos;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.

useUtils, not useContext
Older tRPC docs say 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.ts accepts 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

Quiz1 / 3

What's the role of HydrateClient + prefetch in an RSC?

Recap

  • One route handler at app/api/trpc/[trpc]/route.ts serves every procedure via fetchRequestHandler.
  • The client uses createTRPCReact<AppRouter>() + a provider with httpBatchLink.
  • In RSCs, prefetch with the server caller and wrap children in HydrateClient; the matching useSuspenseQuery on the client reads synchronously.
  • For client-driven reads use useQuery; for writes use useMutation.
  • Optimistic updates use the React Query playbook through trpc.useUtils().
Built with Next.js, Tailwind & Sandpack.
Learn. Build. Ship.