Skip to content
Back to skills

Data Fetching

BSecurity

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...

  • 6 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 19, 2026
developmenttypescriptrustgoshellreactnextjsnodegitapifrontend

Works with

  • cli
  • api
  • mcp

Security analysis

B84/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro scans all 5 files and shows the line behind each finding

Scanned October 19, 2026

npx -y skills add lukedj78/dev-flow --skill data-fetching --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Data Fetching?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Data Fetching
[![Security: B — Skills Directory](https://www.skillsdirectory.com/api/skills/lukedj78-data-fetching/badge)](https://www.skillsdirectory.com/skills/lukedj78-data-fetching)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
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.

```dot
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.

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.
2. **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`](https://nuqs.dev) `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.
3. **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).
4. **`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

```tsx
// app/(app)/cases/page.tsx
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>`

```tsx
// app/(app)/cases/page.tsx — Server Component
import { Suspense } from "react";
import { listCases } from "@/lib/services/case.service";
import CasesTable from "./_components/cases-table"; // "use client"

export default function CasesPage() {
  const casesPromise = listCases(); // do NOT await
  return (
    <Suspense fallback={<CasesTableSkeleton />}>
      <CasesTable casesPromise={casesPromise} />
    </Suspense>
  );
}
```

```tsx
// _components/cases-table.tsx
"use client";
import { use } from "react";

export default function CasesTable({
  casesPromise,
}: {
  casesPromise: Promise<Case[]>;
}) {
  const cases = use(casesPromise); // suspends until resolved
  // …interactive UI
}
```

`CasesTableSkeleton` is not a guess: it is sized to the loaded table — same container, measured rows, shadcn
`<Skeleton>` on the scale — and the page must not jump when it is replaced (CLS ≤ 0.05). The procedure, with the
Playwright measurement and the render probe's `layout-shift`: `design-md-to-app/references/skeleton-audit.md`.

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

```ts
// lib/actions/cases.actions.ts
"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](#cache-components--use-cache-next-16-opt-in) 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.

```ts
// app/api/dashboard/stats/route.ts — only because the dashboard polls every 5s
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));
}
```

```tsx
// TanStack Query — recommended default
"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 /* … */;
}
```

```tsx
// SWR — acceptable for a single trivial polling widget, no other client query lib in the project
"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.

```ts
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = { cacheComponents: true };
export default nextConfig;
```

```ts
// lib/services/case.service.ts — cache an expensive read
export async function getCaseStats() {
  "use cache";                 // file/function/component-level directive
  cacheLife("hours");          // how long this stays fresh (built-in profile or custom)
  cacheTag("cases");           // tag so a mutation can invalidate it
  return db.select(/* expensive aggregate */);
}
```

- **`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:

```ts title="next.config.ts"
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 `await`s 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:

```ts
// next.config.ts
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:
1. **[Migrating to Cache Components](https://nextjs.org/docs/app/guides/migrating-to-cache-components)** — the canonical migration guide for an existing app.
2. **[Adopting Partial Prefetching](https://nextjs.org/docs/app/guides/adopting-partial-prefetching)** — the `<Link>` defaults and the migration off `unstable_eager`.
3. **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:

1. **Reading via Server Action in `useEffect`** → async Server Component.
2. **Filter / tab state in `useState`, refetched via action** → URL `searchParams` + Server Component re-render.
3. **Manual re-read after mutation** → `revalidatePath` / `revalidateTag` / `refresh` inside the action.
4. **`"use server"` file containing read-only `getX`** → move reads to `lib/services/`, called directly from Server Components.
5. **`await` in parent then pass to Client (blocks streaming)** → pass unawaited `Promise<T>`, consume with `use()` + `<Suspense>`.
6. **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.

```ts
// lib/services/case.service.ts
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

```json
{
  "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](https://github.com/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.

- Original: <https://github.com/lusentis/next-skills/tree/main/skills/nextjs-data-fetching>
- Next.js docs (Fetching data): <https://nextjs.org/docs/app/getting-started/fetching-data>
- Next.js docs (Mutating data): <https://nextjs.org/docs/app/getting-started/mutating-data>
- React `use()`: <https://react.dev/reference/react/use>
- `revalidatePath` / `revalidateTag`: <https://nextjs.org/docs/app/api-reference/functions/revalidatePath>
- **`references/nuqs.md`** — the doc-grounded how-to for rung 2 (URL state): `useQueryState`/`useQueryStates`, parsers, `shallow`/`limitUrlUpdates`/`history` options, and `createSearchParamsCache` for the server side. Read it before wiring URL state — don't improvise the API.

## 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.

Files in this skill

  • SKILL.md30.2 KB
  • references/anti-patterns.md10.6 KB
  • references/audit-recipe.md5.6 KB
  • references/contracts.md80.5 KB
  • references/nuqs.md12.7 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.