By the time I've made the implementation this package wasn't compatible with Next 15 (looks like that's still the case). I used fortedigital/nextjs-cache-handler for the process of filling the build cache in Redis, and that package is based on the one you've mentioned.


Initially I went full custom on the cache handler. Later on the road I used this package to use a short TTL memory cache (1 minute) on memory before touching Redis again.

@trieb.work/nextjs-turbo-redis-cache

The structure looked like:
cache
| handler.ts
| tsconfig.json
src
| instrumentation.ts (for build cache filling)
next.config.js


On next config the handler is imported (notice it is a .js file, so it needs to be transpiled before build)

cacheMaxMemorySize: 0,
...(
process
.env.NODE_ENV === "production" && {
cacheHandler: "./cache/handler.js",
}),
tsc -p ./cache/tsconfig.json


Content of the other files:

tsconfig.json

{
"compilerOptions": {
"target": "es2023",
"module": "esnext",
"jsx": "react",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"moduleResolution": "bundler",
"skipLibCheck": true
},
"include": ["*"]
}

handler.ts
Two points of attention here, in case you use as a base for your implementation.
1. We opted for not saving 307 and 308s in cache
2. When a tag is revalidated, we fetch the url right away so the fresh cache is generated

import { RedisStringsHandler } from "@trieb.work/nextjs-turbo-redis-cache"
import
path
from "path"
import node_fs from "node:fs"
let cachedHandler: RedisStringsHandler

const scope =

process
.env.NEXT_PHASE === "phase-production-build"
? "build"
: "runtime"
const buildIdPath = path.join(".next", "BUILD_ID")

function getBuildId() {
return node_fs.readFileSync(buildIdPath, "utf8").trim()
}

const logger = scope === "runtime" ?
console
.log : () => {}

class CustomizedCacheHandler {
constructor() {
if (!cachedHandler && node_fs.existsSync(buildIdPath)) {
const buildId = getBuildId()

cachedHandler = new RedisStringsHandler({
redisUrl:
process
.env.REDIS_URL,
socketOptions: {
tls: true,
rejectUnauthorized: false,
},
database: 0,
keyPrefix: `${buildId}_`,
getTimeoutMs: 10000,
revalidateTagQuerySize: 500,
sharedTagsKey: `${buildId}__shared__`,
inMemoryCachingTime: 1000 * 60,
estimateExpireAge: (age) => age * 60,
})
}
}

get(...args: Parameters<typeof cachedHandler.get>) {
logger("GET", { key: args[0] })
return cachedHandler.get(...args)
}

set(...args: Parameters<typeof cachedHandler.set>) {
logger("SET", { key: args[0] })

if (args[1].
kind
=== "APP_PAGE") {
const status = args[1]?.status
if (status === 307 || status === 308) {
return cachedHandler.set(
args[0],
{
...args[1],
html: "<div></div>",
rscData: Buffer.from(""),
},
args[2]
)
}
}
return cachedHandler.set(...args)
}

revalidateTag(
...args: Parameters<typeof cachedHandler.revalidateTag>
) {
const prefix = "_N_T_/"
const pages = (args[0] as string[])
.filter((t) => t.includes(prefix))
.map((t) => t.replace(prefix, ""))

logger("REVALIDATE", { key: args[0] })
cachedHandler.revalidateTag(...args)

pages.map((page) => {
const url = `${
process
.env.NEXT_PUBLIC_SERVER_URL}/${page}`
logger("GENERATE", url)
fetch(url)
})
}

resetRequestCache(
...args: Parameters<typeof cachedHandler.resetRequestCache>
) {
logger("RESET")
return cachedHandler.resetRequestCache(...args)
}
}

export default CustomizedCacheHandler

instrumentation.ts
1. The "exists" logic make sure the build cache is used only once, in case a POD restarts it won't fallback any entry to the build one.
2. We had trouble using "/" as home page. We default it to "/home" and that did it.
3. On handler.ts and implementation.ts we use the nextjs build ID as prefix for the entries, so k8s orchestration can have two versions of the application running and no overwrites. We deploy once every two weeks and manually purge the old build cache.

export async function register() {
if (

process
.env.NEXT_RUNTIME === "nodejs" &&

process
.env.NODE_ENV === "production"
) {
const { getBuildId } = await import("@/utilities/build")
const buildId = getBuildId()

console
.info(`Registering instrumentation for ${buildId}`)

const fs = (await import("fs/promises")).default
const path = (await import("path")).default

const { createClient } = await import("redis")
const client = createClient({
url:
process
.env.REDIS_URL,
socket: {
tls: true,
rejectUnauthorized: false,
},
})

client.on("error", (err) =>

console
.log("Redis Client Error", err)
)

await client.connect()

const key = `${buildId}__status`
const exists = await client.
get
(key)

if (exists) {

console
.info("Bypassing initial cache seed.")
} else {

console
.info("Generating cache from build metadata.")

const pathToMetadata = path.join(
".next",
"prerender-manifest.json"
)

const metadata = await fs.readFile(pathToMetadata, {
encoding: "utf8",
})
if (metadata) {
const result = metadata.replace('"/"', '"/home"')
await fs.writeFile(pathToMetadata, result, {
encoding: "utf8",
})
}

const { registerInitialCache } = await import(
"@fortedigital/nextjs-cache-handler/instrumentation"
)

const CacheHandler = (await import("../cache/handler")).default

await client.
set
(key, "seeded")


console
.info("Seeding initial cache.")
// @ts-expect-error Classes are interoperable
await registerInitialCache(CacheHandler)

console
.info("Initial data seed completed.")
}
}
}