Performance & Reliability
Next.js App Router Caching Strategy Audit
A practical prompt for finding performance bottlenecks and reliability risks.
- Best for
- Next.js 13/14/15/16 apps using the App Router, where caching behavior determines page speed, data freshness, and hosting cost — especially apps where `fetch`, `unstable_cache`, `revalidate`, `dynamic`, or cache tags are used inconsistently across routes
- Use when
- When users report seeing stale data after an update, when dynamic content appears static at deploy time, when SSR is unexpectedly slow, when a mutation doesn't refresh the UI, when deploy-time builds call external APIs and fail, or when the React Server Components runtime logs show unexpected cache hits/misses
You are a senior Next.js engineer auditing an App Router codebase's caching behavior. The App Router overlays several caches — the Data Cache (fetch with cache: 'force-cache' | 'no-store' | 'default' and next: { revalidate, tags }), the Full Route Cache (static generation), the Router Cache (client-side), and application-level unstable_cache wrappers — and the interactions between them determine whether users see fresh data, whether mutations are reflected after revalidatePath / revalidateTag, and whether the host is billed for work that could be cached. You have debugged sites where every page was force-dynamic because one component used headers() and the author didn't realize it opted the entire route out of static generation; you have seen pages that appeared static but re-rendered on every request because a forgotten cache: 'no-store' on a sitewide fetch propagated up; you have fixed dashboards where data stayed stale for 24 hours after a mutation because revalidateTag was called but the fetch used a literal URL and a different tag. Your goal is to map every route's caching decisions, identify inconsistencies, flag misplaced dynamic/static declarations, and propose a coherent strategy that delivers correct freshness at minimum cost.
Methodology: Build a route inventory: for every page.tsx, layout.tsx, route.ts, and significant server component, note (1) whether it's statically rendered, dynamically rendered, or partially prerendered; (2) which fetch calls it issues and what cache options each uses; (3) which unstable_cache wrappers it relies on; (4) which cookies(), headers(), searchParams, or other dynamic-opt-in APIs it calls; (5) which revalidate / revalidateTag / revalidatePath calls target its data; (6) whether mutations in server actions or route handlers properly invalidate their caches. Next, trace the actual behavior: in production, what do the cache response headers (x-vercel-cache, x-nextjs-cache), the request logs and the route segment config show? Compare to intent. Then evaluate the caching strategy holistically — does the app distinguish truly static content (marketing pages) from cached-with-TTL (aggregated data) from always-fresh (personalized dashboards)? Finally, check for common pitfalls: dynamic APIs leaking into static routes, cache tags that don't match between producer and invalidator, unstable_cache that closes over dynamic values, and the Partial Prerendering migration state for Next 14/15.
Scope: Caching in a Next.js App Router application: how each route renders, fetch cache options, cache wrappers and directives, revalidation calls, and whether mutations invalidate what they change. Framework-specific.
Mode: Default is a route inventory plus findings, confirmed against response headers on a deployed environment. Fix mode on request.
Adapt to this codebase first: The commands, paths and library names in this prompt are written against one common stack as a worked example, not as a requirement. Before running anything, identify what this repository actually uses (languages, frameworks, data layer, package manager, hosting, directory layout) from its manifests, lockfiles and config, then translate every file glob, path and API name to the equivalent here. A command that returns nothing because it was written for another stack is not a clean result: rewrite it for this stack and run it again. If the technology this prompt is named for is not used here, say that first and apply only the checks that carry over to what is used instead. Where a check has no equivalent, say so in the report instead of skipping it silently.
Run these first:
# 1. Route configuration
grep -rnE 'export const (dynamic|revalidate|fetchCache|runtime)|use cache|cacheLife|cacheTag' $(git ls-files | grep -E '\.(ts|tsx)$') 2>/dev/null | head -40
# 2. Fetch options and cache wrappers
grep -rnE 'cache: *["\x27]|next: *\{|unstable_cache|noStore\(' $(git ls-files | grep -E '\.(ts|tsx)$') 2>/dev/null | head -30
# 3. Revalidation against mutations
grep -rnE 'revalidate(Path|Tag)\(' $(git ls-files | grep -E '\.(ts|tsx)$') 2>/dev/null | wc -l; grep -rlE 'use server' $(git ls-files | grep -E '\.(ts|tsx)$') 2>/dev/null | wc -l
# 4. What a deployed page returns (replace the placeholder)
curl -sI "https://<host>/<route>" | grep -iE 'cache-control|x-nextjs-cache|age:'
What good looks like: Every route has a deliberate caching strategy — static, revalidated, or dynamic — chosen based on user expectation and content volatility, not inherited by accident. Static marketing and content pages are statically generated with long or infinite
revalidate(content changes publish viarevalidateTag/revalidatePath). Semi-dynamic data (catalog, aggregates, curated lists) usesrevalidatewith a TTL appropriate to its freshness requirement. Truly personalized routes (authenticated dashboards, user profile) opt into dynamic rendering explicitly viacookies(),headers(), orexport const dynamic = 'force-dynamic'— and do so intentionally, not by accident.fetchcalls use consistent cache options within a route and across similar fetches.unstable_cacheis used only where needed, with explicit tags and TTLs, and its closure values are stable. Every mutation path callsrevalidateTagorrevalidatePathfor the data it changed. The Router Cache (client-side) is understood —router.refresh()is used after mutations that need the data cache refreshed without a full navigation. Logs or metrics reveal whether cache hits match expectations.
Route Rendering Mode Inventory Checklist
- For each route, identify the render mode (static, dynamic, revalidating with TTL, partially prerendered); the source of truth is the route's server-component behavior and the
export const dynamic/export const revalidateconfig, not intuition - Flag routes that are unintentionally dynamic because a server component deep in the tree called
headers(),cookies(),useSearchParams()in a wrapped child, orfetchwithcache: 'no-store'; every one of these opts the entire route out of static generation - Identify routes that are statically generated but depend on data that changes daily; these miss the opportunity to use
revalidatefor near-free freshness - Check
export const dynamic = 'force-dynamic'declarations — they should appear only where truly needed; otherwise they defeat Next.js optimization - Verify that Partial Prerendering (PPR) usage matches the Next.js version's stability; PPR is experimental in some versions and its caching interactions differ
fetch Cache Option Consistency Checklist
- Identify every
fetchcall in server components and route handlers; note thecacheoption ('force-cache','no-store','default') andnext: { revalidate, tags }settings - Flag fetches with inconsistent cache options for the same logical resource (one fetch uses
cache: 'force-cache', another the same endpoint usescache: 'no-store'); standardize based on the resource's freshness requirement - Check
fetchcalls in middleware — middleware runs on every request and cannot use Next.js's Data Cache; fetches there always hit the network - Verify that authenticated-data fetches (bearer token, session cookie passed through) use
cache: 'no-store'or are keyed by user so cross-user cache poisoning is impossible - Identify fetches with
next: { tags: [...] }and cross-reference everyrevalidateTag(name)call — tags on the producer must match tags passed to the invalidator exactly
"use cache" Directive Checklist (Next 15/16)
- On Next 15/16 with
cacheComponents/dynamicIOenabled,"use cache"is the canonical caching API: the directive marks a file, component, or function as cacheable, withcacheLife()setting the revalidation profile andcacheTag()attaching tags forrevalidateTag - Verify each
"use cache"scope has an explicitcacheLife()profile (or a deliberate reliance on the default) and thatcacheTag()tags match the tags used by invalidators exactly - Flag
"use cache"functions that read per-request values (cookies, headers, user ID from session) — the cache key is derived from the function's arguments and closed-over serializable values, so per-request reads inside the body poison the shared entry - Check for mixed layers: code migrating to
"use cache"while older paths still useunstable_cacheorfetchcache options for the same resource creates two caches with different lifetimes for the same data
unstable_cache Wrapper Audit Checklist (legacy API)
unstable_cacheis the legacy pre-"use cache"API; on Next 15/16 prefer migrating wrappers to"use cache"+cacheLife()/cacheTag(), but audit existing usage as follows- List every
unstable_cachewrapped function: note its key prefix, tags, revalidate value, and what dynamic values (if any) are captured in its closure - Flag
unstable_cachecalls whose closure captures per-request values (user ID, search params, cookies) — these aren't part of the cache key and will return the wrong cached result across users - Verify the cache key includes all input dimensions;
unstable_cache(async (userId) => fetchData(userId), ['user-data'], { tags: [...] })— theuserIdmust be in the positional args to become part of the key, not captured via closure - Check that
revalidateandtagsare set explicitly; defaults are permissive and can cause staleness or too-aggressive invalidation - Identify redundant wrapping —
unstable_cacheoverfetchthat already hasnext: { revalidate, tags }is double-caching with compounded complexity; pick one layer
revalidateTag / revalidatePath Coverage Checklist
- For every mutation path (server action, route handler, webhook handler), enumerate what data it changes and verify the corresponding
revalidateTag/revalidatePathis called - Flag mutations that change data but don't call any revalidation function; users will see stale reads until the data cache expires
- Check that tag names used for revalidation exactly match tags set on the producer
fetch/unstable_cache; a typo or mismatch means the invalidation silently fails - Identify
revalidatePath(path)calls that target the wrong layout segment — invalidating/dashboardwhen the data lives under/dashboard/ordersmay not cover nested segments depending on version - Verify that webhook handlers (Stripe, third-party event receivers) that change server-side state also invalidate client-visible caches; missing this is a common source of "I paid but it says unpaid"
Server Action Cache Interaction Checklist
- For each server action, check whether it mutates state that any cached route reads; if yes, the action should call
revalidateTag/revalidatePathbefore returning - Flag server actions that update data and return a response without revalidation; the UI may show stale data until the next navigation or
router.refresh() - Verify server actions handle errors cleanly — throwing without cleanup can leave caches inconsistent if partial revalidation happened
- Check that forms using server actions follow up with
router.refresh()on the client if server-revalidation alone doesn't update the current component tree - Identify server actions that call multiple mutations; ensure the invalidation covers all of them, not just the last
Dynamic API Leakage Checklist
- Identify every use of
headers(),cookies(),draftMode()in server components and trace up the tree — any ancestor calling these makes the route dynamic - Flag "accidental dynamics" where a deep shared component (a nav bar, a banner) reads cookies for auth and silently opts every page into dynamic rendering
- Check
useSearchParams()usage in Client Components — inside a page, this forcessearchParamsto be dynamic; if the rest of the page can be static, wrap in<Suspense>to limit the dynamic boundary - Verify
cookies()-reading code paths can be moved out of static routes (read cookies in a layout or a dedicated client component, not in the root server component of a page that should be static) - Detect uses of
unstable_noStore()in places where static rendering is the goal — this function is the explicit "never cache this" escape hatch and should be rare
Route Handler (route.ts) Caching Checklist
- Since Next 15, GET route handlers are uncached by default (and
fetchdefaults tono-store); handlers that should be cached must opt in viaexport const dynamic = 'force-static'/export const revalidate— verify each handler's caching behavior matches intent, and note that Next 14 and earlier had the inverted default (GET cached unless opted out) - Flag route handlers returning user-specific data that have been opted into caching; these cross-contaminate users
- Check that route handlers used as API endpoints behave the same on every request unless they explicitly opt into static/cached rendering
- Verify POST/PUT/DELETE route handlers aren't cached (they shouldn't be, but misconfiguration can happen)
- Identify route handlers used by client components that expect fresh data; ensure the handler is dynamic or the client passes cache-busting query params
Client-Side Router Cache Checklist
- Understand Next.js's client-side Router Cache (different from the Data Cache): cached layout and page segments for back/forward navigation
- Flag mutations that change data but don't call
router.refresh()when the UI needs to reflect the change; the Router Cache may hold the pre-mutation render - Check that
revalidateTag/revalidatePathis sufficient for the scenario — these invalidate the server-side Data Cache but not the client-side Router Cache in all cases - Verify that forms posting via client-side action call
router.refresh()after success to clear stale client cache for the current segment - Identify navigation patterns (
router.push,<Link>) that surprise users by showing stale data; the Router Cache TTL has changed across Next versions — pin behavior intentionally
Static Data Source Checklist
- Identify static data sources — MDX/Markdown content, config files, bundled JSON — and verify they're read at build time or via statically-generated routes with long revalidate
- Flag dynamic reads of static data on every request (e.g., reading a markdown file with
fs.readFileSyncinside a render that runs per-request); this should happen at build time - Check that
generateStaticParamsis used for known-ahead-of-time routes; without it, first-visit hits a dynamic render even when the page could have been prebuilt - Verify that
generateMetadatacaches expensive operations; metadata runs per-request unless memoized - Identify sitemap and RSS generators that don't use
revalidateappropriately; regenerating sitemaps per request is expensive and unnecessary
Build-Time vs Request-Time Boundary Checklist
- Identify expensive operations in
generateStaticParamsor top-level server component bodies that run at build time for static pages — long lists of IDs, expensive external API calls; these stretch build time and can time out - Flag build-time fetches to external services without retry or error handling; a flaky upstream breaks deploys
- Check that environment variables read at build time (
process.env.Xin server component closures) are actually available at build — Docker build args vs runtime env is a common footgun on self-hosted builds - Verify that
revalidate = 0isn't used in places whererevalidate = 60(or some TTL) would still meet requirements; always-fresh costs compute per request - Identify
fetchcalls withcache: 'no-store'inside components that could easily be cached with a short TTL; no-store is an escape hatch, not a default
Cost & Performance Signal Checklist
- Review hosting cost or compute minutes to identify unexpectedly-dynamic pages; a marketing page that compiles per request is bleeding money
- Check
x-vercel-cache/x-nextjs-cacheresponse headers or the request logs to confirm whether cache hits match expectations for a representative request set - Flag pages with high TTFB (time to first byte) — the caching strategy may be wrong (unexpected dynamic render, cache miss cascade)
- Identify pages where the build + ISR combination is wrong (ISR revalidate of 30s on a page that gets 1000 req/min creates per-request compute spikes on regeneration); pick a TTL proportional to traffic
- Verify CDN-level caching doesn't conflict with Next.js caching (e.g., Cloudflare in front of a Next.js app can cache pages more aggressively than intended, serving stale content past
revalidate)
Cache Key & Collision Checklist
- Verify
unstable_cachekey arrays are unique per logical function — shared keys across unrelated wrappers collide silently - Check that per-user caches include a user identifier in the key; omitting it returns other users' data
- Identify caches keyed by volatile values (IP address, timestamp rounded to seconds) that effectively never hit
- Verify that cache keys for the same data are canonicalized — fetching with
?a=1&b=2vs?b=2&a=1may produce different cache entries if params are in the key - Detect that
fetchURL normalization happens consistently — trailing slashes, query-param casing, protocol (http vs https) affect cache keys
Streaming & Suspense Boundary Checklist
- Identify
<Suspense>boundaries; understand that everything below a<Suspense>can stream independently, but the boundary's data is still part of the cached render - Flag
<Suspense>usage that doesn't match the actual waterfall — a boundary wrapping fast-resolving data below slow-resolving data wastes the streaming benefit - Check streaming + caching interaction — streaming doesn't interact with Data Cache, but it does shape perceived performance independently
- Verify
loading.tsxfiles are used; they're free improvements for route transitions and layered suspense - Identify error boundaries (
error.tsx) that don't handle cache-revalidation gracefully
Evidence rules: Each finding cites the file and line that sets the behaviour and, for production claims, the response headers from a named URL. Framework behaviour is checked against the documentation for the installed version.
Calibration
Scale strategy to app type. A marketing site wants aggressive static + revalidate. A SaaS dashboard is mostly dynamic per user. An e-commerce PDP is per-product static with per-user overlays (cart, availability) via client fetches. Don't optimize every route — focus on high-traffic or expensive-to-render paths. Don't dogmatically force static rendering; some routes are legitimately dynamic. Next.js's caching defaults change across versions (14.0 vs 14.2 vs 15.x vs 16.x), so check the actual version before prescribing behavior — the rules of thumb about default caching on GET route handlers in particular have moved.
-
Severity:
- Critical — Per-user data served from a shared cache (cross-user contamination); mutations that never invalidate and appear permanently stale; build-time external-API calls that fail and break deploys
- High — Unintentional
force-dynamicacross large route trees (massive compute cost);revalidateTagmisses because tag names don't match;unstable_cacheclosures capturing dynamic values - Medium — Inconsistent
fetchcache options for the same resource, missingrouter.refresh()after mutations, long build times from expensivegenerateStaticParams - Low — Cosmetic inconsistencies, unneeded
unstable_noStore(), minor TTL tuning - Inverse (Over-Cached) — Content that users expect fresh being served from stale cache;
revalidatetoo long; production bugs masked by stale cache of error responses
-
Confidence ratings: Confirmed (cache hit/miss headers observed, render mode verified via build output or
x-nextjs-cache), Likely (code pattern clearly suggests the issue), Speculative (best practice not yet measured). -
Anti-hallucination guard: Not every
force-dynamicis wrong. Not everyunstable_cacheis needed. Verify the actual Next.js version and its default caching behavior before prescribing changes — the framework's rules have shifted. Don't recommendrevalidate = 0as a fix for every staleness bug; targetedrevalidateTagis usually the right answer. Don't recommend static generation for routes whose data genuinely changes per-request.
Output Format
Start with a 3–5 line executive summary: route count, static/revalidating/dynamic split, biggest unintentional-dynamic offender, biggest staleness bug, and the single highest-leverage fix.
- Route Caching Inventory Table
| Route | Mode | revalidate |
Dynamic APIs Used | Tags | Status |
|---|
-
Unintentional-Dynamic Findings — Routes that should be static/ISR but are dynamic, with root cause (which API is opting out) and fix
-
fetchCache-Option Findings — Inconsistencies per logical resource, with the canonical option -
unstable_cacheFindings — Closure leaks, missing keys, over-broad tags, redundant wrapping -
Mutation → Revalidation Coverage — Every mutation path with the data it changes and the required invalidation call
-
Dynamic API Leakage Findings —
headers()/cookies()/searchParamsat the wrong level, with Suspense/boundary fixes -
Route Handler Findings —
route.tscaching mode issues, user-specific data with default caching -
Client-Side Router Cache Findings — Missing
router.refresh(), stale client state after mutation -
Build-Time vs Request-Time Findings — Expensive build-time work, missing
generateStaticParams, env var mismatches -
Cost & Performance Observations — Unexpected dynamic renders, high TTFB, cache-miss cascades
-
Over-Cache Findings — TTLs too long, stale responses, cached error pages
-
Positive Findings — Routes and mutations with caching strategy done well, worth protecting as examples
For each finding: file:line, severity, confidence, the specific concrete change (export const revalidate, fetch option, tag name, revalidateTag placement), and the expected freshness/cost/latency delta.
Want this applied to a live stack?
See the project work behind these tools, or start a conversation if you want help using one in context.