Next.js Caching skill for Codex

Every cached value gets a lifetime, a tag and an owner. Codex first runs a bundled inventory script to see whether the app uses Cache Components or the older fetch Data Cache, then maps each data source to a lifetime and tags. It migrates revalidate and dynamic exports to use cache, streams request-bound parts behind Suspense, chooses updateTag, revalidateTag with a profile, or revalidatePath per mutation, and verifies with build output and cache headers.

  • Frontend
  • $nextjs-caching
  • 180 lines
  • 3 files
  • ~2,100 tokens
  • MIT
  • Updated
  • Editor's pick

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- nextjs-caching

Installs to ~/.agents/skills/nextjs-caching/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $nextjs-caching, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Designs and debugs Next.js App Router caching — the Cache Components model with "use cache", cacheLife and cacheTag, the older fetch Data Cache and route segment configs, on-demand invalidation with updateTag, revalidateTag and revalidatePath, and the client router cache. Use when a page shows stale data after a mutation, a route is unexpectedly dynamic or static, when enabling cacheComponents or migrating from unstable_cache and revalidate exports, or when choosing cache lifetimes and tags for Next.js 15 or 16.

Use it when

  • Data stays stale after a mutation or CMS publish
  • A route renders dynamically when it should be static, or the reverse
  • Turning on cacheComponents or moving off unstable_cache and segment configs
  • Choosing cache lifetimes and tag schemes for a Next.js app

Use something else when

  • The issue is CDN or browser Cache-Control headers rather than Next.js internals: use http-caching
  • You are writing the mutation itself and its validation: use nextjs-server-actions

How to check the result

The build route table shows the intended rendering mode per route, a production build reflects mutations after the chosen invalidation, and the inventory script exits 0 with --strict.

The mistake it prevents

Caching a function that returns per-user data without the user ID as an argument, which serves one user's data to everyone.

~/.agents/skills/nextjs-caching/SKILL.md

--- name: nextjs-caching description: Designs and debugs Next.js App Router caching — the Cache Components model with "use cache", cacheLife and cacheTag, the older fetch Data Cache and… license: MIT ---

Done well means: every cached thing has an owner, a lifetime and a tag that a mutation invalidates; uncached request data streams behind Suspense instead of making the whole route dynamic; and you can prove what is cached from the build output and headers, not from guesses.

Step 1: find out which model the project uses

Run the bundled inventory script from the skill folder (Node 20+, no dependencies):

node scripts/cache_inventory.mjs <project-root>           # report
node scripts/cache_inventory.mjs <project-root> --strict  # exit 1 if any warning

It reports whether cacheComponents is on, every segment config, "use cache" directive, cacheLife/cacheTag call, fetch cache option, unstable_cache, and invalidation call, and warns about deprecated or incompatible usage.

Inventory saysModelDefaults
cacheComponents: true (Next 16+)Cache ComponentsNothing is cached unless inside "use cache"; the static shell is prerendered and dynamic parts stream
Not enabled, Next 15Previous modelfetch not cached unless cache: "force-cache"; GET route handlers not cached; routes static unless they read request data
Not enabled, Next 14 or olderPrevious model, old defaultsfetch cached by default; upgrade before reasoning about anything else

Also read AGENTS.md and check the hosting target. Self-hosting on several instances needs a shared cache handler; default in-memory caches are per instance.

Enable in next.config.ts:

const nextConfig: NextConfig = {
  cacheComponents: true,
};

Recent 16.x releases also expect an explicit partialPrefetching setting next to it; follow the warning the dev server prints for the installed version.

Cache a data function, a component, or a whole page with the directive:

import { cacheLife, cacheTag } from "next/cache";

export async function getProduct(id: string) {
  "use cache";
  cacheLife("hours");
  cacheTag("products", `product:${id}`);
  return db.product.findUnique({ where: { id } });
}

Rules for "use cache" scopes:

  • Arguments and closed-over values become part of the cache key. They and the return value must be serializable.
  • Do not call cookies(), headers() or read searchParams inside the scope. Read them outside and pass the needed value in as an argument (it then becomes part of the key). For per-user data that must not be shared, use "use cache: private" or leave it uncached.
  • Default storage is in memory and per deployment. For a cache that survives instance restarts or is shared across instances, use "use cache: remote" with a configured cache handler.
  • Built-in cacheLife profiles: seconds, minutes, hours, days, weeks, max (and default). Define custom profiles under cacheLife in next.config.ts instead of scattering raw numbers.

Uncached data and request APIs must sit inside <Suspense> so the rest of the route can prerender. The dev overlay reports “blocking” insights naming the component; the fix is either cache it or wrap it:

export default function Page({ params }: PageProps<"/product/[id]">) {
  return (
    <>
      <ProductDetails params={params} /> {/* cached via getProduct */}
      <Suspense fallback={<StockSkeleton />}>
        <LiveStock params={params} /> {/* uncached, streams per request */}
      </Suspense>
    </>
  );
}

new Date(), Math.random() and crypto.randomUUID() during prerender are build errors. Move them into a Suspense-wrapped component that first awaits connection() from next/server, or into a client component.

