| name | data-fetching |
| description | Read data in a Next.js 16 App Router app the canonical way — async Server Components first, URL `searchParams` for filter state, `use()` + `<Suspense>` when a Client Component needs server data, Route Handlers + SWR/React Query only as a last resort. Server Actions are for mutations, never reads. Use when the user is about to load data via a Server Action, about to add `useEffect` to fetch, about to convert a page to `"use client"` for filter state, or pastes `useState + useEffect + fetch` in a Client Component. Also owns Next.js 16 Cache Components — `cacheComponents`, `use cache`, `cacheLife`, `revalidateTag`, `partialPrefetching`, Instant Navigation — and any question about caching, prefetching or navigation speed. Refuses outside Next.js 16 web. Not for: form persistence (`forms`), local UI state (`state-discipline`), React Native (`rn-data-fetching`), or mutations. |
data-fetching — Server Components first, never useEffect for reads
This skill governs where data reads land in a Next.js 16 App Router app. The framework lets you call a "use server" function from a Client Component — that's a capability, not a license. Reading data via a Server Action in useEffect costs you SSR, streaming, request deduping, caching, and parallelism. The bug is silent: no error, no warning, just worse UX and wasted POSTs.
When this skill applies
- The user is about to call a Server Action from a Client Component to load data.
- The user is about to add
useEffect (at all — but especially to fetch).
- The user pastes
"use client" + useState + useEffect + fetch/getX and asks for review.
- The user is about to convert a page to
"use client" so it can host filter/tab state.
- The user adds a
"use server" function whose only job is SELECT / read.
- The user asks to audit a Next.js codebase against the data-fetching rules.
Contract
Follows the dev-flow contract — see references/contracts.md. Key facts:
- Reads
meta.json#stack.framework and stack.nextjs_version. For framework = "monorepo", reads stack.monorepo.web.framework and stack.monorepo.web.nextjs_version.
- Refuses to apply if:
stack.framework ∉ {"next", "monorepo"} — Server Components / Server Actions don't exist on RN, Remix, SvelteKit, Astro, plain React, etc.
stack.nextjs_version != "16" — searchParams is async in 16 (was sync in 15), revalidatePath import path moved, refresh() from next/cache is new. Do not silently translate the rules.
- The project uses Pages Router (
pages/ directory). Different mental model (getServerSideProps / getStaticProps / API routes / SWR) — refuse rather than translate.
- Appends a
history entry per refactor.
- Does not bump
phase.
Companion skills
state-discipline (sibling in dev-flow) — owns the broader React-side rule (never bare useEffect; derive state, use a query lib, use event handlers, key to reset, useMountEffect for one-time external sync). Install and follow it alongside this skill.
forms — for any UI that persists field values to the backend. Forms are mutation-heavy and have their own toolkit; reads inside a form (e.g. preloading the entity to edit) follow this skill's rules.
If a green example below looks like it would have been a useEffect in older code, that's the point — it isn't one anymore. The red ❌ blocks show useEffect only because that's what the anti-pattern looks like in the wild; never copy from a red block.
The Rule
Read data in Server Components. Mutate data with Server Actions. Never use useEffect (or useState + useEffect) in a Client Component to call a Server Action just to load data.
Violating the letter is violating the spirit. The signal that you've drifted is not a runtime error (there is none), it's the patterns below: a getX action, a useEffect that fetches, a page newly converted to "use client". The absence of a stack trace is not the absence of a problem.
Why
"Server Functions are designed for server-side mutations, and the client currently dispatches and awaits them one at a time. […] If you need parallel data fetching, use data fetching in Server Components."
— Next.js docs, mutating-data.mdx
"Server Actions are queued, and using them for data fetching introduces sequential execution."
— Next.js docs, backend-for-frontend.mdx
Concretely, a useEffect-driven Server Action read costs:
- No SSR — the page paints empty, then fetches after hydration. Worst LCP.
- Sequential queue — every Server Action call waits on the previous one.
- No request deduping / caching — Server Actions always POST.
- No streaming — no progressive render with
<Suspense>.
- Double-fetch on mount in Strict Mode dev.
- Larger client bundle — fetch logic, loading states, error states ship to the browser.
Decision: how to load data
The first question is not "where does the data need to land?" — it's "why is this a Client Component at all?" Most reads belong on the server. If the answer is anything weaker than "polling, focus refetch, or a third party mutates the data without user intent," the fix is to lift the read to a Server Component, not to swap the transport.
digraph data_fetching {
"Why is this a Client Component?" [shape=diamond];
"Server Component, await directly" [shape=box];
"URL searchParams; page stays Server Component" [shape=box];
"Promise<T> from Server Component, use() in Client leaf" [shape=box];
"Route Handler GET + TanStack Query (last resort)" [shape=box];
"Why is this a Client Component?" -> "Server Component, await directly" [label="It isn't / shouldn't be"];
"Why is this a Client Component?" -> "URL searchParams; page stays Server Component" [label="Filter / tab / range state"];
"Why is this a Client Component?" -> "Promise<T> from Server Component, use() in Client leaf" [label="Genuine interactivity at the data boundary, initial data only"];
"Why is this a Client Component?" -> "Route Handler GET + TanStack Query (last resort)" [label="Polling, focus refetch, or third-party mutates the data"];
}
The branches are not peers. Top to bottom: Server Component (default, ~90% of cases), URL state (most "I need filters" cases), use() + <Suspense> (rare), Route Handler + TanStack Query (last resort, narrow scope). Reaching for the bottom branch when an upper branch fits is the most common failure mode of this skill.
Server Actions are for mutations only.
Migrating away from useEffect + Server Action — the ladder
If you're staring at useState + useEffect + a "use server" read in a Client Component, walk this ladder top-down and stop at the first rung that fits. It's almost always rung 1.
- Lift the read to a Server Component. Convert the page to
async function Page({ searchParams }), await the read at the top, pass data down. If the page has interactive state, ask rung 2 before deciding it has to stay client.
- Move state to URL
searchParams. Tabs, filters, ranges, pagination, sort, search query — all belong in the URL. The Server Component reads the searchParams prop and re-renders with new data; the Client leaf writes the param with nuqs useQueryState/useQueryStates (typed parsers + built-in URL-update rate limiting — the ecosystem-first replacement for hand-rolled router.replace, which stays a fine fallback for a single param; note shallow: false when the Server Component must re-render — see references/nuqs.md). Free streaming, free cache, shareable URL, back-button works. One-time: <NuqsAdapter> in the root layout; createSearchParamsCache for type-safe reads in nested Server Components. Verified against nuqs@2.10.1, whose next peer range is >=14.2.0 — Next 16 needs no special handling.
- Pass
Promise<T> from Server Component, consume with use() + <Suspense>. Only when a Client Component genuinely needs server data as props at mount (charting libs, third-party widgets expecting a synchronous data shape).
GET Route Handler + TanStack Query (recommended default for this rung — retries, request dedup, devtools, mutation helpers; SWR is an acceptable lighter-weight alternative for a single simple polling widget, but don't reach for a second data library once TanStack Query is already in the project). Reserved for: interval polling, focus revalidation, third-party mutates the data outside your app. Not for "I already have a Client Component and want to keep it."
The lateral migration is the failure mode
useEffect + action → useQuery/useSWR + Route Handler in the same Client Component is the wrong refactor. It feels like progress — no more action-as-read — but:
- Page is still
"use client". No SSR, no streaming, same bad LCP.
- You traded a sequential POST queue for a sequential
fetch. Same waterfall.
- "Route Handlers cache!" — not for per-user, per-org reads. Your
/api/cases is Cache-Control: private; the CDN won't touch it.
- You added a network hop, a JSON serialization layer, a client library, an extra route file — for zero cache wins over the Server Component you should have written.
If you reached rung 4 without first asking "can this page simply be a Server Component?", back up.
The four correct patterns
1. Async Server Component — the default
import { listCases } from "@/lib/services/case.service";
export default async function CasesPage() {
const cases = await listCases();
return <CasesTable cases={cases} />;
}
No "use client", no useEffect, no Server Action.
2. Stream a promise to a Client Component with use() + <Suspense>
import { Suspense } from "react";
import { listCases } from "@/lib/services/case.service";
import CasesTable from "./_components/cases-table";
export default function CasesPage() {
const casesPromise = listCases();
return (
<Suspense fallback={<CasesTableSkeleton />}>
<CasesTable casesPromise={casesPromise} />
</Suspense>
);
}
"use client";
import { use } from "react";
export default function CasesTable({
casesPromise,
}: {
casesPromise: Promise<Case[]>;
}) {
const cases = use(casesPromise);
}
The Server Component starts the fetch, streams HTML as soon as it can, the Client Component hydrates with the resolved value. No client-side waterfall.
3. Server Action — only for mutations, invoked via <form> or event handler after user intent
"use server";
import { revalidatePath } from "next/cache";
export async function archiveCase(id: string) {
await caseService.archive(id);
revalidatePath("/cases");
}
After the mutation, invalidate and let the Server Component re-render with fresh data. Don't return a list to refresh client state by hand.
Variants — pick the narrowest:
revalidatePath('/cases') — invalidate by route segment.
revalidateTag('cases') — invalidate by tag (when a service uses fetch(..., { next: { tags: ['cases'] } }), unstable_cache's tags option, or — under Cache Components — cacheTag('cases') inside a "use cache" function/component).
refresh() from next/cache — inside a Server Action, refresh the client router cache for the current route. Useful when the mutation happens on the same page. Does not revalidate tagged data by itself — pair it with revalidateTag/updateTag when the mutation also needs to invalidate a tag.
revalidateTag signature depends on whether Cache Components (cacheComponents: true) is enabled — see Cache Components below for the full breakdown:
- Cache Components off (default / pre-16 caching model):
revalidateTag('cases') — single argument, expires the tag immediately. This is what the example above uses and it's correct for projects that haven't opted into Cache Components.
- Cache Components on: the single-argument form is deprecated. Use either:
revalidateTag('cases', 'max') in a Server Action or Route Handler — stale-while-revalidate: the current request still gets a fast (possibly stale) response, fresh data loads in the background.
updateTag('cases') — Server Actions only, immediate expiry, read-your-own-writes (the user who triggered the mutation sees the new value on the very next render, not a background refresh). Prefer this over revalidateTag inside Server Actions whenever the user needs to see their own change immediately (e.g. after creating or editing the record they're looking at).
4. Route Handler + TanStack Query (recommended) / SWR — last resort, narrow scope
Reach for this only when the data genuinely changes without user intent: interval polling, focus revalidation, third-party mutates the data outside your app. Anything else belongs in patterns 1–3.
TanStack Query is the default for this rung — it's what the project already has if forms scaffolded stack.forms = "tanstack-form", and it gives you retries, request dedup, devtools, and mutation helpers for free. Reach for SWR only for a genuinely trivial one-off polling widget in a project that has no other client-side query library; never install both.
import { NextResponse } from "next/server";
export async function GET(req: Request) {
const range = new URL(req.url).searchParams.get("range") ?? "30d";
return NextResponse.json(await getDashboardStats(range));
}
"use client";
import { useQuery } from "@tanstack/react-query";
export function LiveStats({ range }: { range: string }) {
const { data } = useQuery({
queryKey: ["dashboard-stats", range],
queryFn: () =>
fetch(`/api/dashboard/stats?range=${range}`).then((r) => r.json()),
refetchInterval: 5_000,
});
return ;
}
"use client";
import useSWR from "swr";
export function LiveStats({ range }: { range: string }) {
const { data } = useSWR(`/api/dashboard/stats?range=${range}`, fetcher, {
refreshInterval: 5_000,
});
return ;
}
If your Client Component doesn't poll, doesn't refetch on focus, and isn't watching externally-mutated data — you don't need this.
Cache Components / use cache (Next 16, opt-in)
Next 16 introduces Cache Components, the explicit opt-in caching model, enabled with cacheComponents: true in next.config.ts. Once on, nothing is cached unless you mark it with the "use cache" directive — the framework stops implicitly caching and you cache deliberately. This is orthogonal to the ladder above: you still default to async Server Components; "use cache" is for expensive reads you want memoized across requests (a slow aggregate query, a third-party API call, a rarely-changing config), not a replacement for RSC data fetching.
import type { NextConfig } from "next";
const nextConfig: NextConfig = { cacheComponents: true };
export default nextConfig;
export async function getCaseStats() {
"use cache";
cacheLife("hours");
cacheTag("cases");
return db.select();
}
cacheLife(profile) — the freshness/expiry profile ("seconds" | "minutes" | "hours" | "days" | "max", or a custom profile in next.config.ts).
cacheTag(tag) — attaches a tag; a Server Action then invalidates it.
Invalidation under Cache Components (this is why the mutation section above branches on the flag):
updateTag('cases') — Server Actions only, immediate expiry with read-your-own-writes. Prefer it when the user must see their own change on the next render (e.g. right after editing the record they're viewing).
revalidateTag('cases', 'max') — the second argument (a cache profile) is required here; it's stale-while-revalidate (the triggering request may still see stale data while fresh loads in the background). The single-argument revalidateTag('cases') is the pre-Cache-Components form and is deprecated once cacheComponents is on.
⚠️ Half of that sentence is no longer true, and it's the half that bites. Read off
next@16.3.3's own shipped docs (dist/docs/…/functions/):
| Signature at 16.3.3 | Status |
|---|
revalidatePath | (path: string, type?: 'page' | 'layout') | single-argument form fine, type is optional |
revalidateTag | (tag: string, profile: string | { expire?: number }) | ⚠️ single-argument form deprecated — "It currently works if TypeScript errors are suppressed, but this behavior may be removed in a future version. Update to the two-argument signature." |
updateTag | (tag: string) | single-argument, the Cache-Components-era companion |
The revalidateTag deprecation carries no cacheComponents qualifier — it applies whether or not
you opted in, so "ignore this section if you haven't enabled it" was wrong for that one call. And note
how it fails: TypeScript complains, the runtime doesn't. Suppress the error and you have shipped a form
Next says may stop working.
cacheLife profiles, verified from the same docs — default (5 min stale / 15 min revalidate / never
expire), seconds, minutes, hours, days, weeks, max (30 days revalidate / 1 year expire).
[VERIFY] the exact cacheLife profile names and the revalidateTag/updateTag signatures against the installed Next version — this surface is new in 16 and still settling.
Instant Navigation (Next 16.3, stable — opt-in via cacheComponents + partialPrefetching)
⚠️ Two corrections from next@16.3.3's shipped docs. The feature is "Instant Navigation", singular
— the plural appears zero times in the docs, the guide is at /docs/app/guides/instant-navigation, and
the route-segment config key is instant. And it needs both flags, not just one:
const nextConfig: NextConfig = { cacheComponents: true, partialPrefetching: true };
"Getting the most out of Instant Navigation requires enabling Cache Components and Partial
Prefetching, then following the validation errors that appear."
Doc-grounded against https://nextjs.org/docs/app/guides/instant-navigation (page states version: 16.3.0). Not preview — 16.3.0 is the latest npm tag. Still opt-in: it rides on cacheComponents: true, which Vercel says will become a default in a future major.
Definition. A navigation is instant when the browser starts rendering the moment the user clicks: static/cached/fallback content appears immediately while the server streams the rest into its fallbacks. (Assumes warm caches — a cold cache still computes once.)
The distinction that explains everything: a direct visit and a client navigation produce different initial UI.
- Direct visit → the static shell as HTML, typically from a CDN.
- Client navigation → only the tree below the shared layout re-renders, so a
<Suspense> boundary sitting above that point never triggers. Next generates a per-route App Shell for this case.
That's why the same page can be instant on load and blocking on navigation. (Same reason useSearchParams() suspends on a page load but resolves synchronously on a client navigation — the router already has the params.)
Three levers when a route awaits something:
| Lever | How | Effect |
|---|
| Stream | wrap in <Suspense> | fallback shows instantly, content streams in |
| Cache | 'use cache' (+ cacheLife) | the value lands in the shell |
| Block | export const instant = false in page.tsx/layout.tsx | opts the segment out of validation feedback; that segment's navigation blocks on the server |
⚠️ Read instant = false precisely: it silences insights for that segment, it does not "make it dynamic". Sibling navigations below it are still validated. Prefer use cache: private over opting out when the content depends on cookies()/headers() but has a known lifetime (needs stale ≥ 5 minutes).
Validation is ON by default under Cache Components (validationLevel: 'warning'): every Page and Default segment is validated in development, simulating both page load and client navigation. Opt down to only-explicitly-marked segments:
const nextConfig: NextConfig = {
cacheComponents: true,
experimental: { instantInsights: { validationLevel: 'manual-warning' } },
};
Two use cache variants worth knowing (they change what can be instant):
'use cache: private' — for functions reading cookies()/headers(). Cached in the browser only, so it cannot be part of the static shell; it pairs with runtime prefetching.
'use cache: remote' — persistent caching. Plain 'use cache' is in-memory and does not survive across serverless instances, which is the trap on Vercel.
Partial Prefetching (its own adoption guide) changes <Link>: each visible link prefetches the destination's App Shell, shared across every link to the same route — so rendering a <Link> is effectively free (no more one request per link). prefetch={true} adds the page content and opts into runtime prefetching, resolving that link's params / searchParams / full URL before the click.
Tooling. Navigation Inspector (DevTools) freezes the page at its initial loading state — "Pause on navigations", then Resume — showing the static shell on direct visits and the prefetched destination on client navigations; pair with the React DevTools Suspense panel. Regression guard: the instant() helper from @next/playwright (see write-tests → references/test-page-e2e.md).
How it maps onto our ladder: nothing here replaces it. Rung 1 (async Server Component) + <Suspense> is Stream; the Cache Components section above is Cache. What 16.3 adds is a validator that tells you when a route silently fell off the ladder, plus the vocabulary to say "this one blocks on purpose".
Adopting it — don't hand-roll the migration. Official paths, in order:
- Migrating to Cache Components — the canonical migration guide for an existing app.
- Adopting Partial Prefetching — the
<Link> defaults and the migration off unstable_eager.
- Four official Skills live in
vercel/next.js/skills/ (install: npx skills add vercel/next.js --skill <name>):
next-cache-components-adoption — flip the flag and walk the app to a passing build (ships a cache-components-instant-false codemod).
next-partial-prefetching-adoption — flip partialPrefetching, opt routes in with export const prefetch = 'partial'.
next-cache-components-optimizer — the observe → test → fix → verify loop: confirms the UI renders, writes an instant() test that fails first, works it to green against a production-like build, leaves it as a regression guard.
next-dev-loop — the general edit → verify loop (/_next/mcp + agent-browser), not Cache-Components-specific.
[VERIFY] against https://nextjs.org/docs/app/guides/ai-agents. Note Vercel's own rule there: framework knowledge comes from the bundled docs, not from Skills — Skills cover workflows, not lookups. That is exactly our rule zero.
Next 16.3 — what you get by just upgrading [VERIFY]
Released 2026-08-03 (npm install next@latest). These need no config and no code change — recommend the upgrade to any Next 16 project:
- ~90% less dev memory (Turbopack disk caching + memory eviction, now on by default) and faster repeat builds (the filesystem cache now covers
next build).
- ~22% more requests under load — the App Router SSR layer moved from web streams to native Node.js streams.
- TypeScript 7 for type checking during
next build — just bump the local dep (pnpm add -D typescript@^7).
- Fewer prefetch requests — small prefetch payloads are bundled together automatically.
New APIs worth knowing (opt-in, no flag):
catchError from next/error — a custom error boundary that, unlike a plain React error boundary, does not interfere with notFound() / redirect(), and hands you a retry() that re-fetches the boundary's children including re-rendering failed Server Components. This is the missing piece for "the read failed, let the user retry" — previously you could only reset client state.
import.meta.glob (Vite-compatible, via Turbopack) — load many local files from a Server Component with HMR, e.g. a folder of markdown posts.
Experimental in 16.3, flag-gated: the Rust React Compiler (reactCompiler + experimental.turbopackRustReactCompiler) and network resilience (experimental.useOffline + a useOffline() hook from next/offline, which keeps a navigation/fetch/Server Action pending and retries on reconnect instead of throwing).
Anti-pattern catalog — red ❌ → green ✅
The full red→green catalog (6 patterns: action-in-useEffect, useState-filter, manual-refetch-after-mutation, getX-in-actions, await-then-pass-to-client, optimistic-by-hand) is in references/anti-patterns.md. Brief index:
- Reading via Server Action in
useEffect → async Server Component.
- Filter / tab state in
useState, refetched via action → URL searchParams + Server Component re-render.
- Manual re-read after mutation →
revalidatePath / revalidateTag / refresh inside the action.
"use server" file containing read-only getX → move reads to lib/services/, called directly from Server Components.
await in parent then pass to Client (blocks streaming) → pass unawaited Promise<T>, consume with use() + <Suspense>.
- Optimistic UI by hand (
useState + manual diff) → useOptimistic + revalidatePath inside the action.
Service layer placement
Reads live in lib/services/<entity>.service.ts — called directly from Server Components.
import { db } from "@/lib/db";
import { cases } from "@/lib/db/schema";
import { requireOrgPermission } from "@/lib/auth";
export async function listCases(filters: CaseFilters = {}) {
await requireOrgPermission("org:cases:read");
return db.select().from(cases).where();
}
Mutation actions in lib/actions/<entity>.actions.ts import the service for the write side. No getX/listX/findX in lib/actions/. Service stays the single source of truth.
Workflow
Step 1 — verify the contract
Read .workflow/meta.json. Confirm stack.framework ∈ {"next", "monorepo"} and stack.nextjs_version = "16". Else refuse, explain why.
Step 2 — diagnose the call site
For a refactor request, walk the ladder top-down. For a new read, default to pattern 1 (async Server Component) unless the user has a stated reason for rungs 2–4.
Step 3 — apply the pattern
Refactor / scaffold per the matching pattern above. If the read currently lives in lib/actions/, move it to lib/services/ first (anti-pattern 4).
Step 4 — append history
{
"skill": "data-fetching",
"ran_at": "<now>",
"outputs": ["app/(app)/cases/page.tsx", "lib/services/case.service.ts"],
"phase_before": "<unchanged>",
"phase_after": "<unchanged>"
}
Audit mode
When the user asks "audit my codebase against data-fetching" / "scan for read anti-patterns", produce a report. The audit recipe (ripgrep queries for each violation, severity rubric, report template) lives in references/audit-recipe.md.
Violation kinds:
| Code | Violation | Severity |
|---|
| A | useEffect calling a Server Action (getX/listX/findX) | high |
| B | useState + useEffect + fetch in a Client Component for initial data | high |
| C | Filter/tab state in useState causing client-side refetch loop | high |
| D | "use server" file containing read-only getX/listX/findX | medium |
| E | await in Server Component then pass result to Client (no <Suspense> streaming) | medium |
| F | Manual list re-read after mutation (no revalidatePath / revalidateTag) | high |
| G | Route Handler + SWR for a read that should be a Server Component | medium |
Sources
This skill is derived from the nextjs-data-fetching skill from lusentis/next-skills (MIT-licensed), adapted to the dev-flow contract (reads meta.json#stack.framework / stack.nextjs_version, appends history, refuses on mismatch). The migration ladder, decision graph, four patterns, and anti-pattern catalog are preserved.
When in doubt
Ask: "why is this a Client Component at all?" If the honest answer is anything weaker than polling / focus refetch / third-party mutation, the read belongs on the server. Lift it.