--- 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()`.