| name | tanstack-streaming |
| description | TanStack Query streaming with Next.js App Router - server prefetching, dehydration, Suspense boundaries, useSuspenseQuery/useSuspenseInfiniteQuery hydration pattern. Use when implementing data fetching that streams from server to client using TanStack React Query with Next.js RSC. |
| user-invocable | true |
TanStack Query Streaming with Next.js App Router
This skill describes our pattern for streaming server-prefetched data to client components using TanStack Query + React Suspense in Next.js App Router.
Architecture Overview
SERVER (RSC page.tsx)
โโ getQueryClient() โ singleton per request
โโ prefetchQuery / prefetchInfiniteQuery (with or without await)
โโ dehydrate(queryClient) โ serializes cache including pending queries
โโ <HydrateProvider state={dehydratedState}> โ passes cache to client
CLIENT (components wrapped in Suspense)
โโ useSuspenseQuery() / useSuspenseInfiniteQuery() โ reads hydrated cache
โโ If data is already resolved โ renders immediately
โโ If data is still pending (streamed) โ Suspense boundary shows fallback
Key Files in This Codebase
- getQueryClient:
@/lib/react-query/getQueryClient โ singleton pattern, server creates fresh per request, client reuses single instance
- HydrateProvider:
@/app/_common/providers/hydrateProvider โ thin wrapper around TanStack's HydrationBoundary
- Service layer: e.g.
WorkspaceIssuesService โ defines queryKey, queryFn, staleTime for each query
IMPORTANT: Do NOT pass values through props when a hook or useParams can provide them
The whole point of prefetch + dehydrate is that the cache is the source of truth on the client. Once you've prefetched a query on the server, every client component below <HydrateProvider> can read the same value via the matching useSuspenseQuery hook โ for free, deduped, and reactive to invalidations.
Rules:
- If a child needs prefetched data, it must call the counterpart hook (
useX) โ not receive the data as a prop from the page.
- If a child needs a route param (
agentId, workspaceId, issueId, etc.), it must call useParams<{ ... }>() from next/navigation โ not receive it as a prop.
- The server
page.tsx is responsible for prefetchQuery(...) only. It should pass no data props and no route-param props to the client tree it renders.
- This applies transitively: dialogs, cards, sub-sections โ none of them should accept
agentId / workspaceId / prefetched lists as props. Each reads from its own hook + useParams.
Why this matters:
- Passing prefetched data as a prop bypasses the cache. The child won't react to cache updates, optimistic mutations, or invalidations.
- Prop drilling forces a fresh array/object reference on every parent render โ child memoization breaks โ effects re-fire โ duplicate fetches.
- Route params via props serialize through React's render tree;
useParams reads them directly from the router with stable identity.
- When every consumer reads the same
queryKey, TanStack Query dedupes โ one network request, many readers.
Bad (page passes data + agentId down the tree):
const { agentId } = await params;
const result = await listIntegrations();
const integrations = isSuccessApiResult(result) ? result.data.data : [];
queryClient.prefetchQuery(environmentsListQuery(agentId));
return (
<HydrateProvider state={dehydrate(queryClient)}>
<EnvironmentsSection agentId={agentId} integrations={integrations} />
</HydrateProvider>
);
export const EnvironmentsSection = ({ agentId, integrations }) => {
const { data } = useEnvironments(agentId);
return <AddEnvironmentDialog agentId={agentId} integrations={integrations} />;
};
Good (page only prefetches; children pull from hooks + useParams):
const { agentId } = await params;
const queryClient = getQueryClient();
queryClient.prefetchQuery(environmentsListQuery(agentId));
queryClient.prefetchQuery(integrationsListQuery());
return (
<HydrateProvider state={dehydrate(queryClient)}>
<Suspense fallback={<Skeleton />}>
<EnvironmentsSection />
</Suspense>
</HydrateProvider>
);
'use client';
import { useParams } from 'next/navigation';
export const EnvironmentsSection = () => {
const { agentId } = useParams<{ agentId: string }>();
const { data } = useEnvironments(agentId);
return <AddEnvironmentDialog />;
};
'use client';
import { useParams } from 'next/navigation';
export const AddEnvironmentDialog = ({ open, onOpenChange }) => {
const { agentId } = useParams<{ agentId: string }>();
const { data: integrations } = useIntegrations();
};
Exceptions โ when props ARE the right answer:
- The value is local UI state owned by the parent (
open, onOpenChange, selected, callbacks).
- The value is a list item that the parent has already iterated over (e.g.
<EnvironmentCard environment={env} /> inside a .map() โ the card can't re-derive which item it is from a hook).
- The child needs a derived/filtered slice that only the parent has context to compute.
If a value can be obtained from a hook or useParams, prefer that over a prop.
The Pattern
Step 1: Server Page โ Prefetch & Dehydrate
In a server component (page.tsx), prefetch all data the page needs:
import { dehydrate } from '@tanstack/react-query';
import getQueryClient from '@/lib/react-query/getQueryClient';
import { HydrateProvider } from '@/app/_common/providers/hydrateProvider';
const MyPage = async (props: PageProps<'...'>) => {
const params = await props.params;
const queryClient = getQueryClient();
await Promise.all([
queryClient.prefetchQuery(MyService.getCriticalDataQuery({ id: params.id })),
queryClient.prefetchQuery(MyService.getOtherCriticalQuery({ ... })),
]);
queryClient.prefetchInfiniteQuery(
MyService.getActivitiesInfiniteQuery({ id: params.id }),
);
const dehydratedState = dehydrate(queryClient);
return (
<HydrateProvider state={dehydratedState}>
<MyPageContent />
</HydrateProvider>
);
};
Key rules:
await prefetchQuery(...) โ data is resolved before HTML is sent. Component renders with data immediately.
prefetchQuery(...) (no await) โ the promise is captured in the query cache. dehydrate() includes pending queries. The client receives a pending promise that resolves via streaming. The Suspense boundary shows a fallback until the data arrives.
- Use
Promise.all([...]) to parallelize multiple prefetches.
- Use
fetchQuery instead of prefetchQuery when you need the return value on the server.
Step 2: Shared Content Component โ Suspense Boundaries
Create a component (can be server or client) that wraps child components in <Suspense>:
import { Suspense } from 'react';
export const MyPageContent = () => {
return (
<>
<Suspense fallback={<BodyPlaceholder />}>
<MyPageBody />
</Suspense>
<Suspense fallback={<AsidePlaceholder />}>
<MyPageAside />
</Suspense>
</>
);
};
Key rules:
- Each
<Suspense> boundary is independent โ they stream in as their data resolves.
- Multiple
useSuspenseQuery calls inside the same Suspense boundary will wait for all of them before rendering (they act as a combined container).
- Separate Suspense boundaries when sections can load independently.
- Always provide a meaningful fallback (skeleton/placeholder).
Step 3: Client Components โ useSuspenseQuery
Client components consume the hydrated (or streaming) data:
'use client';
import { useSuspenseQuery } from '@tanstack/react-query';
export const MyPageBody = () => {
const { data: item } = useSuspenseQuery(
MyService.getCriticalDataQuery({ id }),
);
return <div>{item.name}</div>;
};
For infinite queries:
'use client';
import { useSuspenseInfiniteQuery } from '@tanstack/react-query';
export const MyPageActivities = () => {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useSuspenseInfiniteQuery(MyService.getActivitiesInfiniteQuery({ id }));
const allActivities = data.pages.flatMap((page) => page.activities);
return (
<>
{allActivities.map((activity) => (
<ActivityCard key={activity.id} activity={activity} />
))}
{hasNextPage && (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
Load more
</button>
)}
</>
);
};
Step 4: Service Layer โ Query Definitions
Define query configurations in a service class so both server and client use the same queryKey and queryFn:
import { myServerAction } from '@/server/my-domain/actions';
export class MyService {
static getCriticalDataQuery(args: { id: string }) {
return {
queryKey: ['my-data', args.id],
queryFn: () => myServerAction(args),
staleTime: 300 * 1000,
};
}
static getActivitiesInfiniteQuery(args: { id: string }) {
return {
queryKey: ['my-activities', args.id],
queryFn: ({ pageParam }: { pageParam: unknown }) =>
getActivitiesAction({ ...args, cursor: pageParam as string }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage: ActivitiesResponse) => lastPage.nextCursor,
staleTime: 300 * 1000,
};
}
}
Key rules:
- Same query config object is used in both
prefetchQuery() (server) and useSuspenseQuery() (client).
queryKey must match exactly between server and client for hydration to work.
queryFn calls server actions โ these work on both server and client in Next.js.
getQueryClient Setup
The singleton must include pending queries in dehydration for streaming to work:
import {
QueryClient,
defaultShouldDehydrateQuery,
isServer,
} from '@tanstack/react-query';
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
dehydrate: {
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === 'pending',
},
},
});
}
let browserQueryClient: QueryClient | undefined;
export default function getQueryClient() {
if (isServer) {
return makeQueryClient();
}
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
}
The shouldDehydrateQuery config is essential โ without it, pending (un-awaited) queries are excluded from dehydration and streaming breaks.
HydrateProvider
Thin wrapper around TanStack's HydrationBoundary:
'use client';
import {
HydrationBoundary,
HydrationBoundaryProps,
} from '@tanstack/react-query';
export const HydrateProvider = (props: HydrationBoundaryProps) => {
return <HydrationBoundary {...props} />;
};
Streaming vs Blocking Decision Guide
| Scenario | Pattern | Why |
|---|
| Data needed for initial render / SEO | await prefetchQuery(...) | Page waits, but content is in first HTML payload |
| Data for below-the-fold / secondary UI | prefetchQuery(...) (no await) | HTML ships fast, data streams in via Suspense |
| Data only needed on interaction | Don't prefetch, use useQuery() on client | Fetches on demand, no server cost |
| Infinite scroll / pagination | prefetchInfiniteQuery(...) (no await) | First page streams, user fetches more on scroll |
Common Mistakes
- Mismatched queryKeys โ Server prefetches with one key, client reads with another. Data won't hydrate.
- Missing
shouldDehydrateQuery โ Pending queries won't be included in dehydration. Streaming silently breaks.
- Using
useQuery instead of useSuspenseQuery โ useQuery won't throw a promise for Suspense to catch. Component renders with undefined data.
- Awaiting everything โ Defeats the purpose of streaming. Only await what's critical for first paint.
- No Suspense boundary โ
useSuspenseQuery throws a promise. Without a <Suspense> ancestor, it crashes.
- Single Suspense boundary for everything โ All data must resolve before anything renders. Use separate boundaries for independent sections.
- Forgetting
'use client' โ Components using useSuspenseQuery must be client components.
- Passing prefetched data or route params as props โ see the IMPORTANT section above. Children must read prefetched data via the counterpart
useX hook and route params via useParams. Props drilling defeats the cache, churns identities, and causes duplicate/extra fetches.
Reference