Skip to content

fix(adapter-nextjs): support next.config.mts and stop stranding the user config - #682

Open
Om-singhaI wants to merge 1 commit into
firebase:mainfrom
Om-singhaI:fix/nextjs-mts-config-support
Open

fix(adapter-nextjs): support next.config.mts and stop stranding the user config#682
Om-singhaI wants to merge 1 commit into
firebase:mainfrom
Om-singhaI:fix/nextjs-mts-config-support

Conversation

@Om-singhaI

Copy link
Copy Markdown

fix(adapter-nextjs): support next.config.mts and stop stranding the user config

Addresses issue #680.

What breaks

Building an app whose Next config is next.config.mts fails, and the failure takes the
user's config file with it.

overrideNextConfig in packages/@apphosting/adapter-nextjs/src/overrides.ts switches on
the config file extension. It handles .js, .mjs and .ts; .mts falls through to the
default branch and throws:

Unsupported file extension for Next Config: ".mts", please use ".js", ".mjs", or ".ts"

The throw happens after the function has already renamed the user's next.config.mts to
next.config.original.mts, and the surrounding catch only logs and rethrows. In
src/bin/build.ts the overrideNextConfig and validateNextConfigOverride calls sat
outside the try block whose finally runs restoreNextConfig, so the error escaped with
no restore. The build left the project holding next.config.original.mts and no
next.config.mts at all.

That second half is not specific to .mts. Any failure after the rename, including a write
failure, stranded the tree the same way.

Why the extension switch was not the whole fix

Two further things had to change for a generated .mts config to actually load.

Module tail. getCustomNextConfig selected the export form by testing for .mjs only,
so a .mts file would have received module.exports = config;. A .mts file is always an
ES module regardless of the package type field, so module is not defined in it. I
confirmed this on Node v25.6.1:

ReferenceError: module is not defined in ES module scope

The generated config would have had no default export, and validateNextConfigOverride
would have thrown on it anyway.

Import specifier. The .ts case strips the extension entirely, producing
import originalConfig from './next.config.original';. Copying that for .mts does not
work. Next.js discovers next.config.mts only when Node's type stripping is available, and
loads it through Node, which resolves ES module specifiers as written. I checked all three
candidate specifiers against a real .mts file on Node v25.6.1:

specifier result
'./next.config.original' (mirrors the .ts case) ERR_MODULE_NOT_FOUND
'./next.config.original.mjs' ERR_MODULE_NOT_FOUND
'./next.config.original.mts' loads

So .mts keeps its extension. It shares the import statement with .mjs but, unlike
.ts, must not have the extension dropped.

What the fix does

packages/@apphosting/adapter-nextjs/src/overrides.ts

  • Adds .mts to the extension switch, sharing the .mjs import statement so the specifier
    stays fully specified. A comment records why it must not follow the .ts case.
  • Emits export default config; for .mts as well as .mjs.
  • Generates the replacement config content before renaming anything, so an unsupported
    extension now fails while the user's project is still untouched.
  • Moves the original file back via the existing restoreNextConfig if the rename succeeded
    but the write did not, guarded so it cannot clobber a live config with a stale backup from
    an earlier crashed run.

packages/@apphosting/adapter-nextjs/src/bin/build.ts

  • Moves the overrideNextConfig and validateNextConfigOverride calls inside the existing
    try, so the restoreNextConfig already in the finally covers a validation failure too.

Doc comments listing the supported extensions were updated to include mts.

Testing

Two regression tests were added to src/overrides.spec.ts, in the same string and fs
assertion style as the existing cases:

  • should set images.unoptimized to true - TypeScript ES Modules covers the .mts output,
    asserting both the fully specified import and the export default config; tail.
  • should leave the original config in place when the override fails drives a failing
    override and asserts the user's config still exists with its original contents and that no
    backup file was left behind. It uses .cjs, which is unsupported before and after this
    change, so it stays a real test of the failure path.

