---
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 │
│ │ │ → refetches in bg │
│ │ │ when stale │
│ │ │ │
└────────────────────────┘ └──────────────────────────┘
```
## 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:
1. **Key factory** — Hierarchical query keys
2. **Client hooks** — `useQuery` / `useMutation` for `"use client"` components
3. **Server prefetch functions** — Called from `page.tsx` Server 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:
```typescript
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 everything
- `queryClient.invalidateQueries({ queryKey: taskKeys.lists() })` → invalidate all lists
- `queryClient.invalidateQueries({ queryKey: taskKeys.detail(id) })` → invalidate one detail
### Step 2: Create Client Hooks
```typescript
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("/api/tasks", {
params: filters,
});
return res.data;
},
});
}
export function useCreateTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: CreateTaskInput) => {
const res = await apiClient("/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.
```typescript
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("/api/tasks", {
params: filters,
tags: ["tasks"],
});
return res.data;
},
});
}
```
### Step 4: Wire into Thin Page
```typescript
// 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 (
);
}
```
### Step 5: Consume in Client Component
```typescript
"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 ;
if (isLoading) return ;
if (!tasks?.length) return ;
return (
{tasks.map((task) => (
- {task.title}
))}
);
}
```
## 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:
```typescript
type ApiResponse = {
data: T | null;
error: { message: string; code: string } | null;
meta?: { page?: number; total?: number };
};
```
## Critical Rules
1. **Pages prefetch, components consume** — Pages call `await prefetchXxx()`, client components call `useXxx()`.
2. **Same query keys** — Prefetch functions and client hooks MUST use identical keys for hydration to work.
3. **`` is mandatory** — Every page that prefetches wraps children with ``.
4. **Validate at boundaries** — Use Zod schemas to validate API responses inside `queryFn`.
5. **`serverApiClient` for server** — Auto-injects auth token and supports Next.js cache tags.
6. **`apiClient` for client** — Uses browser cookies for authentication.
7. **Cache tags** — Server fetches include `tags` for Next.js on-demand revalidation via `revalidateTag()`.
8. **Feedback states are mandatory** — Components consuming queries MUST handle error, loading, and empty states using `ErrorState`, `LoadingState`, `EmptyState` from `@/features/feedback`.
9. **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
```typescript
// ❌ 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 ; // ❌ 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 wrapper
export default async function TasksPage() {
await prefetchTasks();
return ; // ❌ no — hydration won't work
}
// ❌ WRONG: Returning null when no data
if (!tasks?.length) return null; // ❌ blank page
// ✅ CORRECT:
if (!tasks?.length) return ;
```
## DO NOT
- DO NOT use different query keys in prefetch and client hooks — they MUST be identical.
- DO NOT forget `` — every page that prefetches MUST wrap with ``.
- DO NOT use `serverApiClient` in client components — it's server-only.
- DO NOT use `apiClient` in server prefetch — use `serverApiClient` instead.
- DO NOT fetch data directly in pages — use `prefetchXxx()` + React Query.
- DO NOT return `null` for empty data — use `EmptyState` from `@/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()`.