How to Fix: Cache handler ERR_MODULE_NOT_FOUND when using ESM modules

5 min read

The ERR_MODULE_NOT_FOUND failure in a custom Next.js cache handler usually appears when the app is running in ESM mode but the cache handler path or module format is still being resolved like CommonJS. The result is confusing: the file exists, Next.js starts loading the handler, and Node still throws a module resolution error.

Understanding the Root Cause

This bug happens because Node.js ESM resolution rules are stricter than what many Next.js users expect. When a project uses “type”: “module”, or when the relevant config is treated as ESM, Node does not resolve files the same way as CommonJS.

In practice, a custom cache handler can fail for one or more of these reasons:

  • The handler file is referenced without the exact extension, such as using ./cache-handler instead of ./cache-handler.js in an ESM context.
  • The project mixes CommonJS exports and ESM imports, so the file loads with the wrong expectations.
  • The next.config file and the handler module do not use a compatible format.
  • A relative path is technically correct for one loader but invalid for the loader Next.js ends up using internally.

With ESM, Node requires explicit and unambiguous module references. If Next.js passes the cache handler path into Node’s ESM loader, Node expects a valid ESM-resolvable file path, including the right extension and export syntax.

That is why the issue shows up as ERR_MODULE_NOT_FOUND even though the file is present in the repository. The real problem is usually module resolution semantics, not a missing file on disk.

Step-by-Step Solution

The safest fix is to make the cache handler and the Next.js config agree on one module system and use an explicit file path.

1. Confirm whether your project is using ESM

Check your package.json. If it contains “type”: “module”, then Node treats .js files as ESM by default.

{
  "type": "module"
}

2. Use an explicit cache handler file extension

If your handler is configured in next.config.js or next.config.mjs, do not rely on extensionless paths.

Use this pattern:

const nextConfig = {
  cacheHandler: './cache-handler.js'
}

export default nextConfig

If your config is CommonJS, use:

/** @type {import('next').NextConfig} */
const nextConfig = {
  cacheHandler: './cache-handler.cjs'
}

module.exports = nextConfig

3. Make the handler file match the module system

If the project is ESM, export the handler with ESM syntax:

export default class CacheHandler {
  constructor(options) {
    this.options = options
  }

  async get(key) {
    return null
  }

  async set(key, data, ctx) {
  }

  async revalidateTag(tag) {
  }
}

If you are staying with CommonJS, use:

class CacheHandler {
  constructor(options) {
    this.options = options
  }

  async get(key) {
    return null
  }

  async set(key, data, ctx) {
  }

  async revalidateTag(tag) {
  }
}

module.exports = CacheHandler

4. Prefer next.config.mjs for ESM projects

If your project is already ESM, keeping the Next.js config in next.config.mjs reduces ambiguity.

/** @type {import('next').NextConfig} */
const nextConfig = {
  cacheHandler: './cache-handler.js'
}

export default nextConfig

This makes it clear that both the config and the handler are expected to participate in the ES module loading flow.

5. Avoid mixed-module shortcuts

Do not combine these patterns unless you are intentionally bridging them:

  • next.config.mjs with a handler that uses module.exports
  • next.config.js in an ESM package with a handler path missing the extension
  • Import/export syntax in one file and CommonJS syntax in the other

6. Restart the dev server after changing module format

Next.js and Node can hold onto stale assumptions during development. After renaming files or switching between .js, .mjs, and .cjs, fully stop and restart the server.

rm -rf .next
npm run dev

If you want the least surprising setup, use:

// package.json
{
  "type": "module"
}
// next.config.mjs
const nextConfig = {
  cacheHandler: './cache-handler.js'
}

export default nextConfig
// cache-handler.js
export default class CacheHandler {
  constructor(options) {
    this.options = options
  }

  async get(key) {
    return null
  }

  async set(key, data, ctx) {
  }

  async revalidateTag(tag) {
  }
}

This removes the most common reason for the error: an ESM loader trying to resolve a non-ESM or extensionless module path.

Common Edge Cases

Using TypeScript for the handler

If your cache handler is written as .ts, Node cannot automatically execute that source file unless Next.js explicitly transpiles and loads it in that path. If the handler is being loaded directly by Node, point to a JavaScript output file instead.

cacheHandler: './dist/cache-handler.js'

Path aliases do not work here

A path alias like @/lib/cache-handler may work in app code through bundler configuration, but a low-level runtime loader often expects a plain relative file path. Use a direct relative path for the handler.

Wrong working directory assumptions

If the path is resolved relative to the project root, but you assume it is relative to another file, Node will fail to find it. Keep the handler in a predictable location and reference it explicitly.

Default export mismatch

If Next.js expects a default export but the file only exposes a named export, the error may look like a loading problem even when resolution succeeded. Use a default export unless the API clearly documents another shape.

Deployments behaving differently than local dev

Some environments are less forgiving about casing, symlinks, or generated files. A path like ./Cache-Handler.js may work on one machine and fail on another. Match the filename exactly.

FAQ

Why does the file exist but Node still says ERR_MODULE_NOT_FOUND?

Because the failure is often about how Node resolves ESM modules, not whether the file exists. Missing extensions, unsupported aliases, or incompatible module syntax can all trigger this error.

Should I use .js, .mjs, or .cjs for the cache handler?

Use the file extension that matches your project’s module system. For an ESM project, .js with “type”: “module” or .mjs is the safest choice. For CommonJS, use .cjs if you want zero ambiguity.

Can I keep next.config.js and still use ESM?

Yes, but it is easier to make mistakes. In ESM projects, next.config.mjs makes module expectations explicit and usually avoids resolution confusion around the custom cache handler.

If you want to compare behavior against the reproduction repository from the issue, inspect the project structure directly from the reproduction branch and verify that the handler path, file extension, and export style all line up with the project’s active module system.

Leave a Reply

Your email address will not be published. Required fields are marked *