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: 0causes 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 .