npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

npm version License: MIT Next.js

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) and cacheHandlers (React "use cache" directive)
  • Tag-based Invalidation — Full support for revalidateTag() and revalidatePath() 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-cache

Peer dependencies:

| Package | Version | | --------------- | ---------- | | next | >=15.0.0 | | @redis/client | >=5.0.0 |

Note: This package uses the official @redis/client (part of redis v5+), not ioredis.

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, and revalidatedTagsKey values so that revalidateTag() 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, and revalidatedTagsKey are automatically prefixed with keyPrefix. For example, keyPrefix: "myapp:abc:" + sharedTagsKey: "_tags" results in the Redis key myapp: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:

  1. Scans the sharedTagsKey Hash to find all cache keys tagged with the given tag
  2. Deletes matching cache keys and their tag/TTL registrations
  3. For implicit tags only (_N_T_ prefix, generated by revalidatePath()), also records a revalidation timestamp in revalidatedTagsKey

Note: For explicit tags (e.g., "product"), the legacy handler does not record a timestamp — it relies solely on scan-and-delete. The sharedTagsKey and sharedTagsTtlKey Hashes 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:

  1. Records the tag's revalidation timestamp in revalidatedTagsKey (for all tags)
  2. On subsequent get() calls, compares the entry's timestamp against the tag's revalidation timestamp
  3. 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:

  1. Reads .next/prerender-manifest.json (version 4)
  2. For each route, reads the corresponding files from disk:
    • App Pages: .html, .rsc, .meta, and .segments/ directory
    • App Routes: .body and .meta
  3. Calls CacheHandler.set() with setOnlyIfNotExists: 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() returns null (legacy) or undefined (use-cache) — treated as a cache miss
  • set() silently fails — the entry is not cached, but the response is still served
  • revalidateTag() 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 start

Redis 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:

  1. cache-handler.mjs — Legacy handler setup (change keyPrefix to your app name)
  2. use-cache-handler.mjs"use cache" handler setup (use the same tag key suffixes)
  3. next.config.ts — Add cacheHandler, cacheHandlers, cacheMaxMemorySize: 0, and generateBuildId
  4. src/instrumentation.ts — Optional: add cleanupOldBuildKeys() and registerInitialCache()

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.

License

MIT