TanStack Query + RSC cheatsheet.

Why combine?

  • Server: prefetch data → fast first paint, SEO-friendly HTML.
  • Client: live updates, mutations, optimistic, infinite scroll.

Hydrate server data into client cache so no double-fetch.

QueryClient provider

// app/providers.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";

export function Providers({ children }: { children: React.ReactNode }) {
  const [qc] = useState(() => new QueryClient({
    defaultOptions: { queries: { staleTime: 60 * 1000 } },
  }));
  return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
// app/layout.tsx
import { Providers } from "./providers";

export default function RootLayout({ children }) {
  return <html><body><Providers>{children}</Providers></body></html>;
}

Prefetch on server, hydrate on client

// app/users/page.tsx
import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query";
import { UsersList } from "./UsersList";

export default async function Page() {
  const qc = new QueryClient();
  
  await qc.prefetchQuery({
    queryKey: ["users"],
    queryFn: () => fetch("https://api/...").then(r => r.json()),
  });
  
  return (
    <HydrationBoundary state={dehydrate(qc)}>
      <UsersList />
    </HydrationBoundary>
  );
}
// app/users/UsersList.tsx
"use client";
import { useQuery } from "@tanstack/react-query";

export function UsersList() {
  const { data } = useQuery({
    queryKey: ["users"],
    queryFn: () => fetch("/api/users").then(r => r.json()),
  });
  return <ul>{data?.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}

Cache hits immediately on client; no flash, no refetch (within staleTime).

getQueryClient helper

// lib/query.ts
import { QueryClient } from "@tanstack/react-query";
import { cache } from "react";

export const getQueryClient = cache(() => new QueryClient({
  defaultOptions: { queries: { staleTime: 60 * 1000 } },
}));

cache makes it per-request on server.

const qc = getQueryClient();
await qc.prefetchQuery({ ... });

Streaming prefetch

import { Suspense } from "react";

export default function Page() {
  return (
    <>
      <Suspense fallback={<L />}>
        <UsersSection />
      </Suspense>
      <Suspense fallback={<L />}>
        <PostsSection />
      </Suspense>
    </>
  );
}

async function UsersSection() {
  const qc = getQueryClient();
  await qc.prefetchQuery({ ... });
  return (
    <HydrationBoundary state={dehydrate(qc)}>
      <UsersList />
    </HydrationBoundary>
  );
}

Each section prefetches in parallel and streams.

Mutations + RSC

"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";

export function CreateButton() {
  const qc = useQueryClient();
  
  const m = useMutation({
    mutationFn: (data) => fetch("/api/users", { method: "POST", body: JSON.stringify(data) }),
    onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
  });
  
  return <button onClick={() => m.mutate({ name: "A" })}>Create</button>;
}

Need to refresh RSC too? Call router.refresh():

import { useRouter } from "next/navigation";

const router = useRouter();
// after mutation success:
qc.invalidateQueries({ queryKey: ["users"] });
router.refresh();        // re-fetches RSC for current route

Hybrid: server action + Query

"use client";
import { useTransition } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { createPost } from "./actions";

export function Form() {
  const [isPending, startTransition] = useTransition();
  const qc = useQueryClient();
  
  function onSubmit(formData: FormData) {
    startTransition(async () => {
      await createPost(formData);
      qc.invalidateQueries({ queryKey: ["posts"] });
    });
  }
  
  return <form action={onSubmit}>...</form>;
}

Suspense queries

"use client";
import { useSuspenseQuery } from "@tanstack/react-query";

function UserDetail({ id }: { id: number }) {
  const { data } = useSuspenseQuery({
    queryKey: ["user", id],
    queryFn: () => fetch(`/api/users/${id}`).then(r => r.json()),
  });
  return <p>{data.name}</p>;
}

Wrap with <Suspense> higher. Data always defined.

When NOT to use Query

If you only render on server and never need client mutations: just use server components with fetch.

Use Query for:

  • Mutations from client.
  • Live updates / polling.
  • Optimistic UI.
  • Pagination/infinite scroll without router complexity.

Devtools

"use client";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";

<QueryClientProvider client={qc}>
  {children}
  <ReactQueryDevtools />
</QueryClientProvider>

Caveats

  • Don’t share QueryClient across requests on server (use cache(() => new QueryClient())).
  • Hydration mismatch if server prefetch differs from client refetch.
  • staleTime: 0 causes immediate refetch after hydration — defeats prefetch.

Common mistakes

  • Single global QueryClient on server — leaks data between users.
  • Forgetting HydrationBoundary — prefetch wasted.
  • Mutating without invalidating Query AND calling router.refresh().
  • Same queryKey on server with different params than client.
  • cache: "no-store" in prefetch — fetched at build, but no benefit.

Read this next

If you want my Query + RSC patterns, they’re at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .