PickSkill
← Back

ssr-data-fetching

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.

SKILL.md
Rendered from GitHub raw
View raw ↗

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:

  1. Key factory — Hierarchical query keys
  2. Client hooksuseQuery / 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:

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

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

  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. <Hydrate> is mandatory — Every page that prefetches wraps children with <Hydrate>.
  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

// ❌ 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 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().