diff --git a/errors/invalid-next-config.md b/errors/invalid-next-config.md
new file mode 100644
index 000000000000..ffe6a08e491c
--- /dev/null
+++ b/errors/invalid-next-config.md
@@ -0,0 +1,24 @@
+# Invalid next.config.js
+
+#### Why This Error Occurred
+
+In your `next.config.js` file you passed invalid options that either are the incorrect type or an unknown field.
+
+#### Possible Ways to Fix It
+
+Fixing the listed config errors will remove this warning. You can also leverage the `NextConfig` type by importing from `next` to help ensure your config is correct.
+
+```ts
+/**
+ * @type {import('next').NextConfig}
+ */
+const nextConfig = {
+ /* config options here */
+}
+
+module.exports = nextConfig
+```
+
+### Useful Links
+
+- [`next.config.js`](https://nextjs.org/docs/api-reference/next.config.js/introduction)
diff --git a/errors/manifest.json b/errors/manifest.json
index 5f2f48eb5fee..9bc09eebbd1b 100644
--- a/errors/manifest.json
+++ b/errors/manifest.json
@@ -711,6 +711,10 @@
{
"title": "node-module-in-edge-runtime",
"path": "/errors/node-module-in-edge-runtime.md"
+ },
+ {
+ "title": "invalid-next-config",
+ "path": "/errors/invalid-next-config.md"
}
]
}
diff --git a/packages/next/index.d.ts b/packages/next/index.d.ts
index 6a660719f6dd..dd922052fa49 100644
--- a/packages/next/index.d.ts
+++ b/packages/next/index.d.ts
@@ -1,4 +1,5 @@
///
+///
///
///
///
@@ -11,7 +12,6 @@
///
///
///
-///
export { default } from './types'
export * from './types'
diff --git a/packages/next/package.json b/packages/next/package.json
index e1cc47d896e8..9a40efa79002 100644
--- a/packages/next/package.json
+++ b/packages/next/package.json
@@ -50,6 +50,7 @@
"index.d.ts",
"types/index.d.ts",
"types/global.d.ts",
+ "types/compiled.d.ts",
"image-types/global.d.ts"
],
"bin": {
@@ -168,6 +169,7 @@
"@vercel/ncc": "0.33.4",
"@vercel/nft": "0.20.0",
"acorn": "8.5.0",
+ "ajv": "8.11.0",
"amphtml-validator": "1.0.35",
"arg": "4.1.0",
"assert": "2.0.0",
diff --git a/packages/next/server/config-schema.ts b/packages/next/server/config-schema.ts
new file mode 100644
index 000000000000..f9d18748f444
--- /dev/null
+++ b/packages/next/server/config-schema.ts
@@ -0,0 +1,609 @@
+import { NextConfig } from './config'
+import type { JSONSchemaType } from 'ajv'
+import { VALID_LOADERS } from '../shared/lib/image-config'
+
+const configSchema = {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ amp: {
+ additionalProperties: false,
+ properties: {
+ canonicalBase: {
+ minLength: 1,
+ type: 'string',
+ },
+ },
+ type: 'object',
+ },
+ assetPrefix: {
+ minLength: 1,
+ type: 'string',
+ },
+ basePath: {
+ minLength: 1,
+ type: 'string',
+ },
+ cleanDistDir: {
+ type: 'boolean',
+ },
+ compiler: {
+ additionalProperties: false,
+ properties: {
+ emotion: {
+ oneOf: [
+ {
+ type: 'boolean',
+ },
+ {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ sourceMap: {
+ type: 'boolean',
+ },
+ autoLabel: {
+ type: 'string',
+ enum: ['always', 'dev-only', 'never'],
+ },
+ labelFormat: {
+ type: 'string',
+ minLength: 1,
+ },
+ },
+ },
+ ] as any,
+ },
+ reactRemoveProperties: {
+ oneOf: [
+ {
+ type: 'boolean',
+ },
+ {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ properties: {
+ type: 'array',
+ items: {
+ type: 'string',
+ },
+ },
+ },
+ },
+ ] as any,
+ },
+ relay: {
+ additionalProperties: false,
+ properties: {
+ artifactDirectory: {
+ minLength: 1,
+ type: 'string',
+ },
+ language: {
+ // automatic typing doesn't like enum
+ enum: ['flow', 'typescript'] as any,
+ type: 'string',
+ },
+ src: {
+ minLength: 1,
+ type: 'string',
+ },
+ },
+ type: 'object',
+ },
+ removeConsole: {
+ oneOf: [
+ {
+ type: 'boolean',
+ },
+ {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ exclude: {
+ type: 'array',
+ items: {
+ type: 'string',
+ minLength: 1,
+ },
+ },
+ },
+ },
+ ] as any,
+ },
+ styledComponents: {
+ oneOf: [
+ {
+ type: 'boolean',
+ },
+ {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ displayName: {
+ type: 'boolean',
+ },
+ topLevelImportPaths: {
+ type: 'array',
+ items: {
+ type: 'string',
+ minLength: 1,
+ },
+ },
+ ssr: {
+ type: 'boolean',
+ },
+ fileName: {
+ type: 'boolean',
+ },
+ meaninglessFileNames: {
+ type: 'boolean',
+ },
+ minify: {
+ type: 'boolean',
+ },
+ transpileTemplateLiterals: {
+ type: 'boolean',
+ },
+ namespace: {
+ type: 'string',
+ minLength: 1,
+ },
+ pure: {
+ type: 'boolean',
+ },
+ cssProp: {
+ type: 'boolean',
+ },
+ },
+ },
+ ] as any,
+ },
+ },
+ type: 'object',
+ },
+ compress: {
+ type: 'boolean',
+ },
+ crossOrigin: {
+ oneOf: [
+ false,
+ {
+ enum: ['anonymous', 'use-credentials'],
+ type: 'string',
+ },
+ ], // automatic typing does not like enum
+ } as any,
+ devIndicators: {
+ additionalProperties: false,
+ properties: {
+ buildActivity: {
+ type: 'boolean',
+ },
+ buildActivityPosition: {
+ // automatic typing does not like enum
+ enum: ['bottom-left', 'bottom-right', 'top-left', 'top-right'] as any,
+ type: 'string',
+ },
+ },
+ type: 'object',
+ },
+ distDir: {
+ minLength: 1,
+ type: 'string',
+ nullable: true,
+ },
+ env: {
+ type: 'object',
+ },
+ eslint: {
+ additionalProperties: false,
+ properties: {
+ dirs: {
+ items: {
+ minLength: 1,
+ type: 'string',
+ },
+ type: 'array',
+ },
+ ignoreDuringBuilds: {
+ type: 'boolean',
+ },
+ },
+ type: 'object',
+ },
+ excludeDefaultMomentLocales: {
+ type: 'boolean',
+ },
+ experimental: {
+ additionalProperties: false,
+ properties: {
+ amp: {
+ additionalProperties: false,
+ properties: {
+ optimizer: {
+ type: 'object',
+ },
+ skipValidation: {
+ type: 'boolean',
+ },
+ validator: {
+ type: 'string',
+ },
+ },
+ type: 'object',
+ },
+ appDir: {
+ type: 'boolean',
+ },
+ browsersListForSwc: {
+ type: 'boolean',
+ },
+ cpus: {
+ type: 'number',
+ },
+ craCompat: {
+ type: 'boolean',
+ },
+ disableOptimizedLoading: {
+ type: 'boolean',
+ },
+ disablePostcssPresetEnv: {
+ type: 'boolean',
+ },
+ esmExternals: {
+ type: 'boolean',
+ },
+ externalDir: {
+ type: 'boolean',
+ },
+ forceSwcTransforms: {
+ type: 'boolean',
+ },
+ fullySpecified: {
+ type: 'boolean',
+ },
+ gzipSize: {
+ type: 'boolean',
+ },
+ images: {
+ additionalProperties: false,
+ properties: {
+ allowFutureImage: {
+ type: 'boolean',
+ },
+ remotePatterns: {
+ items: {
+ additionalProperties: false,
+ properties: {
+ hostname: {
+ minLength: 1,
+ type: 'string',
+ },
+ pathname: {
+ minLength: 1,
+ type: 'string',
+ },
+ port: {
+ minLength: 1,
+ type: 'string',
+ },
+ protocol: {
+ // automatic typing doesn't like enum
+ enum: ['http', 'https'] as any,
+ type: 'string',
+ },
+ },
+ type: 'object',
+ },
+ type: 'array',
+ },
+ unoptimized: {
+ type: 'boolean',
+ },
+ },
+ type: 'object',
+ },
+ incrementalCacheHandlerPath: {
+ type: 'string',
+ },
+ isrFlushToDisk: {
+ type: 'boolean',
+ },
+ isrMemoryCacheSize: {
+ type: 'number',
+ },
+ largePageDataBytes: {
+ type: 'number',
+ },
+ legacyBrowsers: {
+ type: 'boolean',
+ },
+ manualClientBasePath: {
+ type: 'boolean',
+ },
+ modularizeImports: {
+ type: 'object',
+ },
+ newNextLinkBehavior: {
+ type: 'boolean',
+ },
+ nextScriptWorkers: {
+ type: 'boolean',
+ },
+ optimizeCss: {
+ type: 'boolean',
+ },
+ outputFileTracingRoot: {
+ minLength: 1,
+ type: 'string',
+ },
+ pageEnv: {
+ type: 'boolean',
+ },
+ profiling: {
+ type: 'boolean',
+ },
+ runtime: {
+ // automatic typing doesn't like enum
+ enum: ['experimental-edge', 'nodejs'] as any,
+ type: 'string',
+ },
+ scrollRestoration: {
+ type: 'boolean',
+ },
+ serverComponents: {
+ type: 'boolean',
+ },
+ sharedPool: {
+ type: 'boolean',
+ },
+ swcFileReading: {
+ type: 'boolean',
+ },
+ swcMinify: {
+ type: 'boolean',
+ },
+ swcMinifyDebugOptions: {
+ additionalProperties: false,
+ properties: {
+ compress: {
+ type: 'object',
+ },
+ mangle: {
+ type: 'object',
+ },
+ },
+ type: 'object',
+ },
+ swcPlugins: {
+ type: 'array',
+ },
+ swcTraceProfiling: {
+ type: 'boolean',
+ },
+ urlImports: {
+ items: {
+ type: 'string',
+ },
+ type: 'array',
+ },
+ workerThreads: {
+ type: 'boolean',
+ },
+ },
+ type: 'object',
+ },
+ future: {
+ additionalProperties: false,
+ properties: {},
+ type: 'object',
+ },
+ generateBuildId: {
+ isFunction: true,
+ } as any,
+ generateEtags: {
+ isFunction: true,
+ } as any,
+ headers: {
+ isFunction: true,
+ } as any,
+ httpAgentOptions: {
+ additionalProperties: false,
+ properties: {
+ keepAlive: {
+ type: 'boolean',
+ },
+ },
+ type: 'object',
+ },
+ i18n: {
+ additionalProperties: false,
+ properties: {
+ defaultLocale: {
+ minLength: 1,
+ type: 'string',
+ },
+ domains: {
+ items: {
+ additionalProperties: false,
+ properties: {
+ defaultLocale: {
+ minLength: 1,
+ type: 'string',
+ },
+ domain: {
+ minLength: 1,
+ type: 'string',
+ },
+ http: {
+ type: 'boolean',
+ },
+ locales: {
+ items: {
+ minLength: 1,
+ type: 'string',
+ },
+ type: 'array',
+ },
+ },
+ type: 'object',
+ },
+ type: 'array',
+ },
+ localeDetection: {
+ type: 'boolean',
+ },
+ locales: {
+ items: {
+ minLength: 1,
+ type: 'string',
+ },
+ type: 'array',
+ },
+ },
+ type: 'object',
+ },
+ images: {
+ additionalProperties: false,
+ properties: {
+ contentSecurityPolicy: {
+ minLength: 1,
+ type: 'string',
+ },
+ dangerouslyAllowSVG: {
+ type: 'boolean',
+ },
+ deviceSizes: {
+ items: {
+ type: 'number',
+ },
+ minItems: 1,
+ type: 'array',
+ },
+ disableStaticImages: {
+ type: 'boolean',
+ },
+ domains: {
+ items: {
+ type: 'string',
+ },
+ type: 'array',
+ },
+ formats: {
+ items: {
+ enum: ['image/avif', 'image/webp'], // automatic typing does not like enum
+ type: 'string',
+ } as any,
+ type: 'array',
+ },
+ imageSizes: {
+ items: {
+ type: 'number',
+ },
+ minItems: 1,
+ type: 'array',
+ },
+ loader: {
+ // automatic typing does not like enum
+ enum: VALID_LOADERS as any,
+ type: 'string',
+ },
+ minimumCacheTTL: {
+ type: 'number',
+ },
+ path: {
+ minLength: 1,
+ type: 'string',
+ },
+ },
+ type: 'object',
+ },
+ onDemandEntries: {
+ additionalProperties: false,
+ properties: {
+ maxInactiveAge: {
+ type: 'number',
+ },
+ pagesBufferLength: {
+ type: 'number',
+ },
+ },
+ type: 'object',
+ },
+ optimizeFonts: {
+ type: 'boolean',
+ },
+ output: {
+ // automatic typing doesn't like enum
+ enum: ['standalone'] as any,
+ type: 'string',
+ },
+ outputFileTracing: {
+ type: 'boolean',
+ },
+ pageExtensions: {
+ minItems: 1,
+ type: 'array',
+ },
+ poweredByHeader: {
+ type: 'boolean',
+ },
+ productionBrowserSourceMaps: {
+ type: 'boolean',
+ },
+ publicRuntimeConfig: {
+ type: 'object',
+ },
+ reactStrictMode: {
+ type: 'boolean',
+ },
+ redirects: {
+ isFunction: true,
+ } as any,
+ rewrites: {
+ isFunction: true,
+ } as any,
+ sassOptions: {
+ type: 'object',
+ },
+ serverRuntimeConfig: {
+ type: 'object',
+ },
+ staticPageGenerationTimeout: {
+ type: 'number',
+ },
+ swcMinify: {
+ type: 'boolean',
+ },
+ trailingSlash: {
+ type: 'boolean',
+ },
+ typescript: {
+ additionalProperties: false,
+ properties: {
+ ignoreBuildErrors: {
+ type: 'boolean',
+ },
+ tsconfigPath: {
+ minLength: 1,
+ type: 'string',
+ },
+ },
+ type: 'object',
+ },
+ useFileSystemPublicRoutes: {
+ type: 'boolean',
+ },
+ webpack: {
+ isFunction: true,
+ } as any,
+ },
+} as JSONSchemaType
+
+// module.exports is used to get around an export bug with TypeScript
+// and the Ajv automatic typing
+module.exports = {
+ configSchema,
+}
diff --git a/packages/next/server/config-shared.ts b/packages/next/server/config-shared.ts
index 3ca8aa5e75f4..a1dbbb5110e0 100644
--- a/packages/next/server/config-shared.ts
+++ b/packages/next/server/config-shared.ts
@@ -566,3 +566,13 @@ export function isServerRuntime(value?: string): value is ServerRuntime {
value === undefined || value === 'nodejs' || value === 'experimental-edge'
)
}
+
+export function validateConfig(userConfig: NextConfig): {
+ errors?: Array | null
+} {
+ const configValidator = require('next/dist/next-config-validate.js')
+ configValidator(userConfig)
+ return {
+ errors: configValidator.errors,
+ }
+}
diff --git a/packages/next/server/config.ts b/packages/next/server/config.ts
index 181282c00b2a..b982bbf20a62 100644
--- a/packages/next/server/config.ts
+++ b/packages/next/server/config.ts
@@ -12,6 +12,7 @@ import {
normalizeConfig,
ExperimentalConfig,
NextConfigComplete,
+ validateConfig,
} from './config-shared'
import { loadWebpackHook } from './config-utils'
import {
@@ -44,23 +45,6 @@ const experimentalWarning = execOnce(
}
)
-const missingExperimentalWarning = execOnce(
- (configFileName: string, features: string[]) => {
- const s = features.length > 1 ? 's' : ''
- const dont = features.length > 1 ? 'do not' : 'does not'
- const them = features.length > 1 ? 'them' : 'it'
- Log.warn(
- chalk.bold(
- `You have defined experimental feature${s} (${features.join(
- ', '
- )}) in ${configFileName} that ${dont} exist in this version of Next.js.`
- )
- )
- Log.warn(`Please remove ${them} from your configuration.`)
- console.warn()
- }
-)
-
function assignDefaults(userConfig: { [key: string]: any }) {
const configFileName = userConfig.configFileName
if (typeof userConfig.exportTrailingSlash !== 'undefined') {
@@ -83,7 +67,6 @@ function assignDefaults(userConfig: { [key: string]: any }) {
}
if (key === 'experimental' && typeof value === 'object') {
- const enabledMissingExperiments: string[] = []
const enabledExperiments: (keyof ExperimentalConfig)[] = []
// defaultConfig.experimental is predefined and will never be undefined
@@ -92,9 +75,7 @@ function assignDefaults(userConfig: { [key: string]: any }) {
for (const featureName of Object.keys(
value
) as (keyof ExperimentalConfig)[]) {
- if (!(featureName in defaultConfig.experimental)) {
- enabledMissingExperiments.push(featureName)
- } else if (
+ if (
value[featureName] !== defaultConfig.experimental[featureName]
) {
enabledExperiments.push(featureName)
@@ -102,9 +83,6 @@ function assignDefaults(userConfig: { [key: string]: any }) {
}
}
- if (enabledMissingExperiments.length > 0) {
- missingExperimentalWarning(configFileName, enabledMissingExperiments)
- }
if (enabledExperiments.length > 0) {
experimentalWarning(configFileName, enabledExperiments)
}
@@ -796,6 +774,16 @@ export default async function loadConfig(
userConfigModule.default || userConfigModule
)
+ const validateResult = validateConfig(userConfig)
+
+ if (validateResult.errors) {
+ Log.warn(`Invalid next.config.js options detected: `)
+ console.error(
+ JSON.stringify(validateResult.errors, null, 2),
+ '\nSee more info here: https://nextjs.org/docs/messages/invalid-next-config'
+ )
+ }
+
if (Object.keys(userConfig).length === 0) {
Log.warn(
`Detected ${configFileName}, no exported configuration found. https://nextjs.org/docs/messages/empty-configuration`
diff --git a/packages/next/taskfile.js b/packages/next/taskfile.js
index 785ef9efcf37..cd2ec074c83b 100644
--- a/packages/next/taskfile.js
+++ b/packages/next/taskfile.js
@@ -176,6 +176,47 @@ export async function ncc_node_fetch(task, opts) {
.target('compiled/node-fetch')
}
+// eslint-disable-next-line camelcase
+export async function compile_config_schema(task, opts) {
+ const { configSchema } = require('./dist/server/config-schema')
+ // eslint-disable-next-line
+ const Ajv = require('ajv')
+ // eslint-disable-next-line
+ const standaloneCode = require('ajv/dist/standalone').default
+ // eslint-disable-next-line
+ const ajv = new Ajv({ code: { source: true }, allErrors: true })
+ ajv.addKeyword({
+ keyword: 'isFunction',
+ schemaType: 'boolean',
+ compile() {
+ return (data) => data instanceof Function
+ },
+ code(ctx) {
+ const { data } = ctx
+ ctx.fail(Ajv._`!(${data} instanceof Function)`)
+ },
+ metaSchema: {
+ anyOf: [{ type: 'boolean' }],
+ },
+ })
+
+ const compiled = ajv.compile(configSchema)
+ const validateCode = standaloneCode(ajv, compiled)
+ const preNccFilename = join(__dirname, 'dist', 'next-config-validate.js')
+ await fs.writeFile(preNccFilename, validateCode)
+ await task
+ .source(opts.src || './dist/next-config-validate.js')
+ .ncc({})
+ .target('dist/next-config-validate')
+
+ await fs.unlink(preNccFilename)
+ await fs.rename(
+ join(__dirname, 'dist/next-config-validate/next-config-validate.js'),
+ join(__dirname, 'dist/next-config-validate.js')
+ )
+ await fs.rmdir(join(__dirname, 'dist/next-config-validate'))
+}
+
// eslint-disable-next-line camelcase
externals['acorn'] = 'next/dist/compiled/acorn'
export async function ncc_acorn(task, opts) {
@@ -1953,7 +1994,7 @@ export async function trace(task, opts) {
}
export async function build(task, opts) {
- await task.serial(['precompile', 'compile'], opts)
+ await task.serial(['precompile', 'compile', 'compile_config_schema'], opts)
}
export default async function (task) {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 42f1a50a6c1c..27f0db91bdf2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -468,6 +468,7 @@ importers:
'@vercel/ncc': 0.33.4
'@vercel/nft': 0.20.0
acorn: 8.5.0
+ ajv: 8.11.0
amphtml-validator: 1.0.35
arg: 4.1.0
assert: 2.0.0
@@ -660,6 +661,7 @@ importers:
'@vercel/ncc': 0.33.4
'@vercel/nft': 0.20.0
acorn: 8.5.0
+ ajv: 8.11.0
amphtml-validator: 1.0.35
arg: 4.1.0
assert: 2.0.0
@@ -6685,6 +6687,15 @@ packages:
require-from-string: 2.0.2
uri-js: 4.2.2
+ /ajv/8.11.0:
+ resolution: {integrity: sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==}
+ dependencies:
+ fast-deep-equal: 3.1.3
+ json-schema-traverse: 1.0.0
+ require-from-string: 2.0.2
+ uri-js: 4.2.2
+ dev: true
+
/alex/9.1.0:
resolution: {integrity: sha512-mlNQ0CBGinzZj1pjiXaSLsihjZ4Kzq0U0EjR+DrZ3IQQfM4pf4OtxHI1agBIiEwv0tQUzimjgTk+5t9iHeT7Vw==}
hasBin: true
@@ -21921,7 +21932,7 @@ packages:
dev: true
/unset-value/1.0.0:
- resolution: {integrity: sha1-g3aHP30jNRef+x5vw6jtDfyKtVk=}
+ resolution: {integrity: sha512-PcA2tsuGSF9cnySLHTLSh2qrQiJ70mn+r+Glzxv2TWZblxsxCC52BDlZoPCsz7STd9pN7EZetkWZBAvk4cgZdQ==}
engines: {node: '>=0.10.0'}
requiresBuild: true
dependencies:
@@ -22001,7 +22012,7 @@ packages:
punycode: 2.1.1
/urix/0.1.0:
- resolution: {integrity: sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=}
+ resolution: {integrity: sha512-Am1ousAhSLBeB9cG/7k7r2R0zj50uDRlZHPGbazid5s9rlF1F/QKYObEKSIunSjIOkJZqwRRLpvewjEkM7pSqg==}
deprecated: Please see https://github.com/lydell/urix#deprecated
requiresBuild: true
diff --git a/test/integration/config-experimental-warning/test/index.test.js b/test/integration/config-experimental-warning/test/index.test.js
index 151684dfb9ee..1f63e260e5c4 100644
--- a/test/integration/config-experimental-warning/test/index.test.js
+++ b/test/integration/config-experimental-warning/test/index.test.js
@@ -113,50 +113,4 @@ describe('Config Experimental Warning', () => {
'You have enabled experimental feature (newNextLinkBehavior) in next.config.mjs.'
)
})
-
- it('should show warning with next.config.js from object with non-exist experimental', async () => {
- configFile.write(`
- const config = {
- experimental: {
- foo: true
- }
- }
- module.exports = config
- `)
- const { stderr } = await nextBuild(appDir, [], { stderr: true })
- expect(stderr).toMatch(
- 'You have defined experimental feature (foo) in next.config.js that does not exist in this version'
- )
- })
-
- it('should show warning with next.config.mjs from object with non-exist experimental', async () => {
- configFileMjs.write(`
- const config = {
- experimental: {
- foo: true
- }
- }
- export default config
- `)
- const { stderr } = await nextBuild(appDir, [], { stderr: true })
- expect(stderr).toMatch(
- 'You have defined experimental feature (foo) in next.config.mjs that does not exist in this version'
- )
- })
-
- it('should show warning with next.config.js from object with multiple non-exist experimental', async () => {
- configFile.write(`
- const config = {
- experimental: {
- foo: true,
- bar: false
- }
- }
- module.exports = config
- `)
- const { stderr } = await nextBuild(appDir, [], { stderr: true })
- expect(stderr).toMatch(
- 'You have defined experimental features (foo, bar) in next.config.js that do not exist in this version'
- )
- })
})
diff --git a/test/integration/config-validation/pages/index.js b/test/integration/config-validation/pages/index.js
new file mode 100644
index 000000000000..08263e34c35f
--- /dev/null
+++ b/test/integration/config-validation/pages/index.js
@@ -0,0 +1,3 @@
+export default function Page() {
+ return index page
+}
diff --git a/test/integration/config-validation/test/index.test.ts b/test/integration/config-validation/test/index.test.ts
new file mode 100644
index 000000000000..276998f9a9f1
--- /dev/null
+++ b/test/integration/config-validation/test/index.test.ts
@@ -0,0 +1,62 @@
+import path from 'path'
+import { nextBuild } from 'next-test-utils'
+import fs from 'fs-extra'
+
+const nextConfigPath = path.join(__dirname, '../next.config.js')
+
+describe('next.config.js validation', () => {
+ it.each([
+ {
+ name: 'invalid config types',
+ configContent: `
+ module.exports = {
+ swcMinify: 'hello',
+ rewrites: true,
+ images: {
+ loader: 'something'
+ }
+ }
+ `,
+ outputs: [
+ '/images/loader',
+ 'must be equal to one of the allowed values',
+ 'imgix',
+ '/rewrites',
+ 'must pass \\"isFunction\\" keyword validation',
+ '/swcMinify',
+ 'must be boolean',
+ ],
+ },
+ {
+ name: 'unexpected config fields',
+ configContent: `
+ module.exports = {
+ nonExistent: true,
+ experimental: {
+ anotherNonExistent: true
+ }
+ }
+ `,
+ outputs: [
+ 'nonExistent',
+ 'must NOT have additional properties',
+ 'anotherNonExistent',
+ 'must NOT have additional properties',
+ ],
+ },
+ ])(
+ 'it should validate correctly for $name',
+ async ({ outputs, configContent }) => {
+ await fs.writeFile(nextConfigPath, configContent)
+ const result = await nextBuild(path.join(__dirname, '../'), undefined, {
+ stderr: true,
+ stdout: true,
+ })
+ await fs.remove(nextConfigPath)
+
+ for (const output of outputs) {
+ expect(result.stdout + result.stderr).toContain(output)
+ }
+ }
+ )
+})