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:
Steven 2023-03-23 10:40:18 -04:00 committed by GitHub
parent 394bff5fd0
commit 9791d1e608
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 240 additions and 69 deletions

View file

@ -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.

View file

@ -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())

View file

@ -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)

View file

@ -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
}

View file

@ -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)
}
})
})

View file

@ -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) {