@mirunamu/next-redis-cache
v1.0.6
Published
Redis cache handler for Next.js 16 — supports both legacy cacheHandler (ISR) and use-cache cacheHandlers
Readme
@mirunamu/next-redis-cache
Production-ready Redis cache handler for Next.js — seamlessly supports both legacy ISR (cacheHandler) and the new "use cache" directive (cacheHandlers).
Highlights
- Dual Handler Architecture — Single package covers both
cacheHandler(ISR/SSG pages) andcacheHandlers(React"use cache"directive) - Tag-based Invalidation — Full support for
revalidateTag()andrevalidatePath()with shared Redis state across instances - Build Prewarming — Provides
registerInitialCache()to push static build output into Redis during the instrumentation phase, so the first request is always a cache hit - Old Build Cleanup — SCAN-based cleanup removes stale keys from previous deployments without blocking the event loop
- Timeout Protection — Every Redis call is wrapped in a configurable timeout with graceful fallback to cache-miss behavior
- Concurrent Request Safety — Deduplicates in-flight
set()operations so parallel requests don't race against each other
Installation
npm install @mirunamu/next-redis-cachePeer dependencies:
| Package | Version |
| --------------- | ---------- |
| next | >=15.0.0 |
| @redis/client | >=5.0.0 |
Note: This package uses the official
@redis/client(part ofredisv5+), notioredis.
Quick Start
Step 1 — Legacy Cache Handler
Create cache-handler.mjs at your project root:
import { LegacyCacheHandler } from "@mirunamu/next-redis-cache";
const buildId = process.env.BUILD_ID || "default";
LegacyCacheHandler.onCreation(async (context) => {
// The onCreation hook receives { serverDistDir, dev } — not the full Next.js context.
// Use process.env to detect the build phase.
if (
process.env.NEXT_PHASE === "phase-production-build" ||
!process.env.REDIS_URL
) {
return null; // skip Redis during build or when no URL is provided
}
const { createClient } = await import("@redis/client");
const client = createClient({ url: process.env.REDIS_URL });
client.on("error", (err) => console.error("[Redis]", err.message));
await client.connect();
return {
client,
keyPrefix: `myapp:${buildId}:`,
sharedTagsKey: `_tags`,
sharedTagsTtlKey: `_tagTtls`,
revalidatedTagsKey: `_revalidated`,
};
});
export default LegacyCacheHandler;Step 2 — "use cache" Handler
Create use-cache-handler.mjs at your project root:
const buildId = process.env.BUILD_ID || "default";
let handler;
if (
process.env.NEXT_PHASE === "phase-production-build" ||
!process.env.REDIS_URL
) {
// Build time or no Redis → noop handler
handler = {
get: () => Promise.resolve(undefined),
set: () => Promise.resolve(),
refreshTags: () => Promise.resolve(),
getExpiration: () => Promise.resolve(0),
updateTags: () => Promise.resolve(),
};
} else {
const { createUseCacheHandler } = await import(
"@mirunamu/next-redis-cache/use-cache"
);
const { createClient } = await import("@redis/client");
const client = createClient({ url: process.env.REDIS_URL });
client.on("error", (err) => console.error("[Redis]", err.message));
await client.connect();
handler = createUseCacheHandler({
client,
keyPrefix: `myapp:${buildId}:`,
useCacheKeyPrefix: `myapp:${buildId}:uc:`,
sharedTagsKey: `_tags`,
sharedTagsTtlKey: `_tagTtls`,
revalidatedTagsKey: `_revalidated`,
timeoutMs: 5000,
});
}
export default handler;Important: Both handlers must use the same
sharedTagsKey,sharedTagsTtlKey, andrevalidatedTagsKeyvalues so thatrevalidateTag()from the legacy handler also invalidates"use cache"entries and vice versa.
Step 3 — Next.js Configuration
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheHandler: require.resolve("./cache-handler.mjs"),
cacheHandlers: {
default: require.resolve("./use-cache-handler.mjs"),
},
cacheMaxMemorySize: 0, // disable in-memory cache, use Redis only
generateBuildId: async () => process.env.BUILD_ID || "default",
};
export default nextConfig;Step 4 — Instrumentation (Optional)
Create src/instrumentation.ts to enable build prewarming and old-key cleanup:
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const buildId = process.env.BUILD_ID || "default";
const { cleanupOldBuildKeys, registerInitialCache } = await import(
"@mirunamu/next-redis-cache/instrumentation"
);
// Remove keys from previous builds
if (process.env.REDIS_URL) {
await cleanupOldBuildKeys({
redisUrl: process.env.REDIS_URL,
patterns: [{ scan: "myapp:*", keepPrefix: `myapp:${buildId}:` }],
});
}
// Push static build output into Redis
const CacheHandler = (await import("../cache-handler.mjs")).default;
await registerInitialCache(CacheHandler, { setOnlyIfNotExists: true });
}
}Configuration Reference
LegacyCacheHandler Options
Returned from the onCreation hook:
| Option | Type | Default | Description |
| -------------------- | ------------------------------ | -------------------------- | ----------------------------------------------------------- |
| client | RedisClientType | required | Connected @redis/client instance |
| keyPrefix | string | "" | Prefix prepended to all Redis keys (cache data, tags, TTLs) |
| sharedTagsKey | string | "__sharedTags__" | Suffix for the tag-to-cache-key mapping Hash |
| sharedTagsTtlKey | string | "__sharedTagsTtl__" | Suffix for the cache key expiration tracking Hash |
| revalidatedTagsKey | string | "__revalidated_tags__" | Suffix for the tag revalidation timestamps Hash |
| timeoutMs | number | 5000 | Timeout (ms) for each Redis operation |
| defaultStaleAge | number | 31536000 (1 year) | Default stale age (seconds) when revalidate is not set |
| estimateExpireAge | (staleAge: number) => number | s => Math.floor(s * 1.5) | Calculates the hard expiration age from the stale age |
Key composition:
sharedTagsKey,sharedTagsTtlKey, andrevalidatedTagsKeyare automatically prefixed withkeyPrefix. For example,keyPrefix: "myapp:abc:"+sharedTagsKey: "_tags"results in the Redis keymyapp:abc:_tags. Do not include the prefix in these values.
createUseCacheHandler Options
| Option | Type | Default | Description |
| -------------------- | ----------------- | ------------------------ | ------------------------------------------------------------------- |
| client | RedisClientType | required | Connected @redis/client instance |
| keyPrefix | string | "" | Prefix prepended to tag/TTL Hash keys |
| useCacheKeyPrefix | string | "uc:{keyPrefix}" | Prefix for "use cache" data entries |
| sharedTagsKey | string | "__sharedTags__" | Suffix for the tag mapping Hash (prefixed with keyPrefix) |
| sharedTagsTtlKey | string | "__sharedTagsTtl__" | Suffix for the expiration tracking Hash (prefixed with keyPrefix) |
| revalidatedTagsKey | string | "__revalidated_tags__" | Suffix for the tag revalidation Hash (prefixed with keyPrefix) |
| timeoutMs | number | 5000 | Timeout (ms) for each Redis operation |
cleanupOldBuildKeys Options
| Option | Type | Description |
| ---------- | ------------------ | --------------------------------------------- |
| redisUrl | string | Redis connection URL (creates its own client) |
| patterns | CleanupPattern[] | Array of scan/keep rules |
Each CleanupPattern:
| Field | Type | Description |
| ------------ | --------- | -------------------------------------- |
| scan | string | Redis SCAN pattern (e.g., "myapp:*") |
| keepPrefix | string? | Keep keys starting with this prefix |
| keepExact | string? | Keep this exact key |
registerInitialCache Options
| Option | Type | Default | Description |
| -------------------- | --------- | ------- | -------------------------------------------------- |
| setOnlyIfNotExists | boolean | true | Only write if key doesn't exist in Redis (NX flag) |
API Reference
Entry Point: @mirunamu/next-redis-cache
import { LegacyCacheHandler } from "@mirunamu/next-redis-cache";LegacyCacheHandler — Drop-in cache handler for Next.js cacheHandler config.
| Method | Signature | Description |
| --------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| onCreation | static onCreation(hook: OnCreationHook): void | Register an async hook that returns Redis config. Called once at module load time. |
| get | async get(key: string, ctx?: object): Promise<CacheHandlerValue \| null> | Retrieve a cached entry. Returns null on miss, timeout, expiry, or tag staleness. The optional ctx may contain softTags for implicit tag checking. |
| set | async set(key: string, data: unknown, ctx?: object): Promise<void> | Store a cache entry with serialized Buffers, tags, and TTL. |
| revalidateTag | async revalidateTag(tag: string \| string[]): Promise<void> | Invalidate all cache entries associated with the given tag(s). |
Entry Point: @mirunamu/next-redis-cache/use-cache
import { createUseCacheHandler } from "@mirunamu/next-redis-cache/use-cache";createUseCacheHandler(options) — Creates a handler object for Next.js cacheHandlers.default.
Returns:
| Method | Signature | Description |
| --------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| get | async get(cacheKey: string, softTags: string[]): Promise<CacheEntry \| undefined> | Retrieve a "use cache" entry. Waits for any in-flight set() on the same key. |
| set | async set(cacheKey: string, pendingEntry: Promise<CacheEntry>): Promise<void> | Await the pending entry promise, tee the stream, and store in Redis. |
| refreshTags | async refreshTags(): Promise<void> | No-op for Redis (shared state across instances). |
| getExpiration | async getExpiration(tags: string[]): Promise<number> | Returns the maximum revalidation timestamp for the given tags, or 0. |
| updateTags | async updateTags(tags: string[], durations?: object): Promise<void> | Update tag revalidation timestamps in Redis. |
Entry Point: @mirunamu/next-redis-cache/instrumentation
import {
registerInitialCache,
cleanupOldBuildKeys,
} from "@mirunamu/next-redis-cache/instrumentation";| Function | Return | Description |
| ---------------------------------------------- | -------------------------------- | --------------------------------------------------------------------------- |
| registerInitialCache(CacheHandler, options?) | Promise<{ prewarmed: number }> | Read .next/prerender-manifest.json and push all static routes into Redis. |
| cleanupOldBuildKeys(options) | Promise<{ deleted: number }> | SCAN Redis for old build keys and delete them in batch. |
Features
Tag-based Invalidation
The two handlers use different invalidation strategies, but share the revalidatedTagsKey Redis Hash for tag revalidation timestamps.
Legacy Handler (revalidateTag)
The legacy handler performs eager invalidation — it scans and deletes cache entries immediately:
- Scans the
sharedTagsKeyHash to find all cache keys tagged with the given tag - Deletes matching cache keys and their tag/TTL registrations
- For implicit tags only (
_N_T_prefix, generated byrevalidatePath()), also records a revalidation timestamp inrevalidatedTagsKey
Note: For explicit tags (e.g.,
"product"), the legacy handler does not record a timestamp — it relies solely on scan-and-delete. ThesharedTagsKeyandsharedTagsTtlKeyHashes are only used by the legacy handler; the use-cache handler does not register entries in them.
Use-cache Handler (updateTags)
The use-cache handler performs lazy invalidation — it records timestamps, and staleness is checked on read:
- Records the tag's revalidation timestamp in
revalidatedTagsKey(for all tags) - On subsequent
get()calls, compares the entry'stimestampagainst the tag's revalidation timestamp - If the tag was revalidated after the entry was stored, the entry is considered stale
// app/actions.ts
"use server";
import { revalidateTag } from "next/cache";
export async function updateProduct(id: string) {
await db.product.update(id, {
/* ... */
});
revalidateTag("product"); // invalidates all entries tagged "product"
revalidateTag(`product:${id}`); // invalidates entries for this specific product
}When revalidateTag() is called in a Server Action, Next.js dispatches it to both handlers: LegacyCacheHandler.revalidateTag() and the use-cache handler's updateTags().
Next.js also generates implicit tags (prefixed with _N_T_) for path-based invalidation. revalidatePath("/blog") marks the implicit tag as stale so subsequent get() calls return a cache miss.
TTL & Cache Lifecycle
Each cache entry tracks three timestamps:
| Timestamp | Meaning | Calculation |
| ---------- | ----------------------------------------------------- | --------------------------------------------------- |
| staleAt | Entry becomes stale, triggers background revalidation | lastModified + revalidate |
| expireAt | Entry is completely removed | lastModified + estimateExpireAge(staleAge) |
| Redis EX | Redis key TTL (auto-deletion) | expireAt - now (remaining seconds until expireAt) |
The estimateExpireAge function determines how long to keep stale entries before hard expiration. The default s => Math.floor(s * 1.5) keeps entries 50% longer than their stale age, giving Next.js time for background revalidation.
For the "use cache" handler, TTL is calculated from the entry's own timestamps:
ttl = max(1, expire - (Date.now() - timestamp) / 1000)You can customize the lifecycle per-route using Next.js cacheLife():
"use cache";
import { cacheLife } from "next/cache";
export async function getCatalog() {
cacheLife("hours"); // stale: 1h, revalidate: 1h, expire: 1h
return db.catalog.findMany();
}Build Prewarming
registerInitialCache() is designed to be called from your instrumentation.ts during the Next.js startup phase. It reads build output and pushes statically generated pages into Redis, eliminating cold starts:
- Reads
.next/prerender-manifest.json(version 4) - For each route, reads the corresponding files from disk:
- App Pages:
.html,.rsc,.meta, and.segments/directory - App Routes:
.bodyand.meta
- App Pages:
- Calls
CacheHandler.set()withsetOnlyIfNotExists: true(Redis NX flag) so existing cache entries are not overwritten
const { prewarmed } = await registerInitialCache(CacheHandler, {
setOnlyIfNotExists: true, // default: true
});
console.log(`Prewarmed ${prewarmed} routes`);Old Build Cleanup
When you deploy a new build with a new buildId, previous build keys become orphaned in Redis. cleanupOldBuildKeys() removes them:
await cleanupOldBuildKeys({
redisUrl: process.env.REDIS_URL!,
patterns: [
{
scan: "myapp:*", // scan all keys under myapp:
keepPrefix: `myapp:${buildId}:`, // keep current build's keys
},
],
});Since all keys (cache data, tags, TTLs, revalidation) share the same keyPrefix, a single pattern is sufficient to clean up everything from previous builds.
The cleanup uses SCAN with COUNT 200 to iterate keys without blocking the Redis server, and DEL to remove them in batch.
Concurrent Request Handling
The "use cache" handler maintains a pendingSets Map that tracks in-flight set() operations by cache key. When a get() request arrives for a key that is currently being written:
- Instead of immediately returning a cache miss, it waits for the pending write to complete
- Then proceeds to read the entry from Redis (a single read, not a retry)
This prevents a common race condition where multiple concurrent requests all experience cache misses and redundantly compute the same value.
Error Recovery
Every Redis operation is wrapped in a timeout (default: 5000ms). When a timeout or connection error occurs:
get()returnsnull(legacy) orundefined(use-cache) — treated as a cache missset()silently fails — the entry is not cached, but the response is still servedrevalidateTag()logs the error and continues
The handler also checks client.isReady before each operation. If the Redis connection is lost, all cache operations gracefully degrade to cache-miss behavior.
Enable debug logging with:
NEXT_PRIVATE_DEBUG_CACHE=1 npm run startRedis Key Structure
All Redis keys are composed from keyPrefix + suffix. This keeps every key under a single namespace for easy cleanup.
# Cache data (String keys with TTL)
{keyPrefix}{cacheKey} → ISR page cache (JSON)
{useCacheKeyPrefix}{cacheKey} → "use cache" entries (base64 JSON)
# Tag management (Hash keys, auto-prefixed with keyPrefix)
{keyPrefix}{sharedTagsKey} → { cacheKey: JSON(tags[]) }
{keyPrefix}{sharedTagsTtlKey} → { cacheKey: expireTimestamp }
{keyPrefix}{revalidatedTagsKey} → { tagName: revalidationTimestamp }Example with keyPrefix: "myapp:abc:", sharedTagsKey: "_tags", revalidatedTagsKey: "_revalidated":
myapp:abc:/products → '{"kind":"APP_PAGE","html":"...","rscData":"base64..."}'
myapp:abc:uc:/api/get → '{"data":"base64...","tags":["product"],"revalidate":3600}'
myapp:abc:_tags → { "/products": '["product","catalog"]' }
myapp:abc:_tagTtls → { "/products": "1707592843" }
myapp:abc:_revalidated → { "product": "1707592000" }Since all keys share the myapp:abc: prefix, a single cleanup pattern { scan: "myapp:*", keepPrefix: "myapp:abc:" } removes all keys from previous builds.
Getting Started
To use the cache handlers in your own Next.js app, create the following files based on the Quick Start examples:
cache-handler.mjs— Legacy handler setup (changekeyPrefixto your app name)use-cache-handler.mjs—"use cache"handler setup (use the same tag key suffixes)next.config.ts— AddcacheHandler,cacheHandlers,cacheMaxMemorySize: 0, andgenerateBuildIdsrc/instrumentation.ts— Optional: addcleanupOldBuildKeys()andregisterInitialCache()
Replace myapp with your own app prefix (e.g., docs, blog). Ensure both handlers share the same sharedTagsKey, sharedTagsTtlKey, and revalidatedTagsKey values.
Compatibility
| Requirement | Version |
| --------------- | ------- |
| Next.js | 15, 16 |
| @redis/client | 5+ |
| Node.js | 18+ |
Works with any deployment target: Vercel, Docker, self-hosted, or any Node.js runtime.
