feat: change next build
to emit output with output: export
(#47376)
This PR ensures the correct output is emitted during `next build` and deprecates `next export`. The `output: export` configuration tells it to emit exported html and the `distDir: out` configures the output directory. ```js module.exports = { output: 'export', distDir: 'out', } ``` fix NEXT-868 ([link](https://linear.app/vercel/issue/NEXT-868)) --------- Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
parent
394bff5fd0
commit
9791d1e608
6 changed files with 240 additions and 69 deletions
|
@ -17,9 +17,9 @@ The core of Next.js has been designed to enable starting as a static site (or Si
|
|||
|
||||
Since Next.js supports this static export, it can be deployed and hosted on any web server that can serve HTML/CSS/JS static assets.
|
||||
|
||||
## `next export`
|
||||
## Usage
|
||||
|
||||
Update your `next.config.js` file to include `output: "export"` like the following:
|
||||
Update your [`next.config.js`](/docs/api-reference/next.config.js/introduction.md) file to include `output: 'export'` like the following:
|
||||
|
||||
```js
|
||||
/**
|
||||
|
@ -32,21 +32,45 @@ const nextConfig = {
|
|||
module.exports = nextConfig
|
||||
```
|
||||
|
||||
Update your scripts in `package.json` file to include `next export` like the following:
|
||||
Then run `next build` to generate an `out` directory containing the HTML/CSS/JS static assets.
|
||||
|
||||
```json
|
||||
"scripts": {
|
||||
"build": "next build && next export"
|
||||
You can utilize [`getStaticProps`](/docs/basic-features/data-fetching/get-static-props.md) and [`getStaticPaths`](/docs/basic-features/data-fetching/get-static-paths.md) to generate an HTML file for each page in your `pages` directory (or more for [dynamic routes](/docs/routing/dynamic-routes.md)).
|
||||
|
||||
If you want to change the output directory, you can configure `distDir` like the following:
|
||||
|
||||
```js
|
||||
/**
|
||||
* @type {import('next').NextConfig}
|
||||
*/
|
||||
const nextConfig = {
|
||||
output: 'export',
|
||||
distDir: 'dist',
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
```
|
||||
|
||||
Running `npm run build` will generate an `out` directory.
|
||||
In this example, `next build` will generate a `dist` directory containing the HTML/CSS/JS static assets.
|
||||
|
||||
`next export` builds an HTML version of your app. During `next build`, [`getStaticProps`](/docs/basic-features/data-fetching/get-static-props.md) and [`getStaticPaths`](/docs/basic-features/data-fetching/get-static-paths.md) will generate an HTML file for each page in your `pages` directory (or more for [dynamic routes](/docs/routing/dynamic-routes.md)). Then, `next export` will copy the already exported files into the correct directory. `getInitialProps` will generate the HTML files during `next export` instead of `next build`.
|
||||
Learn more about [Setting a custom build directory](/docs/api-reference/next.config.js/setting-a-custom-build-directory.md).
|
||||
|
||||
For more advanced scenarios, you can define a parameter called [`exportPathMap`](/docs/api-reference/next.config.js/exportPathMap.md) in your [`next.config.js`](/docs/api-reference/next.config.js/introduction.md) file to configure exactly which pages will be generated.
|
||||
If you want to change the output directory structure to always include a trailing slash, you can configure `trailingSlash` like the following:
|
||||
|
||||
> **Warning**: Using `exportPathMap` is deprecated and is overridden by `getStaticPaths` inside `pages`. We recommend not to use them together.
|
||||
```js
|
||||
/**
|
||||
* @type {import('next').NextConfig}
|
||||
*/
|
||||
const nextConfig = {
|
||||
output: 'export',
|
||||
trailingSlash: true,
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
```
|
||||
|
||||
This will change links so that `href="/about"` will instead be `herf="/about/"`. It will also change the output so that `out/about.html` will instead emit `out/about/index.html`.
|
||||
|
||||
Learn more about [Trailing Slash](/docs/api-reference/next.config.js/trailing-slash.md).
|
||||
|
||||
## Supported Features
|
||||
|
||||
|
@ -88,3 +112,23 @@ It's possible to use the [`getInitialProps`](/docs/api-reference/data-fetching/g
|
|||
- `getInitialProps` should fetch from an API and cannot use Node.js-specific libraries or the file system like `getStaticProps` can.
|
||||
|
||||
We recommend migrating towards `getStaticProps` over `getInitialProps` whenever possible.
|
||||
|
||||
## next export
|
||||
|
||||
> **Warning**: "next export" is deprecated since Next.js 13.3 in favor of "output: 'export'" configuration.
|
||||
|
||||
In versions of Next.js prior to 13.3, there was no configuration option in next.config.js and instead there was a separate command for `next export`.
|
||||
|
||||
This could be used by updating your `package.json` file to include `next export` like the following:
|
||||
|
||||
```json
|
||||
"scripts": {
|
||||
"build": "next build && next export"
|
||||
}
|
||||
```
|
||||
|
||||
Running `npm run build` will generate an `out` directory.
|
||||
|
||||
`next export` builds an HTML version of your app. During `next build`, [`getStaticProps`](/docs/basic-features/data-fetching/get-static-props.md) and [`getStaticPaths`](/docs/basic-features/data-fetching/get-static-paths.md) will generate an HTML file for each page in your `pages` directory (or more for [dynamic routes](/docs/routing/dynamic-routes.md)). Then, `next export` will copy the already exported files into the correct directory. `getInitialProps` will generate the HTML files during `next export` instead of `next build`.
|
||||
|
||||
> **Warning**: Using [`exportPathMap`](/docs/api-reference/next.config.js/exportPathMap.md) is deprecated and is overridden by `getStaticPaths` inside `pages`. We recommend not to use them together.
|
||||
|
|
|
@ -282,6 +282,16 @@ export default async function build(
|
|||
.traceAsyncFn(() => loadConfig(PHASE_PRODUCTION_BUILD, dir))
|
||||
NextBuildContext.config = config
|
||||
|
||||
let configOutDir = 'out'
|
||||
if (config.output === 'export' && config.distDir !== '.next') {
|
||||
// In the past, a user had to run "next build" to generate
|
||||
// ".next" (or whatever the distDir) followed by "next export"
|
||||
// to generate "out" (or whatever the outDir). However, when
|
||||
// "output: export" is configured, "next build" does both steps.
|
||||
// So the user-configured dirDir is actually the outDir.
|
||||
configOutDir = config.distDir
|
||||
config.distDir = '.next'
|
||||
}
|
||||
const distDir = path.join(dir, config.distDir)
|
||||
setGlobal('phase', PHASE_PRODUCTION_BUILD)
|
||||
setGlobal('distDir', distDir)
|
||||
|
@ -2314,24 +2324,7 @@ export default async function build(
|
|||
)
|
||||
const exportApp: typeof import('../export').default =
|
||||
require('../export').default
|
||||
const exportOptions: ExportOptions = {
|
||||
silent: false,
|
||||
buildExport: true,
|
||||
debugOutput,
|
||||
threads: config.experimental.cpus,
|
||||
pages: combinedPages,
|
||||
outdir: path.join(distDir, 'export'),
|
||||
statusMessage: 'Generating static pages',
|
||||
exportPageWorker: sharedPool
|
||||
? staticWorkers.exportPage.bind(staticWorkers)
|
||||
: undefined,
|
||||
endWorker: sharedPool
|
||||
? async () => {
|
||||
await staticWorkers.end()
|
||||
}
|
||||
: undefined,
|
||||
appPaths,
|
||||
}
|
||||
|
||||
const exportConfig: NextConfigComplete = {
|
||||
...config,
|
||||
initialPageRevalidationMap: {},
|
||||
|
@ -2451,7 +2444,28 @@ export default async function build(
|
|||
},
|
||||
}
|
||||
|
||||
await exportApp(dir, exportOptions, nextBuildSpan, exportConfig)
|
||||
const exportOptions: ExportOptions = {
|
||||
isInvokedFromCli: false,
|
||||
nextConfig: exportConfig,
|
||||
silent: false,
|
||||
buildExport: true,
|
||||
debugOutput,
|
||||
threads: config.experimental.cpus,
|
||||
pages: combinedPages,
|
||||
outdir: path.join(distDir, 'export'),
|
||||
statusMessage: 'Generating static pages',
|
||||
exportPageWorker: sharedPool
|
||||
? staticWorkers.exportPage.bind(staticWorkers)
|
||||
: undefined,
|
||||
endWorker: sharedPool
|
||||
? async () => {
|
||||
await staticWorkers.end()
|
||||
}
|
||||
: undefined,
|
||||
appPaths,
|
||||
}
|
||||
|
||||
await exportApp(dir, exportOptions, nextBuildSpan)
|
||||
|
||||
const postBuildSpinner = createSpinner({
|
||||
prefixText: `${Log.prefixes.info} Finalizing page optimization`,
|
||||
|
@ -3099,6 +3113,19 @@ export default async function build(
|
|||
})
|
||||
}
|
||||
|
||||
if (config.output === 'export') {
|
||||
const exportApp: typeof import('../export').default =
|
||||
require('../export').default
|
||||
const options: ExportOptions = {
|
||||
isInvokedFromCli: false,
|
||||
nextConfig: config,
|
||||
silent: true,
|
||||
threads: config.experimental.cpus,
|
||||
outdir: path.join(dir, configOutDir),
|
||||
}
|
||||
await exportApp(dir, options, nextBuildSpan)
|
||||
}
|
||||
|
||||
await nextBuildSpan
|
||||
.traceChild('telemetry-flush')
|
||||
.traceAsyncFn(() => telemetry.flush())
|
||||
|
|
|
@ -62,6 +62,7 @@ const nextExport: CliCommand = (argv) => {
|
|||
silent: args['--silent'] || false,
|
||||
threads: args['--threads'],
|
||||
outdir: args['--outdir'] ? resolve(args['--outdir']) : join(dir, 'out'),
|
||||
isInvokedFromCli: true,
|
||||
}
|
||||
|
||||
exportApp(dir, options, nextExportCliSpan)
|
||||
|
|
|
@ -145,6 +145,7 @@ const createProgress = (total: number, label: string) => {
|
|||
|
||||
export interface ExportOptions {
|
||||
outdir: string
|
||||
isInvokedFromCli: boolean
|
||||
silent?: boolean
|
||||
threads?: number
|
||||
debugOutput?: boolean
|
||||
|
@ -154,13 +155,13 @@ export interface ExportOptions {
|
|||
exportPageWorker?: typeof import('./worker').default
|
||||
endWorker?: () => Promise<void>
|
||||
appPaths?: string[]
|
||||
nextConfig?: NextConfigComplete
|
||||
}
|
||||
|
||||
export default async function exportApp(
|
||||
dir: string,
|
||||
options: ExportOptions,
|
||||
span: Span,
|
||||
configuration?: NextConfigComplete
|
||||
span: Span
|
||||
): Promise<void> {
|
||||
const nextExportSpan = span.traceChild('next-export')
|
||||
const hasAppDir = !!options.appPaths
|
||||
|
@ -174,10 +175,24 @@ export default async function exportApp(
|
|||
.traceFn(() => loadEnvConfig(dir, false, Log))
|
||||
|
||||
const nextConfig =
|
||||
configuration ||
|
||||
options.nextConfig ||
|
||||
(await nextExportSpan
|
||||
.traceChild('load-next-config')
|
||||
.traceAsyncFn(() => loadConfig(PHASE_EXPORT, dir)))
|
||||
|
||||
if (options.isInvokedFromCli) {
|
||||
if (nextConfig.output === 'export') {
|
||||
Log.warn(
|
||||
'"next export" is no longer needed when "output: export" is configured in next.config.js'
|
||||
)
|
||||
return
|
||||
} else {
|
||||
Log.warn(
|
||||
'"next export" is deprecated in favor of "output: export" in next.config.js https://nextjs.org/docs/advanced-features/static-html-export'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const threads = options.threads || nextConfig.experimental.cpus
|
||||
const distDir = join(dir, nextConfig.distDir)
|
||||
|
||||
|
@ -627,7 +642,7 @@ export default async function exportApp(
|
|||
)
|
||||
}
|
||||
|
||||
const timeout = configuration?.staticPageGenerationTimeout || 0
|
||||
const timeout = nextConfig?.staticPageGenerationTimeout || 0
|
||||
let infoPrinted = false
|
||||
let exportPage: typeof import('./worker').default
|
||||
let endWorker: () => Promise<void>
|
||||
|
@ -714,23 +729,22 @@ export default async function exportApp(
|
|||
errorPaths.push(page !== path ? `${page}: ${path}` : path)
|
||||
}
|
||||
|
||||
if (options.buildExport && configuration) {
|
||||
if (options.buildExport) {
|
||||
if (typeof result.fromBuildExportRevalidate !== 'undefined') {
|
||||
configuration.initialPageRevalidationMap[path] =
|
||||
nextConfig.initialPageRevalidationMap[path] =
|
||||
result.fromBuildExportRevalidate
|
||||
}
|
||||
|
||||
if (typeof result.fromBuildExportMeta !== 'undefined') {
|
||||
configuration.initialPageMetaMap[path] =
|
||||
result.fromBuildExportMeta
|
||||
nextConfig.initialPageMetaMap[path] = result.fromBuildExportMeta
|
||||
}
|
||||
|
||||
if (result.ssgNotFound === true) {
|
||||
configuration.ssgNotFoundPaths.push(path)
|
||||
nextConfig.ssgNotFoundPaths.push(path)
|
||||
}
|
||||
|
||||
const durations = (configuration.pageDurationMap[pathMap.page] =
|
||||
configuration.pageDurationMap[pathMap.page] || {})
|
||||
const durations = (nextConfig.pageDurationMap[pathMap.page] =
|
||||
nextConfig.pageDurationMap[pathMap.page] || {})
|
||||
durations[path] = result.duration
|
||||
}
|
||||
|
||||
|
|
|
@ -20,12 +20,41 @@ import {
|
|||
|
||||
const glob = promisify(globOrig)
|
||||
const appDir = join(__dirname, '..')
|
||||
const distDir = join(__dirname, '.next')
|
||||
const distDir = join(appDir, '.next')
|
||||
const exportDir = join(appDir, 'out')
|
||||
const nextConfig = new File(join(appDir, 'next.config.js'))
|
||||
const slugPage = new File(join(appDir, 'app/another/[slug]/page.js'))
|
||||
const apiJson = new File(join(appDir, 'app/api/json/route.js'))
|
||||
|
||||
const expectedFiles = [
|
||||
'404.html',
|
||||
'404/index.html',
|
||||
'_next/static/media/test.3f1a293b.png',
|
||||
'_next/static/test-build-id/_buildManifest.js',
|
||||
'_next/static/test-build-id/_ssgManifest.js',
|
||||
'another/first/index.html',
|
||||
'another/first/index.txt',
|
||||
'another/index.html',
|
||||
'another/index.txt',
|
||||
'another/second/index.html',
|
||||
'another/second/index.txt',
|
||||
'api/json',
|
||||
'api/txt',
|
||||
'favicon.ico',
|
||||
'image-import/index.html',
|
||||
'image-import/index.txt',
|
||||
'index.html',
|
||||
'index.txt',
|
||||
'robots.txt',
|
||||
]
|
||||
|
||||
async function getFiles(cwd = exportDir) {
|
||||
const opts = { cwd, nodir: true }
|
||||
const files = ((await glob('**/*', opts)) as string[])
|
||||
.filter((f) => !f.startsWith('_next/static/chunks/'))
|
||||
.sort()
|
||||
return files
|
||||
}
|
||||
async function runTests({
|
||||
isDev,
|
||||
trailingSlash,
|
||||
|
@ -65,7 +94,6 @@ async function runTests({
|
|||
stopOrKill = async () => await killApp(app)
|
||||
} else {
|
||||
await nextBuild(appDir)
|
||||
await nextExport(appDir, { outdir: exportDir })
|
||||
const app = await startStaticServer(exportDir, null, appPort)
|
||||
stopOrKill = async () => await stopApp(app)
|
||||
}
|
||||
|
@ -159,31 +187,7 @@ describe('app dir with output export', () => {
|
|||
{ dynamic: "'force-static'" },
|
||||
])('should work with dynamic $dynamic on page', async ({ dynamic }) => {
|
||||
await runTests({ dynamicPage: dynamic })
|
||||
const opts = { cwd: exportDir, nodir: true }
|
||||
const files = ((await glob('**/*', opts)) as string[])
|
||||
.filter((f) => !f.startsWith('_next/static/chunks/'))
|
||||
.sort()
|
||||
expect(files).toEqual([
|
||||
'404.html',
|
||||
'404/index.html',
|
||||
'_next/static/media/test.3f1a293b.png',
|
||||
'_next/static/test-build-id/_buildManifest.js',
|
||||
'_next/static/test-build-id/_ssgManifest.js',
|
||||
'another/first/index.html',
|
||||
'another/first/index.txt',
|
||||
'another/index.html',
|
||||
'another/index.txt',
|
||||
'another/second/index.html',
|
||||
'another/second/index.txt',
|
||||
'api/json',
|
||||
'api/txt',
|
||||
'favicon.ico',
|
||||
'image-import/index.html',
|
||||
'image-import/index.txt',
|
||||
'index.html',
|
||||
'index.txt',
|
||||
'robots.txt',
|
||||
])
|
||||
expect(await getFiles()).toEqual(expectedFiles)
|
||||
})
|
||||
it("should throw when dynamic 'force-dynamic' on page", async () => {
|
||||
slugPage.replace(
|
||||
|
@ -256,4 +260,79 @@ describe('app dir with output export', () => {
|
|||
'The "exportPathMap" configuration cannot be used with the "app" directory. Please use generateStaticParams() instead.'
|
||||
)
|
||||
})
|
||||
it('should warn about "next export" is no longer needed with config', async () => {
|
||||
await fs.remove(distDir)
|
||||
await fs.remove(exportDir)
|
||||
await nextBuild(appDir)
|
||||
expect(await getFiles()).toEqual(expectedFiles)
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
await nextExport(
|
||||
appDir,
|
||||
{ outdir: exportDir },
|
||||
{
|
||||
onStdout(msg) {
|
||||
stdout += msg
|
||||
},
|
||||
onStderr(msg) {
|
||||
stderr += msg
|
||||
},
|
||||
}
|
||||
)
|
||||
expect(stderr).toContain(
|
||||
'warn - "next export" is no longer needed when "output: export" is configured in next.config.js'
|
||||
)
|
||||
expect(stdout).toContain('Export successful. Files written to')
|
||||
expect(await getFiles()).toEqual(expectedFiles)
|
||||
})
|
||||
it('should warn with deprecation message when no config.output detected for next export', async () => {
|
||||
await fs.remove(distDir)
|
||||
await fs.remove(exportDir)
|
||||
nextConfig.replace(`output: 'export',`, '')
|
||||
try {
|
||||
await nextBuild(appDir)
|
||||
expect(await getFiles()).toEqual([])
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
await nextExport(
|
||||
appDir,
|
||||
{ outdir: exportDir },
|
||||
{
|
||||
onStdout(msg) {
|
||||
stdout += msg
|
||||
},
|
||||
onStderr(msg) {
|
||||
stderr += msg
|
||||
},
|
||||
}
|
||||
)
|
||||
expect(stderr).toContain(
|
||||
'warn - "next export" is deprecated in favor of "output: export" in next.config.js'
|
||||
)
|
||||
expect(stdout).toContain('Export successful. Files written to')
|
||||
expect(await getFiles()).toEqual(expectedFiles)
|
||||
} finally {
|
||||
nextConfig.restore()
|
||||
await fs.remove(distDir)
|
||||
await fs.remove(exportDir)
|
||||
}
|
||||
})
|
||||
it('should correctly emit exported assets to config.distDir', async () => {
|
||||
const outputDir = join(appDir, 'output')
|
||||
await fs.remove(distDir)
|
||||
await fs.remove(outputDir)
|
||||
nextConfig.replace(
|
||||
'trailingSlash: true,',
|
||||
`trailingSlash: true,
|
||||
distDir: 'output',`
|
||||
)
|
||||
try {
|
||||
await nextBuild(appDir)
|
||||
expect(await getFiles(outputDir)).toEqual(expectedFiles)
|
||||
} finally {
|
||||
nextConfig.restore()
|
||||
await fs.remove(distDir)
|
||||
await fs.remove(outputDir)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
@ -216,7 +216,7 @@ export function runNextCommand(argv, options = {}) {
|
|||
let mergedStdio = ''
|
||||
|
||||
let stderrOutput = ''
|
||||
if (options.stderr) {
|
||||
if (options.stderr || options.onStderr) {
|
||||
instance.stderr.on('data', function (chunk) {
|
||||
mergedStdio += chunk
|
||||
stderrOutput += chunk
|
||||
|
@ -224,6 +224,9 @@ export function runNextCommand(argv, options = {}) {
|
|||
if (options.stderr === 'log') {
|
||||
console.log(chunk.toString())
|
||||
}
|
||||
if (typeof options.onStderr === 'function') {
|
||||
options.onStderr(chunk.toString())
|
||||
}
|
||||
})
|
||||
} else {
|
||||
instance.stderr.on('data', function (chunk) {
|
||||
|
@ -232,7 +235,7 @@ export function runNextCommand(argv, options = {}) {
|
|||
}
|
||||
|
||||
let stdoutOutput = ''
|
||||
if (options.stdout) {
|
||||
if (options.stdout || options.onStdout) {
|
||||
instance.stdout.on('data', function (chunk) {
|
||||
mergedStdio += chunk
|
||||
stdoutOutput += chunk
|
||||
|
@ -240,6 +243,9 @@ export function runNextCommand(argv, options = {}) {
|
|||
if (options.stdout === 'log') {
|
||||
console.log(chunk.toString())
|
||||
}
|
||||
if (typeof options.onStdout === 'function') {
|
||||
options.onStdout(chunk.toString())
|
||||
}
|
||||
})
|
||||
} else {
|
||||
instance.stdout.on('data', function (chunk) {
|
||||
|
|
Loading…
Reference in a new issue