Migrating segment configs

With cacheComponents on, dynamic, revalidate, fetchCache, dynamicParams and runtime exports error. Replace them:

OldCache Components equivalent
dynamic = "force-dynamic", revalidate = 0, noStore()delete; uncached is the default
dynamic = "force-static""use cache" + cacheLife("max") on the page or data
revalidate = 3600"use cache" + cacheLife("hours")
fetch(url, { next: { revalidate, tags } })wrap in a "use cache" function with cacheLife and cacheTag
unstable_cache(fn, keys, { tags, revalidate })keeps working; migrate later to a "use cache" function
dynamicParams = falsedelete; call notFound() for unknown params
generateStaticParams returning []return at least one real param
runtime = "edge" or "nodejs"delete; Cache Components runs on Node.js

For a large app, adopt incrementally: newer 16.x versions support export const instant = false on a segment to defer validation, plus a codemod that adds it everywhere; check the installed version’s migration guide before relying on it. Note that fetch’s old Data Cache and unstable_cache persisted across deployments, while default "use cache" storage does not; budget for a cold cache after each deploy.

Previous model (Next 15 without Cache Components)

LayerWhat it holdsControl
Request memoizationduplicate fetch/React.cache calls in one renderautomatic
Data Cachefetch responses and unstable_cache results across requestscache: "force-cache", next.revalidate, next.tags
Full Route Cacheprerendered HTML and RSC payload for static routesstatic unless request APIs or uncached fetch are used; revalidate export
Router Cache (client)RSC payloads of visited segments in the browserexperimental.staleTimes, router.refresh()

Invalidation

SituationCall
Server action; the user must see their own write immediatelyupdateTag("posts") (server actions only)
Content can be briefly stale (CMS publish, catalog import)revalidateTag("posts", "max")
Webhook or route handler that needs data gone nowrevalidateTag("posts", { expire: 0 })
A specific URL or layout regardless of tagsrevalidatePath("/blog/[slug]", "page")
Client view of the current route after a mutationhandled by the action response; router.refresh() from client code if needed
  • revalidateTag(tag) with one argument is deprecated in Next 16; always pass a profile.
  • Tag at two granularities: collection (posts) for lists and entity (post:123) for detail views. A mutation invalidates both.
  • Webhook endpoints that call revalidateTag must verify a signature or secret; an open revalidation endpoint lets anyone flush your cache.

Gotchas

  • A page that reads cookies() at the top level becomes fully dynamic (previous model) or blocks the shell (Cache Components). Push the read into the smallest component.
  • Caching a function that returns user-specific data with plain "use cache" shares it across users if the user ID is not an argument. Treat this as a data leak.
  • Tags are case-sensitive and limited to 256 characters; longer tags are silently never attached.
  • Stale-after-mutation bugs are usually a missing tag on the read side, not a missing call on the write side. Compare the tags in both places.
  • With Cache Components, routes stay mounted in <Activity> when navigating away, so useActionState results and form inputs persist on return; reset them explicitly.

Verification

  • next build route table: each route shows the intended mode (static, dynamic or partial prerender). Record it before and after.
  • Add logging: { fetches: { fullUrl: true } } to next.config in development to see fetch cache hits and misses.
  • On a production build (next build && next start), mutate data and confirm the next request shows it (updateTag) or shows it after one stale request (max).
  • Self-hosted ISR pages expose x-nextjs-cache (HIT, STALE, MISS) response headers; check them with curl -sI.
  • node scripts/cache_inventory.mjs . --strict exits 0.

Output

| Data / route | Cached? | Lifetime | Tags | Invalidated by |
|---|---|---|---|---|
| getProduct(id) | use cache | hours | products, product:{id} | updateProduct action (updateTag) |
| /product/[id] stock | no, streams | - | - | - |
| /blog list | use cache | max | posts | CMS webhook (revalidateTag 'max') |

Follow the table with any routes whose rendering mode changed and why.

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Next.js Caching"
  short_description: "Every cached value gets a lifetime, a tag and an owner"
  default_prompt: "$nextjs-caching Users don't see their new post until they refresh twice; fix our Next.js caching"
scripts/cache_inventory.mjs122 lines, 5.2 KB
#!/usr/bin/env node
// Inventory every caching decision in a Next.js App Router project.
// Usage: node scripts/cache_inventory.mjs <project-root> [--strict]
// Prints per-file findings and warnings. Exit 0 = ran fine, 1 = warnings with --strict,
// 2 = bad arguments or no app directory found. Standard library only (Node 20+).
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';

const args = process.argv.slice(2);
const strict = args.includes('--strict');
const root = args.find((a) => !a.startsWith('--'));
if (!root || !existsSync(root) || !statSync(root).isDirectory()) {
  console.error('usage: node cache_inventory.mjs <project-root> [--strict]');
  process.exit(2);
}

const appDirs = ['app', 'src/app'].map((d) => join(root, d)).filter((d) => existsSync(d));
if (!appDirs.length) {
  console.error(`no app/ or src/app/ directory under ${root}`);
  process.exit(2);
}

