From 8bea898f61a626b6c14da92db9b21ae152e0f45f Mon Sep 17 00:00:00 2001 From: Om Singhal Date: Sun, 23 Aug 2026 20:59:40 -0400 Subject: [PATCH] fix(adapter-nextjs): support next.config.mts and stop stranding the user 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. --- .../adapter-nextjs/src/bin/build.ts | 11 +-- .../adapter-nextjs/src/overrides.spec.ts | 47 +++++++++++++ .../adapter-nextjs/src/overrides.ts | 67 +++++++++++-------- 3 files changed, 92 insertions(+), 33 deletions(-) diff --git a/packages/@apphosting/adapter-nextjs/src/bin/build.ts b/packages/@apphosting/adapter-nextjs/src/bin/build.ts index 3d46dbd03..c56de969a 100644 --- a/packages/@apphosting/adapter-nextjs/src/bin/build.ts +++ b/packages/@apphosting/adapter-nextjs/src/bin/build.ts @@ -36,19 +36,20 @@ const nextConfig = await loadConfig(root, opts.projectDirectory); * We restore the user's Next Config at the end of the build, after the config file has been * copied over to the output directory, so that the user's original code is not modified. * - * If the app does not have a next.config.[js|mjs|ts] file in the first place, + * If the app does not have a next.config.[js|mjs|ts|mts] file in the first place, * then can skip config override. * * Note: loadConfig always returns a fileName (default: next.config.js) even if * one does not exist in the app's root: https://github.com/vercel/next.js/blob/23681508ca34b66a6ef55965c5eac57de20eb67f/packages/next/src/server/config.ts#L1115 */ const nextConfigPath = join(root, nextConfig.configFileName); -if (await exists(nextConfigPath)) { - await overrideNextConfig(root, nextConfig.configFileName); - await validateNextConfigOverride(root, opts.projectDirectory, nextConfig.configFileName); -} try { + if (await exists(nextConfigPath)) { + await overrideNextConfig(root, nextConfig.configFileName); + await validateNextConfigOverride(root, opts.projectDirectory, nextConfig.configFileName); + } + await runBuild(); const adapterMetadata = getAdapterMetadata(); diff --git a/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts b/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts index e223037c7..68a41c6dc 100644 --- a/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts +++ b/packages/@apphosting/adapter-nextjs/src/overrides.spec.ts @@ -319,6 +319,53 @@ describe("next config overrides", () => { ); }); + it("should set images.unoptimized to true - TypeScript ES Modules", async () => { + const { overrideNextConfig } = await importOverrides; + const originalConfig = ` + import type { NextConfig } from 'next' + + const nextConfig: NextConfig = { + /* config options here */ + } + + export default nextConfig + `; + + fs.writeFileSync(path.join(tmpDir, "next.config.mts"), originalConfig); + await overrideNextConfig(tmpDir, "next.config.mts"); + + const updatedConfig = fs.readFileSync(path.join(tmpDir, "next.config.mts"), "utf-8"); + assert.equal( + normalizeWhitespace(updatedConfig), + normalizeWhitespace(` + // @ts-nocheck + import originalConfig from './next.config.original.mts'; + + ${nextConfigOverrideBody} + + export default config; + `), + ); + }); + + it("should leave the original config in place when the override fails", async () => { + const { overrideNextConfig } = await importOverrides; + const originalConfig = `module.exports = { /* config options here */ }`; + + fs.writeFileSync(path.join(tmpDir, "next.config.cjs"), originalConfig); + + await assert.rejects( + async () => await overrideNextConfig(tmpDir, "next.config.cjs"), + /Unsupported file extension for Next Config/, + ); + + assert.equal(fs.readFileSync(path.join(tmpDir, "next.config.cjs"), "utf-8"), originalConfig); + assert.ok( + !fs.existsSync(path.join(tmpDir, "next.config.original.cjs")), + "No next.config.original.cjs backup should be left behind", + ); + }); + it("should not do anything if no next.config.* file exists", async () => { const { overrideNextConfig } = await importOverrides; await overrideNextConfig(tmpDir, "next.config.js"); diff --git a/packages/@apphosting/adapter-nextjs/src/overrides.ts b/packages/@apphosting/adapter-nextjs/src/overrides.ts index 5edd0d547..de6b0dc15 100644 --- a/packages/@apphosting/adapter-nextjs/src/overrides.ts +++ b/packages/@apphosting/adapter-nextjs/src/overrides.ts @@ -11,7 +11,7 @@ import { join, extname } from "path"; import { rename as renamePromise } from "fs/promises"; /** - * Overrides the user's Next Config file (next.config.[ts|js|mjs]) to add configs + * Overrides the user's Next Config file (next.config.[ts|mts|js|mjs]) to add configs * optimized for Firebase App Hosting. */ export async function overrideNextConfig(projectRoot: string, nextConfigFileName: string) { @@ -28,40 +28,49 @@ export async function overrideNextConfig(projectRoot: string, nextConfigFileName const fileExtension = extname(nextConfigFileName); const originalConfigName = `next.config.original${fileExtension}`; + // Create a new config file with the appropriate import. This is done before renaming + // the original config file so that an unsupported extension fails before we have + // modified anything in the user's project. + let importStatement; + switch (fileExtension) { + case ".js": + importStatement = `const originalConfig = require('./${originalConfigName}');`; + break; + case ".mjs": + case ".mts": + // Next.js loads next.config.mts with Node's type stripping, which resolves ES module + // specifiers as written, so ".mts" keeps its extension instead of dropping it as ".ts" does. + importStatement = `import originalConfig from './${originalConfigName}';`; + break; + case ".ts": + importStatement = `import originalConfig from './${originalConfigName.replace(".ts", "")}';`; + break; + default: + throw new Error( + `Unsupported file extension for Next Config: "${fileExtension}", please use ".js", ".mjs", ".ts", or ".mts"`, + ); + } + + // Create the new config content with our overrides + const newConfigContent = getCustomNextConfig(importStatement, fileExtension); + // Rename the original config file + let originalConfigRenamed = false; try { const originalPath = join(projectRoot, originalConfigName); await renamePromise(configPath, originalPath); - - // Create a new config file with the appropriate import - let importStatement; - switch (fileExtension) { - case ".js": - importStatement = `const originalConfig = require('./${originalConfigName}');`; - break; - case ".mjs": - importStatement = `import originalConfig from './${originalConfigName}';`; - break; - case ".ts": - importStatement = `import originalConfig from './${originalConfigName.replace( - ".ts", - "", - )}';`; - break; - default: - throw new Error( - `Unsupported file extension for Next Config: "${fileExtension}", please use ".js", ".mjs", or ".ts"`, - ); - } - - // Create the new config content with our overrides - const newConfigContent = getCustomNextConfig(importStatement, fileExtension); + originalConfigRenamed = true; // Write the new config file await writeFile(join(projectRoot, nextConfigFileName), newConfigContent); console.log(`Successfully created ${nextConfigFileName} with Firebase App Hosting overrides`); } catch (error) { console.error(`Error overriding Next.js config: ${error}`); + // Move the original config file back so that a failed override does not leave the + // app without a Next Config. + if (originalConfigRenamed) { + await restoreNextConfig(projectRoot, nextConfigFileName); + } throw error; } } @@ -73,7 +82,7 @@ export async function overrideNextConfig(projectRoot: string, nextConfigFileName * - images.unoptimized = true, unless user explicitly sets images.unoptimized to false or * is using a custom image loader. * @param importStatement The import statement for the original config. - * @param fileExtension The file extension of the original config. Use ".js", ".mjs", or ".ts" + * @param fileExtension The file extension of the original config. Use ".js", ".mjs", ".ts", or ".mts" * @return The custom Next.js config. */ function getCustomNextConfig(importStatement: string, fileExtension: string) { @@ -99,7 +108,9 @@ function getCustomNextConfig(importStatement: string, fileExtension: string) { } : fahOptimizedConfig(originalConfig); - ${fileExtension === ".mjs" ? "export default config;" : "module.exports = config;"} + ${ + [".mjs", ".mts"].includes(fileExtension) ? "export default config;" : "module.exports = config;" + } `; } @@ -143,7 +154,7 @@ export async function validateNextConfigOverride( } /** - * Restores the user's original Next Config file (next.config.original.[ts|js|mjs]) + * Restores the user's original Next Config file (next.config.original.[ts|mts|js|mjs]) * to leave user code the way we found it. */ export async function restoreNextConfig(projectRoot: string, nextConfigFileName: string) {