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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- nextjs-caching Installs to ~/.agents/skills/nextjs-caching/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install nextjs-caching The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project nextjs-caching Installs to .agents/skills/nextjs-caching/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/nextjs-caching.zipunzip -o nextjs-caching.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) nextjs-caching PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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.
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 says | Model | Defaults |
|---|---|---|
cacheComponents: true (Next 16+) | Cache Components | Nothing is cached unless inside "use cache"; the static shell is prerendered and dynamic parts stream |
| Not enabled, Next 15 | Previous model | fetch not cached unless cache: "force-cache"; GET route handlers not cached; routes static unless they read request data |
| Not enabled, Next 14 or older | Previous model, old defaults | fetch 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.
Cache Components model (recommended for new work on Next 16)
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 readsearchParamsinside 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
cacheLifeprofiles:seconds,minutes,hours,days,weeks,max(anddefault). Define custom profiles undercacheLifeinnext.config.tsinstead 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:
| Old | Cache 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 = false | delete; 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)
| Layer | What it holds | Control |
|---|---|---|
| Request memoization | duplicate fetch/React.cache calls in one render | automatic |
| Data Cache | fetch responses and unstable_cache results across requests | cache: "force-cache", next.revalidate, next.tags |
| Full Route Cache | prerendered HTML and RSC payload for static routes | static unless request APIs or uncached fetch are used; revalidate export |
| Router Cache (client) | RSC payloads of visited segments in the browser | experimental.staleTimes, router.refresh() |
Invalidation
| Situation | Call |
|---|---|
| Server action; the user must see their own write immediately | updateTag("posts") (server actions only) |
| Content can be briefly stale (CMS publish, catalog import) | revalidateTag("posts", "max") |
| Webhook or route handler that needs data gone now | revalidateTag("posts", { expire: 0 }) |
| A specific URL or layout regardless of tags | revalidatePath("/blog/[slug]", "page") |
| Client view of the current route after a mutation | handled 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
revalidateTagmust 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, souseActionStateresults and form inputs persist on return; reset them explicitly.
Verification
-
next buildroute table: each route shows the intended mode (static, dynamic or partial prerender). Record it before and after. - Add
logging: { fetches: { fullUrl: true } }tonext.configin 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 withcurl -sI. -
node scripts/cache_inventory.mjs . --strictexits 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.yaml
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.mjs
#!/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.