7 min read
Data Fetching & Caching
Fetch caching is on by default and often wrong for the data — deliberate revalidation windows, tag-based invalidation, and segment config that can silently override both.
Principles
`fetch` is cached by default in Server Components
The App Router extends `fetch` with an implicit cache: identical requests during a render, and across requests until revalidated, are deduplicated for free. Not knowing this is why teams reach for a data-fetching library to solve a problem the framework already solved.
Revalidation is a decision, not a default
Choose `force-cache` (the default), `no-store`, or a `next: { revalidate: N }` window deliberately per request. Leaving genuinely dynamic data at the default serves stale content to everyone who loads the page.
Tag-based revalidation invalidates by meaning, not by guesswork
`next: { tags: [...] }` plus `revalidateTag()` lets a mutation invalidate exactly the data it affected, instead of a time-based window that's either too short to help or too long to be correct.
Route segment config sets the ceiling for the whole segment
`export const dynamic`, `revalidate`, and `fetchCache` at the file level override every fetch inside it. A stray `dynamic = "force-dynamic"` left over from debugging silently defeats every carefully-tuned fetch below it.
Co-locate the fetch with the component that needs the data
Passing fetched data down through props from a distant ancestor recreates the prop drilling that Server Components exist to avoid. Fetch where you render — request deduplication makes this free even when several components fetch the same thing.
Patterns
What goes wrong, what to do instead, and why the difference matters.
Deliberate cache lifetime vs accidental staleness
Avoid
// No cache option specified — defaults to force-cache. Fine for a
// blog post, wrong for a price that changes every few minutes.
async function getPrice(sku: string) {
const res = await fetch(`https://api.example.com/price/${sku}`);
return res.json();
}The default cache setting is silently wrong for data that changes faster than the page is rebuilt.
Prefer
async function getPrice(sku: string) {
const res = await fetch(`https://api.example.com/price/${sku}`, {
next: { revalidate: 60 }, // stale after 60s, refetched in the background
});
return res.json();
}The lifetime matches how often the data actually changes, stated explicitly instead of inherited by accident.
Tag-based invalidation instead of time-based guessing
Avoid
// Revalidate every 5 minutes and hope that's often enough after an edit.
async function getPost(slug: string) {
const res = await fetch(`/api/posts/${slug}`, { next: { revalidate: 300 } });
return res.json();
}Readers see stale content for up to 5 minutes after every edit, or the interval gets tightened until the cache stops helping.
Prefer
async function getPost(slug: string) {
const res = await fetch(`/api/posts/${slug}`, {
next: { tags: [`post-${slug}`] },
});
return res.json();
}
// In the mutation that edits the post:
("use server");
await updatePost(slug, data);
revalidateTag(`post-${slug}`);The edit that changes the data is what invalidates the cache — freshness is exact, not probabilistic.
Segment config silently overriding fetch-level intent
Avoid
// app/products/[id]/page.tsx
export const dynamic = "force-dynamic"; // added for one debugging
// session, never removed
async function getProduct(id: string) {
// This fetch's own tuned revalidate window is now irrelevant —
// force-dynamic re-runs everything on every request regardless.
return fetch(`/api/products/${id}`, { next: { revalidate: 3600 } });
}A segment-level flag silently overrides every fetch beneath it — the per-fetch tuning stops mattering and nobody notices.
Prefer
// No segment-level override. Each fetch's own cache setting governs it.
async function getProduct(id: string) {
return fetch(`/api/products/${id}`, { next: { revalidate: 3600 } });
}Segment config is left alone unless the whole route genuinely needs to opt out of static rendering.
Review checklist
What I look for when reviewing a pull request that touches this area.
- Every fetch states its cache intent explicitly (`force-cache` default, `no-store`, or a `revalidate` window) rather than inheriting the default by accident.
- Data that changes on a specific user action uses tag-based revalidation, not a shortened time-based window.
- Route segment config (`dynamic`, `revalidate`, `fetchCache`) is set deliberately, with nothing left over from a debugging session.
- Fetches are co-located with the component that renders their data, not threaded down through props.
- Independent fetches within a segment run in parallel (`Promise.all`), not sequentially.
- `revalidateTag`/`revalidatePath` is called from the exact mutation that changes the underlying data.
Take the skill
Everything above, generated into a skill file your agent can load. Copy it, or download it and commit it to your repo.
.claude/skills/nextjs-data-fetching/SKILL.mdDrop into .claude/skills/ or .cursor/skills/ — the frontmatter drives when the skill loads.
---
name: nextjs-data-fetching
description: >-
Next.js App Router data fetching and caching: fetch cache lifetimes,
tag-based revalidation, route segment config, and co-locating fetches with
the components that use them. Use when adding or reviewing data fetching.
argument-hint: "[route or fetch]"
license: MIT
metadata:
author: John Felix Lim
version: "1.0.0"
---
# Data Fetching & Caching
Apply when adding a fetch or reviewing staleness or performance. Start by asking how often this specific data actually changes, then pick force-cache, a revalidate window, or no-store to match — never leave it as an unexamined default.
## Rules
1. **`fetch` is cached by default in Server Components** — The App Router extends `fetch` with an implicit cache: identical requests during a render, and across requests until revalidated, are deduplicated for free. Not knowing this is why teams reach for a data-fetching library to solve a problem the framework already solved.
2. **Revalidation is a decision, not a default** — Choose `force-cache` (the default), `no-store`, or a `next: { revalidate: N }` window deliberately per request. Leaving genuinely dynamic data at the default serves stale content to everyone who loads the page.
3. **Tag-based revalidation invalidates by meaning, not by guesswork** — `next: { tags: [...] }` plus `revalidateTag()` lets a mutation invalidate exactly the data it affected, instead of a time-based window that's either too short to help or too long to be correct.
4. **Route segment config sets the ceiling for the whole segment** — `export const dynamic`, `revalidate`, and `fetchCache` at the file level override every fetch inside it. A stray `dynamic = "force-dynamic"` left over from debugging silently defeats every carefully-tuned fetch below it.
5. **Co-locate the fetch with the component that needs the data** — Passing fetched data down through props from a distant ancestor recreates the prop drilling that Server Components exist to avoid. Fetch where you render — request deduplication makes this free even when several components fetch the same thing.
## Patterns
### Deliberate cache lifetime vs accidental staleness
**Avoid** — The default cache setting is silently wrong for data that changes faster than the page is rebuilt.
```tsx
// No cache option specified — defaults to force-cache. Fine for a
// blog post, wrong for a price that changes every few minutes.
async function getPrice(sku: string) {
const res = await fetch(`https://api.example.com/price/${sku}`);
return res.json();
}
```
**Prefer** — The lifetime matches how often the data actually changes, stated explicitly instead of inherited by accident.
```tsx
async function getPrice(sku: string) {
const res = await fetch(`https://api.example.com/price/${sku}`, {
next: { revalidate: 60 }, // stale after 60s, refetched in the background
});
return res.json();
}
```
### Tag-based invalidation instead of time-based guessing
**Avoid** — Readers see stale content for up to 5 minutes after every edit, or the interval gets tightened until the cache stops helping.
```tsx
// Revalidate every 5 minutes and hope that's often enough after an edit.
async function getPost(slug: string) {
const res = await fetch(`/api/posts/${slug}`, { next: { revalidate: 300 } });
return res.json();
}
```
**Prefer** — The edit that changes the data is what invalidates the cache — freshness is exact, not probabilistic.
```tsx
async function getPost(slug: string) {
const res = await fetch(`/api/posts/${slug}`, {
next: { tags: [`post-${slug}`] },
});
return res.json();
}
// In the mutation that edits the post:
("use server");
await updatePost(slug, data);
revalidateTag(`post-${slug}`);
```
### Segment config silently overriding fetch-level intent
**Avoid** — A segment-level flag silently overrides every fetch beneath it — the per-fetch tuning stops mattering and nobody notices.
```tsx
// app/products/[id]/page.tsx
export const dynamic = "force-dynamic"; // added for one debugging
// session, never removed
async function getProduct(id: string) {
// This fetch's own tuned revalidate window is now irrelevant —
// force-dynamic re-runs everything on every request regardless.
return fetch(`/api/products/${id}`, { next: { revalidate: 3600 } });
}
```
**Prefer** — Segment config is left alone unless the whole route genuinely needs to opt out of static rendering.
```tsx
// No segment-level override. Each fetch's own cache setting governs it.
async function getProduct(id: string) {
return fetch(`/api/products/${id}`, { next: { revalidate: 3600 } });
}
```
## Review checklist
- [ ] Every fetch states its cache intent explicitly (`force-cache` default, `no-store`, or a `revalidate` window) rather than inheriting the default by accident.
- [ ] Data that changes on a specific user action uses tag-based revalidation, not a shortened time-based window.
- [ ] Route segment config (`dynamic`, `revalidate`, `fetchCache`) is set deliberately, with nothing left over from a debugging session.
- [ ] Fetches are co-located with the component that renders their data, not threaded down through props.
- [ ] Independent fetches within a segment run in parallel (`Promise.all`), not sequentially.
- [ ] `revalidateTag`/`revalidatePath` is called from the exact mutation that changes the underlying data.
---
From the John Felix Lim Frontend Engineering Playbook — https://github.com/JohnFelixLim