Both tests were written first and observed failing against unmodified source:

  1) next config overrides
       should set images.unoptimized to true - TypeScript ES Modules:
     Error: Unsupported file extension for Next Config: ".mts", please use ".js", ".mjs", or ".ts"
      at overrideNextConfig (.../dist/overrides.js:36:23)
      at Context.<anonymous> (src/overrides.spec.ts:335:5)

  2) next config overrides
       should leave the original config in place when the override fails:
     Error: ENOENT: no such file or directory, open '/var/folders/.../test-overridesrKjJqo/next.config.cjs'
      at Object.readFileSync (node:fs:435:20)
      at Context.<anonymous> (src/overrides.spec.ts:362:21)

  0 passing (30ms)
  2 failing

The ENOENT in the second test is the bug itself: the config the test wrote had been renamed
away and never restored.

With the fix, npm run test:unit in packages/@apphosting/adapter-nextjs reports
30 passing, up from the 28 on main, with no regressions. npm run lint reports 0
errors, and the changed files pass prettier --check.

Beyond the unit tests I generated a real override with the built adapter and loaded it the
way Next.js does. The result loads cleanly, applies the App Hosting override and preserves
the user's own settings:

LOADED OK, images.unoptimized = true | reactStrictMode = true

Note on the runtime gate

Next.js only lists next.config.mts among its candidate config files when
process.features.typescript is set, that is when Node has type stripping available, which
is the default on Node 22.18 and later and on Node 24 and later. The bug is real but only
reachable on such a runtime. The added unit tests call overrideNextConfig directly, so
they exercise the adapter's own logic and do not depend on that gate.

Why there is no end to end scenario

e2e/config-override-test-cases.yaml already drives one scenario per config flavour,
including next.config.ts and next.config.mjs, so a next.config.mts entry would be the
natural home for end to end coverage. It cannot go in yet. starters/nextjs/basic pins
next@15.0.5, and next.config.mts first appears in Next's CONFIG_FILES in v16.0.0, so
on the pinned starter the file is never discovered and the scenario would fail for a reason
unrelated to this change. CI also runs the suite on Node 18, where
process.features.typescript is unset and the gate is closed. Adding that scenario means
bumping the starter to Next 16 first, which is worth doing separately.

The .mts output was instead checked by hand against Node itself, which is what Next 16
delegates to for this file: it loads next.config.mts with a plain
await import(pathToFileURL(path).href) rather than through transpileConfig, the way it
handles next.config.ts.

…ser config

next.config.mts was rejected by overrideNextConfig. The extension switch only
handled ".js", ".mjs" and ".ts", so ".mts" fell through to the default branch
and threw "Unsupported file extension for Next Config".

Adding the extension to the switch alone is not enough. getCustomNextConfig
chose the module tail by testing for ".mjs" only, so a ".mts" file would have
been given "module.exports = config;". A .mts file is always an ES module
regardless of the package type field, so "module" is not defined there and the
generated config would export nothing. The import specifier also differs from
the ".ts" case: Next.js loads next.config.mts through Node's type stripping,
which resolves ES module specifiers as written, so the ".mts" extension must be
kept rather than dropped the way the ".ts" case drops it.

The rename was also destructive. overrideNextConfig renamed the user's config to
next.config.original with the same extension before deciding whether it could
generate a replacement, and the catch only logged and rethrew. bin/build.ts then
called overrideNextConfig and validateNextConfigOverride outside the try block
whose finally runs restoreNextConfig. Any throw from the override step therefore
escaped with no restore, leaving the project with a next.config.original file
and no next config at all. That was independent of the extension: a write
failure stranded the tree the same way.

The config content is now generated before anything is renamed, so an
unsupported extension fails while the project is still untouched, and a failure
after the rename moves the original file back. The override and validation calls
in bin/build.ts moved inside the try so the existing restoreNextConfig in the
finally covers them.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request adds support for the .mts file extension in Next.js configuration overrides and improves error handling during the override process. Specifically, validation of the file extension is now performed before renaming the original configuration file, and a fallback mechanism has been added to restore the original configuration if the override process fails. Unit tests have also been added to verify these changes. I have no feedback to provide.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant