Repository navigation
Replies: 7 comments 4 replies
|
I had drafted this a while ago: https://github.com/vercel/next.js/pull/89933/changes — which is not ideal, more of a workaround. Let me check with a colleague who might have more input. |
|
Current behavior is:
The handler loader already uses dynamic What Next does not do is run handler files through the A modern ESM setup can look like this: // cache-handlers/remote-handler.mts
import type {
CacheEntry,
CacheHandler,
} from "next/dist/server/lib/cache-handlers/types"
const cache = new Map<string, CacheEntry>()
const handler: CacheHandler = {
async get(key) {
return cache.get(key)
},
async set(key, pendingEntry) {
cache.set(key, await pendingEntry)
},
async refreshTags() {},
async getExpiration() {
return 0
},
async updateTags() {},
}
export default handlerCompile // next.config.mjs
import { fileURLToPath } from "node:url"
export default {
cacheHandlers: {
remote: fileURLToPath(
new URL("./cache-handlers/dist/remote-handler.mjs", import.meta.url),
),
},
}Run the handler compile step from One caveat: So the CommonJS examples are a documentation limitation, not an ESM runtime limitation. Direct TypeScript loading is the unsupported part. |
Tip for
|
|
Yes, the Node type-stripping route works, and you don't need a compile step. Next.js doesn't bundle cache handlers. It loads the path from I tested this with Next 16.4.2 on Node 22.22. It worked with // next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
cacheHandlers: {
default: require.resolve('./cache-handlers/memory-handler.ts'),
// import.meta.resolve('./cache-handlers/memory-handler.ts') also works
},
}
export default nextConfig// cache-handlers/memory-handler.ts
import type { CacheEntry, CacheHandler } from 'next/cache'
import { log } from '../lib/log.ts' // shared TS code works too
type StoredEntry = Omit<CacheEntry, 'value'> & { value: Uint8Array }
const cache = new Map<string, StoredEntry>()
const pending = new Map<string, Promise<void>>()
const handler: CacheHandler = {
async get(cacheKey) {
await pending.get(cacheKey)
const entry = cache.get(cacheKey)
log(entry ? 'HIT' : 'MISS', cacheKey)
if (!entry) return undefined
return {
...entry,
value: new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(entry.value)
controller.close()
},
}),
}
},
async set(cacheKey, pendingEntry) {
const { promise, resolve } = Promise.withResolvers<void>()
pending.set(cacheKey, promise)
try {
const entry = await pendingEntry
const value = new Uint8Array(await new Response(entry.value).arrayBuffer())
cache.set(cacheKey, { ...entry, value })
} finally {
resolve()
pending.delete(cacheKey)
}
},
async refreshTags() {},
async getExpiration() {
return 0
},
async updateTags() {},
}
export default handlerNode strips the types but never reads
With Docs: |
|
Yes, there are two working approaches. Which one to use depends on your Node.js version. Option 1: No build step (Next.js 16.4+, Node 22.18+ or 23.6+)
Tested with Next.js 16.4.2 on Node 22.22, using
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
cacheHandlers: {
default: require.resolve('./cache-handlers/memory-handler.ts'),
// import.meta.resolve('./cache-handlers/memory-handler.ts') also works
},
}
export default nextConfig
import type { CacheEntry, CacheHandler } from 'next/cache'
import { log } from '../lib/log.ts'
type StoredEntry = Omit<CacheEntry, 'value'> & { value: Uint8Array }
const cache = new Map<string, StoredEntry>()
const pending = new Map<string, Promise<void>>()
const handler: CacheHandler = {
async get(cacheKey) {
await pending.get(cacheKey)
const entry = cache.get(cacheKey)
log(entry ? 'HIT' : 'MISS', cacheKey)
if (!entry) return undefined
return {
...entry,
value: new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(entry.value)
controller.close()
},
}),
}
},
async set(cacheKey, pendingEntry) {
const { promise, resolve } = Promise.withResolvers<void>()
pending.set(cacheKey, promise)
try {
const entry = await pendingEntry
const value = new Uint8Array(await new Response(entry.value).arrayBuffer())
cache.set(cacheKey, { ...entry, value })
} finally {
resolve()
pending.delete(cacheKey)
}
},
async refreshTags() {},
async getExpiration() {
return 0
},
async updateTags() {},
}
export default handler
export function log(status: 'HIT' | 'MISS', key: string) {
console.log(`[cache] ${status} ${key}`)
}
{
"compilerOptions": {
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"noEmit": true
}
}Rules for files loaded this way:
Option 2: Compile step (older Node versions)If you can't use Node type stripping, compile the handler to
import type { CacheEntry, CacheHandler } from 'next/cache'
const cache = new Map<string, CacheEntry>()
const handler: CacheHandler = {
async get(key) {
return cache.get(key)
},
async set(key, pendingEntry) {
cache.set(key, await pendingEntry)
},
async refreshTags() {},
async getExpiration() {
return 0
},
async updateTags() {},
}
export default handler
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"outDir": "cache-handlers/dist",
"rootDir": "cache-handlers",
"skipLibCheck": true
},
"include": ["cache-handlers/**/*.mts"]
}
{
"scripts": {
"build:cache-handlers": "tsc -p tsconfig.cache-handlers.json",
"predev": "npm run build:cache-handlers",
"prebuild": "npm run build:cache-handlers"
}
}
import { fileURLToPath } from 'node:url'
export default {
cacheHandlers: {
default: fileURLToPath(
new URL('./cache-handlers/dist/remote-handler.mjs', import.meta.url),
),
},
}Note: Summary
|
|
Yes, you can. The CJS-only examples are a docs limitation, not a runtime one.
import { fileURLToPath } from 'node:url'
export default {
cacheHandlers: {
default: fileURLToPath(new URL('./cache-handlers/handler.mjs', import.meta.url)),
},
}
Since Node only strips types and never reads
On Node 20 or older, compile the handler to @icyJoseph @devjiwonchoi it would be great if the docs added an ESM/TS example (even as a tab next to the CJS one) and noted the Node version requirement. Happy to open a PR if that's welcome. |
Uh oh!
There was an error while loading. Please reload this page.
Summary
All the examples of custom
cacheHandlerson https://nextjs.org/docs/app/api-reference/config/next-config-js/cacheHandlers seem to be written as awful, old school CommonJS stuff. The configuration refers to them viarequire.resolve, the file itself is.js, and it usesmodule.exportsandrequire.We're in 2026, can I really not write a custom cache handler for Next in Typescript using ESM?
Additional information
No response
Example
No response
All reactions