Data Fetching with TanStack Query
The problem this solves
Before this change, every organisation page fetched data in async server components. This meant:
- Client-side navigations (spotlight → analyst → back) triggered full server re-renders — even though the data hadn't changed.
- No stale-while-revalidate — when enrichment updated an organisation, users had to manually refresh the page.
- All-or-nothing loading — the entire risk assessment was behind one Suspense boundary. If one of the 7 cells was slow, all 7 showed a skeleton.
TanStack Query gives us a client-side cache that persists across navigations, granular loading/error states per cell, and targeted invalidation when enrichment completes.
Mental model
Think of it as two worlds cooperating:
SERVER (layout.tsx) CLIENT (hooks + components)
───────────────── ────────────────────────────
1. Fetch org data (await) 4. useSuspenseQuery reads from cache
2. Seed cache with setQueryData - cache hit → instant render
3. Fire prefetches (no await) - still pending → suspend (show skeleton)
↓ - error → caught by error boundary
dehydrate(queryClient)
↓ 5. On client navigation (e.g. back button)
HydrationBoundary streams → data still in cache (60s staleTime)
pending results to client → no server round-trip neededThe server prefetches and streams. The client consumes and caches. They share the same query keys but use different queryFn implementations — direct function calls on the server, server actions on the client.
The lifecycle of a page load
Step 1: Layout seeds and prefetches
organisations/[id]/layout.tsx is the entry point:
// Data the layout already needs — awaited normally
const [{ accountId }, organisation] = await Promise.all([
requireAccount(),
getOrganisationCached(id),
]);
// Seed the cache so client hooks get an instant hit
const queryClient = getQueryClient();
queryClient.setQueryData(queryKeys.organisations.detail(id), organisation);
// Fire 5 prefetches — NOT awaited
prefetchOrganisationOverview(queryClient, id, accountId);
return (
<HydrationBoundary state={dehydrate(queryClient)}>
{children}
</HydrationBoundary>
);setQueryData puts the organisation into the cache immediately — any component calling useOrganisation(id) gets it for free.
prefetchOrganisationOverview fires 5 void queryClient.prefetchQuery(...) calls. They start executing on the server but the layout does not wait for them to finish.
Step 2: Dehydration includes pending queries
When dehydrate(queryClient) runs, the 5 prefetches are likely still in flight. Normally TanStack Query only dehydrates successful queries, but our config extends this:
// get-query-client.ts
dehydrate: {
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
}This means pending promises are serialized into the HydrationBoundary state. React's RSC streaming protocol delivers the resolved results to the client as they complete — the shell paints immediately.
Step 3: Client components read from the cache
When a component like RiskAndFlagsCellsLoader mounts:
const { data: riskData } = useOrganisationRisk(organisationId);
// ↓
// useSuspenseQuery({
// queryKey: queryKeys.organisations.risk(organisationId),
// queryFn: () => queryAction(fetchRiskAssessmentAction({ organisationId })),
// })Three possible outcomes:
- Prefetch already resolved → data is in cache → render immediately
- Prefetch still streaming →
useSuspenseQuerysuspends →<Suspense>fallback (skeleton) shows until data arrives - Prefetch failed → error propagates → caught by error boundary → em-dash fallback renders
The queryFn (server action) is only called if the cache is empty or stale. On initial load, the prefetch already populated the cache, so the server action is never called.
Step 4: Client-side navigation
User navigates from spotlight → analyst → back to spotlight. Because the data is cached with staleTime: 60_000, all hooks return instantly from cache. No server round-trip. This is the biggest UX win.
Key files
| File | Purpose |
|---|---|
lib/get-query-client.ts | QueryClient factory (request-scoped on server, singleton in browser) |
lib/query-keys.ts | Hierarchical key factory for targeted invalidation |
lib/query-action.ts | Bridges zsa [data, err] tuples → TanStack Query's throw-on-error convention |
app/query-provider.tsx | Client-side QueryClientProvider wrapper |
organisations/[id]/layout.tsx | Seeds cache + fires prefetches + wraps in HydrationBoundary |
organisations/[id]/prefetch-organisation-overview.ts | The 5 fire-and-forget prefetches |
organisations/[id]/actions.ts | Server actions used as queryFn on the client |
hooks/use-organisation-risk.ts | useSuspenseQuery hooks for each data type |
hooks/use-enrichment-status.ts | useQuery with polling for enrichment progress |
_components/enrichment-refresher.tsx | Realtime + polling → targeted cache invalidation |
Why server prefetches and client hooks use different queryFns
On the server, the queryFn calls functions directly:
// prefetch-organisation-overview.ts
queryFn: () => getRiskAssessmentData(id) // direct call, no HTTP overheadOn the client, the queryFn calls server actions:
// hooks/use-organisation-risk.ts
queryFn: () => queryAction(fetchRiskAssessmentAction({ organisationId }))This is because client code can't call server functions directly — it must go through a server action (which Next.js routes over HTTP). The queryAction wrapper is needed because zsa server actions return [data, null] | [null, error] tuples, but TanStack Query expects the function to throw on error.
Both use the same query key, so the prefetch populates the exact cache entry that the client hook reads from.
Query key factory and invalidation
Keys are hierarchical arrays. TanStack Query invalidation matches by prefix:
['organisations'] ← matches EVERYTHING below
['organisations', 'org-1'] ← matches all data for org-1
['organisations', 'org-1', 'risk'] ← matches just risk for org-1
['organisations', 'org-1', 'flag-counts']
['organisations', 'org-1', 'spend']
...When enrichment completes, EnrichmentRefresher only invalidates the queries enrichment can change:
queryClient.invalidateQueries({ queryKey: queryKeys.organisations.detail(id), exact: true });
queryClient.invalidateQueries({ queryKey: queryKeys.organisations.risk(id) });
queryClient.invalidateQueries({ queryKey: queryKeys.organisations.flagCounts(id) });
queryClient.invalidateQueries({ queryKey: queryKeys.organisations.reportedIssues(id) });
// spend and controlStats are NOT invalidated — they come from transaction data, not enrichmentThe Suspense + error boundary composition
Each scorecard cell is wrapped in three layers:
{profilePending ? (
<Skeleton /> // enrichment gate — don't even try
) : (
<ErrorBoundary> // catches query errors → renders em-dashes
<Suspense fallback={<Skeleton />}> // shows skeleton while query resolves
<Loader organisationId={id} /> // useSuspenseQuery → renders data
</Suspense>
</ErrorBoundary>
)}This means:
- Cell A can be loading while Cell B shows data and Cell C shows an error
- During first-time enrichment (no data exists yet), skeletons show without firing doomed queries
- A single failed fetch doesn't take down the entire scorecard
How to add a new query
1. Add the key
// lib/query-keys.ts
compliance: (id: string) => ['organisations', id, 'compliance'] as const,2. Add the server action
// organisations/[id]/actions.ts
export const fetchComplianceAction = createServerAction()
.input(byOrganisationId)
.handler(async ({ input }) => {
await requireAccount();
return new GetCompliance().execute({ organisationId: input.organisationId });
});3. Add the hook
// hooks/use-organisation-compliance.ts
export function useOrganisationCompliance(organisationId: string) {
return useSuspenseQuery({
queryKey: queryKeys.organisations.compliance(organisationId),
queryFn: () => queryAction(fetchComplianceAction({ organisationId })),
staleTime: 60 * 1000,
});
}4. Optionally add a prefetch
Add to prefetch-organisation-overview.ts if the data should be ready on first paint:
void queryClient.prefetchQuery({
queryKey: queryKeys.organisations.compliance(id),
queryFn: () => getComplianceData(id),
});5. Add to invalidation
If enrichment affects this data, add to enrichment-refresher.tsx:
queryClient.invalidateQueries({ queryKey: queryKeys.organisations.compliance(organisationId) });