// next.config: is Cache Components on?
let cacheComponents = false;
for (const name of ['next.config.ts', 'next.config.mjs', 'next.config.js', 'next.config.cjs', 'next.config.mts']) {
  const p = join(root, name);
  if (existsSync(p)) {
    const src = readFileSync(p, 'utf8');
    if (/\bcacheComponents\s*:\s*true/.test(src)) cacheComponents = true;
    if (/\b(dynamicIO|useCache)\s*:\s*true/.test(src)) {
      console.log(`config: ${name} uses experimental.dynamicIO/useCache; Next 16 replaces them with cacheComponents`);
    }
  }
}

const SKIP = new Set(['node_modules', '.next', '.git', 'dist', 'out']);
const files = [];
const walk = (dir) => {
  for (const e of readdirSync(dir, { withFileTypes: true })) {
    if (SKIP.has(e.name)) continue;
    const p = join(dir, e.name);
    if (e.isDirectory()) walk(p);
    else if (/\.(tsx?|jsx?|mjs)$/.test(e.name)) files.push(p);
  }
};
for (const d of appDirs) walk(d);
// Data-access helpers often live outside app/.
for (const extra of ['lib', 'src/lib', 'src/server', 'server', 'src/data', 'data']) {
  const p = join(root, extra);
  if (existsSync(p) && statSync(p).isDirectory()) walk(p);
}

const checks = [
  ['segment:dynamic', /export\s+const\s+dynamic\s*=\s*['"]([\w-]+)['"]/g],
  ['segment:revalidate', /export\s+const\s+revalidate\s*=\s*([\w.]+)/g],
  ['segment:fetchCache', /export\s+const\s+fetchCache\s*=\s*['"]([\w-]+)['"]/g],
  ['segment:runtime', /export\s+const\s+runtime\s*=\s*['"]([\w-]+)['"]/g],
  ['segment:dynamicParams', /export\s+const\s+dynamicParams\s*=\s*(\w+)/g],
  ['directive', /['"](use cache(?::\s*\w+)?)['"]/g],
  ['cacheLife', /\bcacheLife\(\s*([^)]*)\)/g],
  ['cacheTag', /\bcacheTag\(\s*([^)]*)\)/g],
  ['fetch:cache', /cache\s*:\s*['"](force-cache|no-store)['"]/g],
  ['fetch:next', /next\s*:\s*\{([^}]*)\}/g],
  ['unstable_cache', /\bunstable_cache\(/g],
  ['noStore', /\b(unstable_noStore|noStore)\(\)/g],
  ['revalidateTag', /\brevalidateTag\(([^)]*)\)/g],
  ['revalidatePath', /\brevalidatePath\(([^)]*)\)/g],
  ['updateTag', /\bupdateTag\(([^)]*)\)/g],
  ['request-api', /\b(cookies|headers|draftMode|connection)\(\)/g],
];

const warnings = [];
let total = 0;
console.log(`Cache Components: ${cacheComponents ? 'ENABLED' : 'not enabled'}`);
console.log(`Scanned ${files.length} files\n`);

for (const f of files.sort()) {
  const src = readFileSync(f, 'utf8');
  const rel = relative(root, f);
  const found = [];
  for (const [label, re] of checks) {
    for (const m of src.matchAll(re)) {
      const line = src.slice(0, m.index).split('\n').length;
      found.push({ label, value: (m[1] ?? '').trim().replace(/\s+/g, ' ').slice(0, 60), line });
    }
  }
  if (!found.length) continue;
  total += found.length;
  console.log(rel);
  for (const x of found) console.log(`  L${x.line}  ${x.label}${x.value ? `  ${x.value}` : ''}`);

  const has = (l) => found.some((x) => x.label === l);
  for (const x of found) {
    if (x.label === 'revalidateTag' && !x.value.includes(',')) {
      warnings.push(`${rel}:${x.line} revalidateTag with one argument is deprecated; pass 'max' or use updateTag in a server action`);
    }
    if (cacheComponents && x.label.startsWith('segment:') && x.label !== 'segment:runtime') {
      warnings.push(`${rel}:${x.line} ${x.label.slice(8)} segment config is not compatible with cacheComponents`);
    }
    if (cacheComponents && x.label === 'segment:runtime') {
      warnings.push(`${rel}:${x.line} runtime export must be removed when cacheComponents is enabled`);
    }
  }
  if (has('directive') && has('request-api') && !found.some((x) => x.value.includes('private'))) {
    warnings.push(`${rel} has "use cache" and cookies()/headers() in one file; confirm the request API is read outside the cached scope`);
  }
  if (has('directive') && !cacheComponents) {
    warnings.push(`${rel} uses "use cache" but cacheComponents is not enabled in next.config`);
  }
  if (has('updateTag') && !/['"]use server['"]/.test(src)) {
    warnings.push(`${rel} calls updateTag outside a "use server" module; it only works in server actions`);
  }
  console.log('');
}

console.log(`${total} caching markers found.`);
if (warnings.length) {
  console.log(`\n${warnings.length} warning(s):`);
  for (const w of warnings) console.log(`  - ${w}`);
  process.exit(strict ? 1 : 0);
}
console.log('No warnings.');

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem