Skip to content

Resolve ESM-only plugins passed as strings#92576

Open
20syldev wants to merge 1 commit intovercel:canaryfrom
20syldev:fix/next-mdx-esm-plugin-string-resolution
Open

Resolve ESM-only plugins passed as strings#92576
20syldev wants to merge 1 commit intovercel:canaryfrom
20syldev:fix/next-mdx-esm-plugin-string-resolution

Conversation

@20syldev
Copy link
Copy Markdown

@20syldev 20syldev commented Apr 9, 2026

What

When a remark/rehype/recma plugin is passed as a string in @next/mdx options:

const withMDX = createMDX({
  options: {
    rehypePlugins: [['@stefanprobst/rehype-extract-toc']],
  },
})

mdx-js-loader.js resolves it with require.resolve(), which uses CJS resolution semantics. For ESM-only packages that expose an exports field with only an "import" condition (no "require" condition), Node throws ERR_PACKAGE_PATH_NOT_EXPORTED because no CJS-compatible entry exists.

Why

require.resolve() applies the "require" condition when matching exports. If the package's exports map looks like:

{
  "exports": {
    ".": {
      "import": "./dist/index.mjs"
    }
  }
}

require.resolve() finds no matching condition and throws. A dynamic import() uses ESM resolution and correctly matches the "import" condition.

Fix

Catch ERR_PACKAGE_PATH_NOT_EXPORTED from require.resolve() and fall back to a direct import(). This lets Node's ESM resolver handle the exports map, resolving the module correctly.

Code diff
 async function importPluginForPath(pluginPath, projectRoot) {
-  const path = require.resolve(pluginPath, { paths: [projectRoot] })
+  let resolvedPath
+  try {
+    resolvedPath = require.resolve(pluginPath, { paths: [projectRoot] })
+  } catch (e) {
+    if (e.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED') {
+      // ESM-only package: no "require" condition in exports map.
+      // Fall back to a direct import() which uses ESM resolution.
+      return interopDefault(await import(pluginPath))
+    }
+    throw e
+  }
   return interopDefault(
-    await import(process.platform === 'win32' ? pathToFileURL(path) : path)
+    await import(process.platform === 'win32' ? pathToFileURL(resolvedPath) : resolvedPath)
   )
 }

Testing

The existing test/e2e/app-dir/mdx suite already covers the string plugin resolution happy path (packages with CJS-compatible exports).

The fix affects the fallback path only — packages whose exports map has no "require" entry — which isn't covered by the current suite. Adding a test fixture with a local ESM-only plugin would provide coverage; happy to add one if the maintainers prefer it.

Fixes #73757

@nextjs-bot
Copy link
Copy Markdown
Collaborator

Allow CI Workflow Run

  • approve CI run for commit: e69e82a

Note: this should only be enabled once the PR is ready to go and can only be enabled by a maintainer

@nextjs-bot
Copy link
Copy Markdown
Collaborator

nextjs-bot commented Apr 9, 2026

Allow CI Workflow Run

  • approve CI run for commit: e05db67

Note: this should only be enabled once the PR is ready to go and can only be enabled by a maintainer

When an MDX plugin is passed as a string (e.g.
`rehypePlugins: ['@stefanprobst/rehype-extract-toc']`), the loader
resolves it via `require.resolve()` which uses CJS resolution
semantics.  Packages that ship with an `exports` field containing only
an `"import"` condition (no `"require"` condition) cause
`require.resolve` to throw ERR_PACKAGE_PATH_NOT_EXPORTED because no
CJS-compatible export entry is found.

Catch that specific error and fall back to a direct ESM dynamic
`import()`, which uses the ESM resolver and correctly matches the
`"import"` condition in the exports map.

Fixes vercel#73757
@20syldev 20syldev force-pushed the fix/next-mdx-esm-plugin-string-resolution branch from 23ca5eb to e05db67 Compare April 12, 2026 00:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MDX Plugin String Format resolution doesn't work with ESM plugins with multiple exports

2 participants