name: ssr-data-fetching description: "Guides SSR-first data fetching with TanStack Query v5 in Next.js. Activates when creating query hooks, prefetch functions, hydration patterns, API clients, or wiring server-rendered pages with client-side data consumption."
SSR-First Data Fetching
This project uses an SSR-first approach: data is prefetched on the server and hydrated on the client. Users see fully rendered pages on first load — no loading spinners.
IMPORTANT: The most critical rule is that prefetch functions and client hooks MUST use identical query keys. If keys don't match, hydration fails and the client refetches from scratch (causing a loading spinner on first render).
Architecture
Server (page.tsx) Client (component.tsx)
┌────────────────────────┐ ┌──────────────────────────┐
│ await prefetchTasks() │ │ const { data } = useTasks()│
│ → getQueryClient() │ dehydrate │ → data available │
│ → .prefetchQuery() │ ──────────→ │ immediately │
│ │ HydrationB. │ → no loading state │
│ <Hydrate> │ │ → refetches in bg │
│ <TaskList /> │ │ when stale │
│ </Hydrate> │ │ │
└────────────────────────┘ └──────────────────────────┘
Infrastructure Files
| File | Purpose |
|---|---|
src/lib/query-client.ts |
QueryClient factory. Server uses cache() for per-request singleton. Browser uses module-level singleton. |
src/lib/hydrate.tsx |
Server Component that dehydrates QueryClient and wraps children in HydrationBoundary. |
src/lib/api-client.ts |
apiClient() for client, serverApiClient() for server (auto-injects auth token + cache tags). |
Query File Convention
Each feature with data fetching has a queries/use-[resource].ts file containing three things:
- Key factory — Hierarchical query keys
- Client hooks —
useQuery/useMutationfor"use client"components - Server prefetch functions — Called from
page.tsxServer Components
See examples/query-file.ts for the complete pattern.
Step-by-Step: Creating a Query File
Step 1: Define the Key Factory
Keys are hierarchical to enable granular cache invalidation:
export const taskKeys = {
all: ["tasks"] as const,
lists: () => [...taskKeys.all, "list"] as const,
list: (filters: TaskFilters) => [...taskKeys.lists(), filters] as const,
details: () => [...taskKeys.all, "detail"] as const,
detail: (id: string) => [...taskKeys.details(), id] as const,
};
This enables:
queryClient.invalidateQueries({ queryKey: taskKeys.all })→ invalidate everythingqueryClient.invalidateQueries({ queryKey: taskKeys.lists() })→ invalidate all listsqueryClient.invalidateQueries({ queryKey: taskKeys.detail(id) })→ invalidate one detail
Step 2: Create Client Hooks
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/lib/api-client";
import type { Task, TaskFilters } from "../types/task";
export function useTasks(filters?: TaskFilters) {
return useQuery({
queryKey: taskKeys.list(filters ?? {}),
queryFn: async () => {
const res = await apiClient<Task[]>("/api/tasks", {
params: filters,
});
return res.data;
},
});
}
export function useCreateTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: CreateTaskInput) => {
const res = await apiClient<Task>("/api/tasks", {
method: "POST",
body: input,
});
return res.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: taskKeys.lists() });
},
});
}
Step 3: Create Server Prefetch Functions
CRITICAL: Use the SAME query keys as client hooks.
import { getQueryClient } from "@/lib/query-client";
import { serverApiClient } from "@/lib/api-client";
export async function prefetchTasks(filters?: TaskFilters) {
const queryClient = getQueryClient();
await queryClient.prefetchQuery({
queryKey: taskKeys.list(filters ?? {}), // ← MUST match useTasks() key
queryFn: async () => {
const res = await serverApiClient<Task[]>("/api/tasks", {
params: filters,
tags: ["tasks"],
});
return res.data;
},
});
}
Step 4: Wire into Thin Page
// src/app/(dashboard)/tasks/page.tsx
import { Hydrate } from "@/lib/hydrate";
import { TaskList, prefetchTasks } from "@/features/tasks";
export default async function TasksPage() {
await prefetchTasks();
return (
<Hydrate>
<TaskList />
</Hydrate>
);
}
Step 5: Consume in Client Component
"use client";
import { useTasks } from "@/features/tasks";
import { ErrorState, LoadingState, EmptyState } from "@/features/feedback";
export function TaskList() {
const { data: tasks, isLoading, error } = useTasks();
if (error) return <ErrorState error={error} />;
if (isLoading) return <LoadingState />;
if (!tasks?.length) return <EmptyState title="No tasks" description="Create your first task" />;
return (
<ul>
{tasks.map((task) => (
<li key={task.id}>{task.title}</li>
))}
</ul>
);
}
API Client Decision Tree
Where is this code running?
├── Server Component / page.tsx / route handler
│ └── Use serverApiClient() — auto-injects auth token, supports cache tags
├── "use client" component
│ └── Use apiClient() — uses browser cookies for auth
└── Unsure?
└── If the file has "use client" → apiClient(). Otherwise → serverApiClient().
API Response Envelope
All API routes return a consistent shape:
type ApiResponse<T> = {
data: T | null;
error: { message: string; code: string } | null;
meta?: { page?: number; total?: number };
};
Critical Rules
- Pages prefetch, components consume — Pages call
await prefetchXxx(), client components calluseXxx(). - Same query keys — Prefetch functions and client hooks MUST use identical keys for hydration to work.
<Hydrate>is mandatory — Every page that prefetches wraps children with<Hydrate>.- Validate at boundaries — Use Zod schemas to validate API responses inside
queryFn. serverApiClientfor server — Auto-injects auth token and supports Next.js cache tags.apiClientfor client — Uses browser cookies for authentication.- Cache tags — Server fetches include
tagsfor Next.js on-demand revalidation viarevalidateTag(). - Feedback states are mandatory — Components consuming queries MUST handle error, loading, and empty states using
ErrorState,LoadingState,EmptyStatefrom@/features/feedback. - Export from barrel — Prefetch functions, hooks, and key factories are all exported from the feature's
index.ts.
Filter Integration
- Server filters go into the query key and are sent as request params — trigger refetch
- Client filters (search, sort) are applied via
useMemo+applyClientFilters()from@/lib/filters— instant, no network - Two separate schemas:
[entity]ServerFiltersSchema+[entity]ClientFiltersSchema
Common Anti-Patterns
// ❌ WRONG: Different keys in prefetch vs client hook
// Prefetch:
queryKey: ["tasks", "list"]
// Client hook:
queryKey: ["tasks"]
// Result: Hydration fails, client refetches from scratch
// ❌ WRONG: Fetching data directly in page instead of using prefetch
export default async function TasksPage() {
const tasks = await fetch("/api/tasks"); // ❌ bypasses React Query cache
return <TaskList tasks={tasks} />; // ❌ prop drilling instead of hook
}
// ❌ WRONG: Using serverApiClient in a "use client" component
"use client";
import { serverApiClient } from "@/lib/api-client"; // ❌ server-only in client
// ❌ WRONG: Missing <Hydrate> wrapper
export default async function TasksPage() {
await prefetchTasks();
return <TaskList />; // ❌ no <Hydrate> — hydration won't work
}
// ❌ WRONG: Returning null when no data
if (!tasks?.length) return null; // ❌ blank page
// ✅ CORRECT:
if (!tasks?.length) return <EmptyState title="No tasks" />;
DO NOT
- DO NOT use different query keys in prefetch and client hooks — they MUST be identical.
- DO NOT forget
<Hydrate>— every page that prefetches MUST wrap with<Hydrate>. - DO NOT use
serverApiClientin client components — it's server-only. - DO NOT use
apiClientin server prefetch — useserverApiClientinstead. - DO NOT fetch data directly in pages — use
prefetchXxx()+ React Query. - DO NOT return
nullfor empty data — useEmptyStatefrom@/features/feedback. - DO NOT skip error/loading states — every data-consuming component MUST handle all three states.
- DO NOT create a new QueryClient per request in client code — use
getQueryClient().

