{
  "markdown": "# @leejpsd/nextjs-cache-handler\n\n[![npm](https://img.shields.io/npm/v/@leejpsd/nextjs-cache-handler.svg)](https://www.npmjs.com/package/@leejpsd/nextjs-cache-handler)\n[![license](https://img.shields.io/npm/l/@leejpsd/nextjs-cache-handler.svg)](./LICENSE)\n\n> **`v1.0.0`** — install with\n> `npm install @leejpsd/nextjs-cache-handler`, or wire everything with\n> `npx nextjs-cache-handler init --yes`. Production-validated against\n> AWS ECS Fargate with multi-instance Redis (24h live-traffic soak and a\n> live Redis-reboot drill with zero 5xx — see\n> [`docs/staging-verification-2026-08-01.md`](./docs/staging-verification-2026-08-01.md)),\n> and exercised end-to-end against **Next.js 16.3** (both cache interfaces).\n>\n> v0.4 adds: **build-output cache seeding** (first request after deploy is a\n> HIT), **pub/sub tag propagation** (~3ms cross-instance), **Redis Cluster**\n> e2e in CI, a **CLI** (`init` / `doctor` / `seed`), and agent-native assets\n> (skill, rules, [MCP server](https://www.npmjs.com/package/@leejpsd/nextjs-cache-handler-mcp)).\n> 1.0 freezes the public API under semver — the eight entry points, the\n> `CacheHandlerOptions` surface, the Redis key shapes and tag-marker format,\n> and the metric event names. Upgrading from 0.4.x needs no changes.\n\n## 🤖 For AI agents\n\nWorking with Claude Code / Codex / Cursor? Give your agent this URL and it\nwill install and wire everything (version detection, wrapper files,\nnext.config patch, verification):\n\n```\nhttps://raw.githubusercontent.com/leejpsd/nextjs-cache-handler/main/setup-instructions/setup.md\n```\n\nAn agent skill with decision tables, invalidation semantics, and a\ntroubleshooting playbook ships in the package (`AGENTS.md`,\n`skills/nextjs-redis-cache/SKILL.md`) and via\n`npx skills add leejpsd/nextjs-cache-handler`.\n\nFor cache operations from your agent (health, tag state, safe invalidation),\nthe companion MCP server is on the official registry as\n`io.github.leejpsd/nextjs-cache-handler-mcp` — or one command:\n`npx nextjs-cache-handler init --yes` wires handlers, rules, and `.mcp.json` together.\n\n---\n\nThe Redis cache handler for **Next.js 15/16** that ships **both** `cacheHandler`\n(ISR / Pages Router) **and** `cacheHandlers` (`'use cache'` directive,\n`cacheComponents: true`) — the area where\n[`@fortedigital/nextjs-cache-handler`](https://github.com/fortedigital/nextjs-cache-handler)\ncurrently lists \"Help needed\".\n\n```ts\n// next.config.ts\nconst nextConfig = {\n  cacheComponents: true,\n  cacheHandler: require.resolve(\"./cache-incremental.cjs\"),\n  cacheHandlers: { default: require.resolve(\"./cache-components.cjs\") },\n};\n```\n\n```js\n// cache-components.cjs\nconst { createCacheComponentsHandler } = require(\"@leejpsd/nextjs-cache-handler/cache-components\");\nmodule.exports = createCacheComponentsHandler({\n  client: { type: \"redis\", url: process.env.REDIS_URL },\n  buildNamespace: process.env.DEPLOYMENT_VERSION, // auto-isolates deploys\n});\n```\n\nThat's it. `'use cache'`, `revalidateTag`, `updateTag`, `cacheLife` all work.\n\n---\n\n## Why this exists\n\nNext.js 16 split caching into two handler interfaces:\n\n| Option | Used by | Methods |\n|---|---|---|\n| `cacheHandler` (singular) | Pages Router ISR, on-demand revalidation | `get`, `set`, `revalidateTag`, `resetRequestCache` |\n| `cacheHandlers` (plural) | `'use cache'` directive, `cacheComponents: true` | `get`, `set`, `refreshTags`, `getExpiration`, `updateTags` |\n\nAs of 2026-05, the leading OSS Redis handler `@fortedigital/nextjs-cache-handler@3.2.0`\ndeclares `peerDependencies.next: \">=16.1.5\"` but the README marks the new\nplural interface as ❌ **\"Not yet supported - Help needed\"**:\n\n> 📅 **Compatibility matrix re-verified 2026-07-31** (from each project's\n> published README/registry metadata). The OSS Next.js cache\n> handler ecosystem moves quickly — please verify\n> [`@fortedigital`](https://github.com/fortedigital/nextjs-cache-handler#compatibility)\n> and\n> [`nextjs-turbo-redis-cache`](https://github.com/trieb-work/nextjs-turbo-redis-cache#features)\n> directly before relying on this comparison.\n\n| Feature | this (0.4.x) | @fortedigital 3.2.1 | nextjs-turbo-redis-cache 1.15 |\n|---|---|---|---|\n| `cacheHandlers` config (plural) | ✅ | ❌ Help needed | ✅ since 1.11 |\n| `'use cache'` directive | ✅ | ❌ Help needed | ✅ since 1.11 |\n| `'use cache: remote'` | ✅ default handler (dedicated multi-tier: roadmap) | ❌ Help needed | partial |\n| `'use cache: private'` | n/a (uncustomizable) | n/a | n/a |\n| `cacheComponents: true` | ✅ | ❌ Help needed | ✅ |\n| Build-phase skip (`PHASE_PRODUCTION_BUILD`) | ✅ | ✅ (singular only) | ✅ |\n| Auto deploy isolation | ✅ `BUILD_NAMESPACE` env-resolved | manual | ✅ `BUILD_ID` since 1.13 |\n| Lua-atomic SET+tag | ✅ Lua scripts | partial (MULTI) | partial |\n| AbortSignal timeout | ✅ per-op | ✅ Proxy-wrapped | ❌ |\n| Redis Cluster | ✅ (cluster adapter, see Production checklist) | ✅ | ✅ |\n| ioredis support | ✅ | ✅ | ✅ |\n| In-memory fallback (TTL-aware) | ✅ | partial | ✅ L1 + Redis L2 |\n| Next 15 support (ISR handler) | ✅ `>=15.0.0` | ✅ (legacy 2.x line) | ✅ `>=15.0.3` |\n| Request-scoped GET dedup | ✅ | ❌ | ✅ |\n| Built-in value compression | ✅ gzip/brotli option | example only | example only |\n| Redis Sentinel | ✅ (local failover drill) | ❌ | ❌ |\n| OpenTelemetry | ✅ built-in `/otel` emitter + `onMetric` hook | ❌ | ❌ |\n| Reconnect strategy | ✅ exponential backoff | client-level | error-threshold restart |\n| Live-traffic dogfood (24h+) | ✅ AWS ECS Fargate | not published | not published |\n\nPR [#207](https://github.com/fortedigital/nextjs-cache-handler/pull/207) on\n`@fortedigital` (their `cacheHandlers` attempt) was held up in review over\n`PHASE_PRODUCTION_BUILD` handling — which this package has from the start.\n\n---\n\n## Quick start\n\n### Install\n\n```bash\nnpm install @leejpsd/nextjs-cache-handler redis\n# or\nnpm install @leejpsd/nextjs-cache-handler ioredis\n```\n\n`redis` and `ioredis` are optional peer dependencies — install whichever\nclient you use. Both can be present.\n\n### Wire up\n\nTwo CommonJS wrapper files in your project root (Next.js's\n`require.resolve` pattern doesn't accept ESM directly):\n\n```js\n// cache-components.cjs\nconst { createCacheComponentsHandler } = require(\"@leejpsd/nextjs-cache-handler/cache-components\");\nmodule.exports = createCacheComponentsHandler({\n  client: { type: \"redis\", url: process.env.REDIS_URL },\n  buildNamespace: process.env.DEPLOYMENT_VERSION,\n  abortTimeoutMs: 1500,\n});\n```\n\n```js\n// cache-incremental.cjs\nconst { createIncrementalCacheHandler } = require(\"@leejpsd/nextjs-cache-handler/incremental\");\nmodule.exports = createIncrementalCacheHandler({\n  client: { type: \"redis\", url: process.env.REDIS_URL },\n  buildNamespace: process.env.DEPLOYMENT_VERSION,\n  abortTimeoutMs: 1500,\n});\n```\n\n```ts\n// next.config.ts\nimport path from \"path\";\nimport type { NextConfig } from \"next\";\n\nconst enabled = !!process.env.REDIS_URL && process.env.DISABLE_REDIS_CACHE_HANDLER !== \"true\";\n\nconst nextConfig: NextConfig = {\n  output: \"standalone\",\n  outputFileTracingRoot: path.join(__dirname),\n  cacheComponents: true,\n  deploymentId: process.env.DEPLOYMENT_VERSION,\n  generateBuildId: async () => process.env.DEPLOYMENT_VERSION ?? \"dev-build\",\n  cacheMaxMemorySize: 0, // delegate everything to the Redis handler\n  cacheHandler: enabled ? require.resolve(\"./cache-incremental.cjs\") : undefined,\n  cacheHandlers: enabled ? { default: require.resolve(\"./cache-components.cjs\") } : {},\n};\nexport default nextConfig;\n```\n\n### Use in your code\n\n```tsx\n// app/blog/page.tsx\nimport { cacheLife, cacheTag, revalidateTag } from \"next/cache\";\n\nasync function getPosts() {\n  \"use cache\";\n  cacheLife(\"hours\");\n  cacheTag(\"posts\");\n  return await db.post.findMany();\n}\n\n// Server Action — invalidate\nasync function publishPost(formData: FormData) {\n  \"use server\";\n  await db.post.create({ data: Object.fromEntries(formData) });\n  revalidateTag(\"posts\", \"max\");\n}\n\nexport default async function Page() {\n  const posts = await getPosts();\n  return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;\n}\n```\n\n---\n\n## Configuration reference\n\n```ts\ninterface CacheHandlerOptions {\n  client: RedisClientFactory | RedisClientConfig;\n  keyPrefix?: string;             // default: \"next-cache:\" / \"next-incremental:\"\n  buildNamespace?: string | (() => string);  // default: env DEPLOYMENT_VERSION || GIT_HASH || \"unversioned\"\n  abortTimeoutMs?: number;        // default: 1500\n  fallback?: \"auto\" | \"always\" | \"never\";    // default: \"auto\"\n  staleWhileRevalidate?: boolean; // default: true (cache-components only)\n  singleFlight?: boolean;         // default: false — see \"Single-flight refresh lock\" below\n  singleFlightLockTtlSec?: number; // default: 10\n  isBuildPhase?: () => boolean;   // override PHASE_PRODUCTION_BUILD detection\n  hashTag?: boolean;              // default: false (set true on Redis Cluster)\n  memoryMaxEntries?: number;      // default: 1000 — LRU cap for the in-memory fallback\n  compression?: \"gzip\" | \"brotli\"; // default: off — transparent value compression (node:zlib)\n  onMetric?: (event: MetricEvent) => void;\n  logger?: Logger;\n}\n\ntype RedisClientConfig =\n  | { type: \"redis\";    url: string; password?: string; tls?: boolean; connectTimeout?: number }\n  | { type: \"ioredis\";  url: string; password?: string; tls?: boolean; connectTimeout?: number }\n  | { type: \"cluster\";  nodes: { host: string; port: number }[]; password?: string; tls?: boolean }\n  | { type: \"sentinel\"; sentinels: { host: string; port: number }[]; name: string;\n      password?: string; sentinelPassword?: string; tls?: boolean; connectTimeout?: number };\n```\n\nFull reference: [`docs/api.md`](./docs/api.md).\n\n---\n\n## Production checklist\n\n- [ ] **`DEPLOYMENT_VERSION` env injected at runtime** — every entry key is\n      prefixed with this so old prerender HTML can't bleed across deploys.\n      For Docker, set `ENV DEPLOYMENT_VERSION=...` in your **runner** stage,\n      not just the builder. (See [`docs/build-phase.md`](./docs/build-phase.md).)\n- [ ] **`cacheMaxMemorySize: 0`** — turn off Next's local LRU so multi-instance\n      reads always hit Redis (or the explicit memory fallback).\n- [ ] **`outputFileTracingRoot` pinned** — required for `output: \"standalone\"`\n      to avoid static-chunk-404 issues during a deploy.\n- [ ] **`abortTimeoutMs: 1500`** (default) — protects against a stuck Redis\n      connection from hanging the request thread.\n- [ ] **Redis Cluster: set `hashTag: true`** — multi-key Lua scripts\n      (`set-with-tags.lua`, `revalidate-hard.lua`) require all KEYS to land\n      on the same hash slot. Without `hashTag`, cluster deployments will hit\n      `CROSSSLOT Keys in request don't hash to the same slot`. The flag wraps\n      the namespace in `{}` so every key for a given deploy hashes together.\n      Cluster support is validated by a dedicated e2e suite against a real\n      3-master cluster (`npm run test:cluster`, also in CI) — covering the\n      multi-key Lua scripts, per-master SCAN propagation, and both handlers.\n      Not yet load-tested at production scale.\n- [ ] **Redis `maxmemory-policy: allkeys-lru` or `noeviction`** — if you need\n      bounded memory, choose `allkeys-lru`. Otherwise `noeviction` keeps\n      tag indices intact.\n- [ ] **TLS** — use `rediss://` URLs (e.g. ElastiCache in-transit\n      encryption). The library auto-detects from the URL scheme.\n- [ ] **Health check** — call your own `/api/health` that pings Redis\n      (separate from the handler) so a Redis outage surfaces in your\n      monitoring without inducing 5xx in user requests.\n\n### Compatibility with Redis-protocol services\n\n| Service | How to use | Tested? |\n|---|---|---|\n| **Self-hosted Redis 7+** | `{ type: \"redis\", url }` or `{ type: \"ioredis\", url }` | ✅ AWS ElastiCache 24h soak |\n| **Redis Cluster** | `{ type: \"cluster\", nodes }` + `hashTag: true` | ✅ e2e-tested against a real 3-master cluster (CI); not yet load-tested at scale |\n| **Upstash Redis** | `{ type: \"redis\", url: \"rediss://...\" }` (TLS auto-detected) | not yet validated, expected to work via the standard Redis protocol |\n| **AWS ElastiCache (replication group)** | `{ type: \"redis\", url: \"rediss://...\" }` | ✅ reference deployment (re-verified 2026-08-01, Seoul) |\n| **Redis Sentinel** | `{ type: \"sentinel\", sentinels, name }` | ✅ local master/replica failover drill |\n| **Vercel KV** | not yet supported — dedicated adapter on the roadmap | — |\n| **DragonflyDB / KeyDB** | Redis-protocol compatible — `{ type: \"redis\", url }` should work | not validated |\n\n---\n\n## Single-flight refresh lock (v0.2)\n\nThe `cacheHandlers` (plural) interface returns stale entries inside the\nSWR window so users get an instant response while the background\nrefresh completes. With many instances, the moment an entry crosses the\n`revalidate` boundary, every instance independently triggers its own\nrefresh — N parallel re-renders for the same key, each hitting your\norigin once.\n\n`singleFlight: true` adds an opt-in Redis lock (`refresh-tag-lock.lua`,\ndefault TTL 10s) at the SWR boundary. The first instance to acquire it\nbecomes the **leader** and runs the refresh; the rest become\n**followers**, keep serving the same stale entry, and wait for the\nleader's write to land. The lock is observability-only at the handler\nlayer — Next.js still drives the actual refresh; we just suppress the\nstampede.\n\n```js\ncreateCacheComponentsHandler({\n  client: { type: \"redis\", url: process.env.REDIS_URL },\n  singleFlight: true,         // default false\n  singleFlightLockTtlSec: 10, // default 10\n});\n```\n\nTwo new `MetricEvent` types appear on `onMetric`:\n\n| event type | meaning |\n|---|---|\n| `cache.stale.refresh.leader` | this instance just acquired the lock and is the designated refresher |\n| `cache.stale.refresh.follower` | another instance holds the lock; we serve stale and skip the refresh |\n\nIf lock acquisition fails (Redis hiccup, TTL race), the handler\ndefaults to the follower path — the stale entry is always served, never\ndropped. This is intentional: the lock is an optimization, not a\ncorrectness-critical primitive.\n\nWhen **not** to enable single-flight: small fleets (1–2 instances) where\nNext's per-process serialization already covers the stampede risk.\nAdding a Redis round-trip per stale read isn't free.\n\nSee [`docs/architecture.md`](./docs/architecture.md#single-flight-refresh-lock-v02-)\nfor the full state machine and a reference to the Lua script body.\n\n---\n\n## OpenTelemetry instrumentation (v0.2)\n\nThe handler doesn't bundle `@opentelemetry/api` (zero runtime\ndependencies stays a goal). Instead, the `onMetric(event)` hook gives\nstrictly-typed events you can pipe into whatever observability stack\nyou already run.\n\n[`examples/opentelemetry/`](./examples/opentelemetry/) is a copy-paste\nreference wrapper that:\n\n- exposes a `nextjs_cache.events_total` counter dimensioned on\n  `type` / `freshness` / `backend` / `reason` / `op`\n- exposes a `nextjs_cache.op_latency_ms` histogram for events that\n  carry an `ms` field\n- keeps cardinality bounded — cache keys and tag names are never\n  emitted as attributes\n\nSee [`examples/opentelemetry/README.md`](./examples/opentelemetry/README.md)\nfor setup and three suggested dashboards (hit rate, single-flight\nleadership distribution, op latency tails).\n\n---\n\n## How it differs from `@fortedigital/nextjs-cache-handler`\n\nThree deliberate departures, all rooted in lessons from production\nincidents (see [`docs/`](./docs/)):\n\n1. **Build-phase skip is the default**, not an opt-in. Every Redis call\n   goes through a `shouldUseRedis()` gate that short-circuits when\n   `process.env.NEXT_PHASE === \"phase-production-build\"`. PR #207 on\n   `@fortedigital` was rejected for missing exactly this.\n2. **Deployment isolation is automatic**. Every entry key includes\n   `BUILD_NAMESPACE` (=`process.env.DEPLOYMENT_VERSION`) by default. New\n   deploys can never read entries written by old ones — fixes the\n   \"static chunk 404 after deploy\" failure mode without manual cache\n   flushes.\n3. **Lua-atomic tag updates**. `set` writes the entry and updates tag\n   indices in a single Lua transaction. `updateTags(..., {expire: 0})`\n   removes matching entries with one `EVALSHA`. No window for half-applied\n   sets to leak dangling tag members.\n\nWhen `@fortedigital` ships its `cacheHandlers` support (PR #207 / feature\nbranch `feature/cache-components`), this package will continue to differ\non (2) and (3). For (1), we consider it table-stakes; the upstream's\neventual implementation should converge on the same behavior.\n\n---\n\n## Migrating from another cache handler\n\nComing from `@neshca/cache-handler` (or its forks like\n`@jadkins89/next-cache-handler`)? There's a dedicated guide with a full\nAPI/concept mapping, config diff, and a seeding replacement:\n[docs/migrating-from-neshca.md](docs/migrating-from-neshca.md).\n\n---\n\n## Compatibility\n\n> **Upgrading to Next.js 16.3?** Two behavioral notes: (1) 16.3 changed the\n> internal fetch-cache key format (`v3` → `v4`), so 16.2-era fetch entries\n> become unreadable orphans — deploy with a new `DEPLOYMENT_VERSION` and let\n> the old namespace age out via TTL. (2) An ISR entry found past its\n> `cacheLife` **expire** now triggers a blocking revalidation instead of\n> serving stale (matching `'use cache'` semantics); within\n> `revalidate..expire` stale-while-revalidate is unchanged.\n\n- **Next.js**: `>=15.0.0 <17`\n  - `cacheHandler` (singular, ISR): Next **15 and 16** — the handler accepts\n    both ctx shapes (Next 15's `ctx.revalidate` / `kindHint`, Next 16's\n    `ctx.cacheControl` / `kind`)\n  - `cacheHandlers` (plural, `'use cache'`): Next **>=16.1.5** only — the\n    interface does not exist before 16\n- **Node.js**: `>=20`\n- **redis**: `>=5.0.0` (peer, optional)\n- **ioredis**: `>=5.0.0` (peer, optional) — also powers `type: \"cluster\"` and\n  `type: \"sentinel\"` (Sentinel master discovery with automatic failover)\n\nESM and CJS dual-published, full TypeScript types, validated via\n[`arethetypeswrong`](https://arethetypeswrong.github.io) and\n[`publint`](https://publint.dev).\n\n---\n\n## Roadmap\n\n- **v0.1.0** *(2026-05)* — Both handlers, SWR, Lua atomicity, build-phase\n  skip, `redis@5` + `ioredis` adapters, AbortSignal, in-memory fallback\n  with TTL, soft-tag freshness check ✅\n- **v0.2.0** *(2026-05)* — Single-flight refresh lock with leader/follower\n  metrics, OpenTelemetry reference adapter, integration tests against\n  real Redis 7 (21 scenarios over `redis@5` + `ioredis`), GitHub Actions\n  OIDC publish path with provenance attestation ✅\n- **v0.3.0** *(2026-08)* — Next.js 15 ISR support, reconnect backoff (no\n  permanent connect-failure latch), request-scoped GET deduplication,\n  gzip/brotli compression, Redis Sentinel, built-in OpenTelemetry emitter\n  (`/otel`), LRU-bounded memory fallback, ESM peer-loading fixes ✅\n- **v0.4.0** *(2026-08)* — Build-output cache seeding (`seed` CLI, NX-safe),\n  pub/sub tag propagation, Redis Cluster e2e in CI, `init`/`doctor` CLI,\n  agent assets (skill, rules, MCP server) ✅\n  - *0.4.1* — Buffer serialization fix (~2.8x smaller ISR payloads) ✅\n  - *0.4.2* — upstream-exact tag invalidation semantics: no-durations\n    `updateTags` is hard (`updateTag()` read-your-own-writes), profile\n    durations are soft with a real hard deadline; tag-stale entries are\n    served with `revalidate: -1` instead of a backdated timestamp ✅\n- **v1.0.0** *(2026-08)* — public API frozen under semver; every export\n  documented in [`docs/api.md`](./docs/api.md) ✅\n- **Next** — Vercel KV / Upstash adapters, `'use cache: remote'` multi-tier\n  setup, `neshClassicCache` equivalent. All additive; no 2.0 needed.\n\n---\n\n## License\n\n[MIT](./LICENSE) © 2026 Eddy Lee\n",
  "bytes": 19611,
  "sha": "cbdd0f3a20b562321dcb6d8a492d8a5e49b68ef459bf8b611661505d3497b2a1",
  "repo_slug": "leejpsd/nextjs-cache-handler",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_leejpsd_nextjs_cache_handler_m_ffbb8007/readme"
}