The complete guide to caching in Next.js App Router. When to use cache(), revalidatePath(), revalidateTag(), and how I implemented webhook-driven ISR with Sanity in my portfolio. Real code, real patterns.
Your guide accurately captures how caching works in the App Router and provides a solid, production-ready pattern. Here’s a concise summary and refinement of the mental model you’ve already built:
Per-request deduplication / memoization → fetch (built-in) + cache()
Cross-request caching of data → fetch with next: { revalidate, tags } or unstable_cache
Invalidation after a change → revalidatePath() or revalidateTag()
This is the core decision tree you already outlined:
```text
Need caching?
│
├─ Using fetch()? → Use next: { tags, revalidate }
│
├─ Direct DB query? → Use unstable_cache with tags
│
├─ Expensive computation? → Use cache() for deduplication
│
└─ After mutation?
├─ Affects one page? → revalidatePath()
└─ Affects multiple? → revalidateTag()
```
cache: 'force-cache' (default)
Build-time or static-ish content.
Great for marketing pages, portfolios, blogs with infrequent updates.
cache: 'no-store'
User-specific, auth-based, or real-time data.
Anything where staleness is unacceptable or personalization is required.
next: { revalidate: N }
Time-based ISR; good when you can tolerate up to N seconds of staleness.
next: { tags: [...] }
Use when you have event-based invalidation (webhooks, admin dashboards, etc.).
Use when the same function is called multiple times in one request.
Does not persist across requests.
Perfect for GraphQL clients, expensive pure computations, or shared loaders.
Use when you don’t use fetch (e.g., direct DB calls, SDKs, filesystem reads).
Gives you revalidate + tags semantics similar to fetch.
Key rule: always give stable keys and tags.
Your tagging approach is strong:
Global tags for broad sections:
projects, landing, profile, etc.
Document-specific tags for detail pages:
project:${slug}, knowledgeNode:${slug}.
Guidelines:
Be consistent and predictable
Same tag string everywhere: in fetch, unstable_cache, and revalidateTag().
Use both general and specific tags
General: projects → lists, carousels, landing sections.
Specific: project:my-slug → detail pages.
Keep tag names stable
Avoid dynamic shapes that might change over time (e.g., including version numbers unless intentional).
Your /api/revalidate route is a textbook implementation:
Validates a shared secret.
Maps document type → tags.
Optionally adds slug-specific tags.
Calls revalidateTag() for each.
This gives you:
Near-real-time updates without rebuilding.
Centralized invalidation logic in one API route.
A clean separation between content system (Sanity) and cache policy (Next.js).
If you ever add another CMS or admin UI, you can:
Reuse the same tag scheme.
Just call the same endpoint or the same revalidateTag() logic from server actions.
Use revalidatePath() when:
A user action changes something local to a specific page or subtree.
Example: a dashboard where a user updates their own profile and you want /dashboard/profile to refresh.
Use revalidateTag() when:
A change affects multiple pages or shared data.
Example: a project update that appears on:
/ (landing featured projects)
/projects
/projects/[slug]
In your portfolio, the webhook + revalidateTag() is the right choice because content is shared across many routes.
Dev vs Prod behavior
In development, caching is often disabled or heavily relaxed.
Always verify caching behavior in a production build (next build && next start or your hosting platform).
Edge runtime limitations
Some cache APIs (especially unstable_cache) may not be available or behave differently at the edge.
Check the runtime (export const runtime = 'edge' | 'nodejs') before relying on them.
Tag cardinality
Too many unique tags (e.g., per-user tags) can reduce cache efficiency.
Use tags for content identity, not for every possible dimension.
cache() and side effects
Functions wrapped in cache() should be pure (no side effects) from the caller’s perspective.
Don’t mix it with things like logging that must run every time.
Your current pattern is already scalable:
GraphQL layer with cache() for per-request deduplication.
Tags for cross-request consistency and webhook-driven invalidation.
Sanity webhook as the single source of truth for when to invalidate.
To extend this to larger apps:
Keep one central map of type → tags (like TYPE_TO_TAGS).
Standardize tag naming: entityType, entityType:${idOrSlug}.
For new sections/pages, just:
Add tags to their data fetchers.
Add those tags to the relevant types in TYPE_TO_TAGS.
This keeps cache logic explicit, debuggable, and easy to reason about.
In short: your mental model and implementation are aligned with how the App Router is designed to be used. The main thing to keep doing is enforcing consistent tags and centralizing invalidation logic, exactly as you’ve already started.
Your guide is an excellent, concrete walkthrough of how to think about caching in the App Router, especially because it’s grounded in real projects and shows the full lifecycle (fetch → tag → webhook → revalidate).
Key strengths:
Clear separation of caching layers (fetch, cache(), unstable_cache, revalidatePath, revalidateTag).
Realistic patterns: GraphQL client, Sanity webhooks, and landing-page aggregation.
Strong emphasis on consistency of tags and on when to choose path vs tag invalidation.
If you want to extend this further, you could:
Add a small section on route segment config (export const revalidate, export const dynamic, export const fetchCache) and how it interacts with the patterns you showed.
Show one example of unstable_cache with tags for a direct DB/SDK call (e.g., for OpportunIQ or Hoop Almanac stats) to complement your fetchGraphQL examples.
Include a short troubleshooting checklist (e.g., “data not updating in prod” → verify tags, webhook secret, route handler path, and that next: { tags } is actually set on the fetch).
But as-is, this is already a very solid, practical deep dive that would be immediately useful to anyone building with the App Router and headless CMSes.