diff --git a/.gitignore b/.gitignore index 5f42518c9978f..cb9667809701e 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ CHANGELOG.md # Next.js .next +out # Angular Cache .angular diff --git a/docs/generated/packages/next/documents/overview.md b/docs/generated/packages/next/documents/overview.md index 1638827d5f616..7726607323ba3 100644 --- a/docs/generated/packages/next/documents/overview.md +++ b/docs/generated/packages/next/documents/overview.md @@ -60,7 +60,8 @@ The `@nx/next/plugin` is configured in the `plugins` array in `nx.json`. "options": { "buildTargetName": "build", "devTargetName": "dev", - "startTargetName": "start" + "startTargetName": "start", + "serveStaticTargetName": "serve-static" } } ] @@ -70,6 +71,10 @@ The `@nx/next/plugin` is configured in the `plugins` array in `nx.json`. - The `buildTargetName` option controls the name of Next.js' compilation task which compiles the application for production deployment. The default name is `build`. - The `devTargetName` option controls the name of Next.js' development serve task which starts the application in development mode. The default name is `dev`. - The `startTargetName` option controls the name of Next.js' production serve task which starts the application in production mode. The default name is `start`. +- The `serveStaticTargetName` option controls the name of Next.js' static export task which exports the application to static HTML files. The default name is `serve-static`. + +{% /tab %} +{% tab label="Nx < 18" %} {% /tab %} {% tab label="Nx < 18" %} @@ -246,9 +251,50 @@ const nextConfig = { nx: { svgr: false, }, + output: 'export', }; ``` +After setting the output to `export`, you can run the `build` command to generate the static HTML files. + +```shell +nx build my-next-app +``` + +You can then check your project folder for the `out` folder which contains the static HTML files. + +```shell +├── index.d.ts +├── jest.config.ts +├── next-env.d.ts +├── next.config.js +├── out +├── project.json +├── public +├── specs +├── src +├── tsconfig.json +└── tsconfig.spec.json +``` + +#### E2E testing + +You can perform end-to-end (E2E) testing on static HTML files using a test runner like Cypress. When you create a Next.js application, Nx automatically creates a `serve-static` target. This target is designed to serve the static HTML files produced by the build command. + +This feature is particularly useful for testing in continuous integration (CI) pipelines, where resources may be constrained. Unlike the `dev` and `start` targets, `serve-static` does not require a Next.js server to operate, making it more efficient and faster by eliminating background processes, such as file change monitoring. + +To utilize the `serve-static` target for testing, run the following command: + +```shell +nx serve-static my-next-app-e2e +``` + +This command performs several actions: + +1. It will build the Next.js application and generate the static HTML files. +2. It will serve the static HTML files using a simple HTTP server. +3. It will run the Cypress tests against the served static HTML files. + ### Deploying Next.js Applications Once you are ready to deploy your Next.js application, you have absolute freedom to choose any hosting provider that fits your needs. diff --git a/docs/shared/packages/next/plugin-overview.md b/docs/shared/packages/next/plugin-overview.md index 1638827d5f616..7726607323ba3 100644 --- a/docs/shared/packages/next/plugin-overview.md +++ b/docs/shared/packages/next/plugin-overview.md @@ -60,7 +60,8 @@ The `@nx/next/plugin` is configured in the `plugins` array in `nx.json`. "options": { "buildTargetName": "build", "devTargetName": "dev", - "startTargetName": "start" + "startTargetName": "start", + "serveStaticTargetName": "serve-static" } } ] @@ -70,6 +71,10 @@ The `@nx/next/plugin` is configured in the `plugins` array in `nx.json`. - The `buildTargetName` option controls the name of Next.js' compilation task which compiles the application for production deployment. The default name is `build`. - The `devTargetName` option controls the name of Next.js' development serve task which starts the application in development mode. The default name is `dev`. - The `startTargetName` option controls the name of Next.js' production serve task which starts the application in production mode. The default name is `start`. +- The `serveStaticTargetName` option controls the name of Next.js' static export task which exports the application to static HTML files. The default name is `serve-static`. + +{% /tab %} +{% tab label="Nx < 18" %} {% /tab %} {% tab label="Nx < 18" %} @@ -246,9 +251,50 @@ const nextConfig = { nx: { svgr: false, }, + output: 'export', }; ``` +After setting the output to `export`, you can run the `build` command to generate the static HTML files. + +```shell +nx build my-next-app +``` + +You can then check your project folder for the `out` folder which contains the static HTML files. + +```shell +├── index.d.ts +├── jest.config.ts +├── next-env.d.ts +├── next.config.js +├── out +├── project.json +├── public +├── specs +├── src +├── tsconfig.json +└── tsconfig.spec.json +``` + +#### E2E testing + +You can perform end-to-end (E2E) testing on static HTML files using a test runner like Cypress. When you create a Next.js application, Nx automatically creates a `serve-static` target. This target is designed to serve the static HTML files produced by the build command. + +This feature is particularly useful for testing in continuous integration (CI) pipelines, where resources may be constrained. Unlike the `dev` and `start` targets, `serve-static` does not require a Next.js server to operate, making it more efficient and faster by eliminating background processes, such as file change monitoring. + +To utilize the `serve-static` target for testing, run the following command: + +```shell +nx serve-static my-next-app-e2e +``` + +This command performs several actions: + +1. It will build the Next.js application and generate the static HTML files. +2. It will serve the static HTML files using a simple HTTP server. +3. It will run the Cypress tests against the served static HTML files. + ### Deploying Next.js Applications Once you are ready to deploy your Next.js application, you have absolute freedom to choose any hosting provider that fits your needs. diff --git a/e2e/next-core/src/next.test.ts b/e2e/next-core/src/next.test.ts index eb933a6808d0d..f6e2372365104 100644 --- a/e2e/next-core/src/next.test.ts +++ b/e2e/next-core/src/next.test.ts @@ -5,6 +5,7 @@ import { newProject, readFile, runCLI, + runE2ETests, uniq, updateFile, } from '@nx/e2e/utils'; @@ -183,6 +184,44 @@ describe('Next.js Applications', () => { `Successfully ran target build for project ${appName}` ); }, 300_000); + + it('should run e2e-ci test', async () => { + const appName = uniq('app'); + + runCLI( + `generate @nx/next:app ${appName} --no-interactive --style=css --project-name-and-root-format=as-provided` + ); + + // Update the cypress timeout to 25 seconds since we need to build and wait for the server to start + updateFile(`${appName}-e2e/cypress.config.ts`, (_) => { + return `import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; + + import { defineConfig } from 'cypress'; + + export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename, { + cypressDir: 'src', + webServerCommands: { default: 'nx run ${appName}:start' }, + webServerConfig: { timeout: 25_000 }, + ciWebServerCommand: 'nx run ${appName}:serve-static', + }), + baseUrl: 'http://localhost:3000', + }, + }); + + `; + }); + + if (runE2ETests()) { + const e2eResults = runCLI(`e2e-ci ${appName}-e2e --verbose`, { + verbose: true, + }); + expect(e2eResults).toContain( + 'Successfully ran target e2e-ci for project' + ); + } + }, 600_000); }); function getData(port, path = ''): Promise { diff --git a/packages/next/plugins/with-nx.ts b/packages/next/plugins/with-nx.ts index 87d2949858772..555c57be61d02 100644 --- a/packages/next/plugins/with-nx.ts +++ b/packages/next/plugins/with-nx.ts @@ -197,6 +197,12 @@ function withNx( : joinPathFragments(outputDir, '.next'); } + // If we are running a static serve of the Next.js app, we need to change the output to 'export' and the distDir to 'out'. + if (process.env.NX_SERVE_STATIC_BUILD_RUNNING === 'true') { + nextConfig.output = 'export'; + nextConfig.distDir = 'out'; + } + const userWebpackConfig = nextConfig.webpack; const { createWebpackConfig } = require('@nx/next/src/utils/config'); diff --git a/packages/next/src/generators/application/lib/add-e2e.ts b/packages/next/src/generators/application/lib/add-e2e.ts index 99e8891101ccf..95c7f74fefcde 100644 --- a/packages/next/src/generators/application/lib/add-e2e.ts +++ b/packages/next/src/generators/application/lib/add-e2e.ts @@ -10,6 +10,7 @@ import { Linter } from '@nx/eslint'; import { nxVersion } from '../../../utils/versions'; import { NormalizedSchema } from './normalize-options'; +import { webStaticServeGenerator } from '@nx/web'; export async function addE2e(host: Tree, options: NormalizedSchema) { const nxJson = readNxJson(host); @@ -18,10 +19,20 @@ export async function addE2e(host: Tree, options: NormalizedSchema) { ? p === '@nx/next/plugin' : p.plugin === '@nx/next/plugin' ); + if (options.e2eTestRunner === 'cypress') { const { configurationGenerator } = ensurePackage< typeof import('@nx/cypress') >('@nx/cypress', nxVersion); + + if (!hasPlugin) { + webStaticServeGenerator(host, { + buildTarget: `${options.projectName}:build`, + outputPath: `${options.outputPath}/out`, + targetName: 'serve-static', + }); + } + addProjectConfiguration(host, options.e2eProjectName, { root: options.e2eProjectRoot, sourceRoot: joinPathFragments(options.e2eProjectRoot, 'src'), @@ -29,6 +40,7 @@ export async function addE2e(host: Tree, options: NormalizedSchema) { tags: [], implicitDependencies: [options.projectName], }); + return configurationGenerator(host, { ...options, linter: Linter.EsLint, @@ -40,6 +52,14 @@ export async function addE2e(host: Tree, options: NormalizedSchema) { }`, baseUrl: `http://localhost:${hasPlugin ? '3000' : '4200'}`, jsx: true, + webServerCommands: hasPlugin + ? { + default: `nx run ${options.projectName}:start`, + } + : undefined, + ciWebServerCommand: hasPlugin + ? `nx run ${options.projectName}:serve-static` + : undefined, }); } else if (options.e2eTestRunner === 'playwright') { const { configurationGenerator } = ensurePackage< diff --git a/packages/next/src/generators/custom-server/custom-server.spec.ts b/packages/next/src/generators/custom-server/custom-server.spec.ts index b7e6f2062a9a8..46d082189610b 100644 --- a/packages/next/src/generators/custom-server/custom-server.spec.ts +++ b/packages/next/src/generators/custom-server/custom-server.spec.ts @@ -28,7 +28,7 @@ describe('app', () => { }); it('should create a custom server with swc', async () => { - const name = uniq('custom-server'); + const name = uniq('custom-server-swc'); await applicationGenerator(tree, { name, diff --git a/packages/next/src/generators/init/lib/add-plugin.ts b/packages/next/src/generators/init/lib/add-plugin.ts index 2835c14968f21..216d065ab4a91 100644 --- a/packages/next/src/generators/init/lib/add-plugin.ts +++ b/packages/next/src/generators/init/lib/add-plugin.ts @@ -20,6 +20,7 @@ export function addPlugin(tree: Tree) { buildTargetName: 'build', devTargetName: 'dev', startTargetName: 'start', + serveStaticTargetName: 'serve-static', }, }); diff --git a/packages/next/src/plugins/__snapshots__/plugin.spec.ts.snap b/packages/next/src/plugins/__snapshots__/plugin.spec.ts.snap index 64fa1296359fc..ab9794cfa1ecd 100644 --- a/packages/next/src/plugins/__snapshots__/plugin.spec.ts.snap +++ b/packages/next/src/plugins/__snapshots__/plugin.spec.ts.snap @@ -35,6 +35,14 @@ exports[`@nx/next/plugin integrated projects should create nodes 1`] = ` "cwd": "my-app", }, }, + "my-serve-static": { + "executor": "@nx/web:file-server", + "options": { + "buildTarget": "my-build", + "port": 3000, + "staticFilePath": "{projectRoot}/out", + }, + }, "my-start": { "command": "next start", "dependsOn": [ @@ -85,6 +93,14 @@ exports[`@nx/next/plugin root projects should create nodes 1`] = ` "cwd": ".", }, }, + "serve-static": { + "executor": "@nx/web:file-server", + "options": { + "buildTarget": "build", + "port": 3000, + "staticFilePath": "{projectRoot}/out", + }, + }, "start": { "command": "next start", "dependsOn": [ diff --git a/packages/next/src/plugins/plugin.spec.ts b/packages/next/src/plugins/plugin.spec.ts index b0006368c5d20..0b1f5e6e662aa 100644 --- a/packages/next/src/plugins/plugin.spec.ts +++ b/packages/next/src/plugins/plugin.spec.ts @@ -34,6 +34,7 @@ describe('@nx/next/plugin', () => { buildTargetName: 'build', devTargetName: 'dev', startTargetName: 'start', + serveStaticTargetName: 'serve-static', }, context ); @@ -73,6 +74,7 @@ describe('@nx/next/plugin', () => { buildTargetName: 'my-build', devTargetName: 'my-serve', startTargetName: 'my-start', + serveStaticTargetName: 'my-serve-static', }, context ); diff --git a/packages/next/src/plugins/plugin.ts b/packages/next/src/plugins/plugin.ts index dcc2d5c1e140c..96c076510b0f8 100644 --- a/packages/next/src/plugins/plugin.ts +++ b/packages/next/src/plugins/plugin.ts @@ -21,6 +21,7 @@ export interface NextPluginOptions { buildTargetName?: string; devTargetName?: string; startTargetName?: string; + serveStaticTargetName?: string; } const cachePath = join(projectGraphCacheDirectory, 'next.hash'); @@ -62,7 +63,6 @@ export const createNodes: CreateNodes = [ ) { return {}; } - options = normalizeOptions(options); const hash = calculateHashForCreateNodes(projectRoot, options, context, [ @@ -106,6 +106,9 @@ async function buildNextTargets( targets[options.devTargetName] = getDevTargetConfig(projectRoot); targets[options.startTargetName] = getStartTargetConfig(options, projectRoot); + + targets[options.serveStaticTargetName] = getStaticServeTargetConfig(options); + return targets; } @@ -152,6 +155,19 @@ function getStartTargetConfig(options: NextPluginOptions, projectRoot: string) { return targetConfig; } +function getStaticServeTargetConfig(options: NextPluginOptions) { + const targetConfig: TargetConfiguration = { + executor: '@nx/web:file-server', + options: { + buildTarget: options.buildTargetName, + staticFilePath: '{projectRoot}/out', + port: 3000, + }, + }; + + return targetConfig; +} + async function getOutputs(projectRoot, nextConfig) { let dir = '.next'; const { PHASE_PRODUCTION_BUILD } = require('next/constants'); @@ -196,6 +212,7 @@ function normalizeOptions(options: NextPluginOptions): NextPluginOptions { options.buildTargetName ??= 'build'; options.devTargetName ??= 'dev'; options.startTargetName ??= 'start'; + options.serveStaticTargetName ??= 'serve-static'; return options; } diff --git a/packages/next/src/utils/add-gitignore-entry.ts b/packages/next/src/utils/add-gitignore-entry.ts index 561d6201d2b5d..6f66cfdc0552a 100644 --- a/packages/next/src/utils/add-gitignore-entry.ts +++ b/packages/next/src/utils/add-gitignore-entry.ts @@ -12,7 +12,7 @@ export function addGitIgnoreEntry(host: Tree) { ig.add(host.read('.gitignore', 'utf-8')); if (!ig.ignores('apps/example/.next')) { - content = `${content}\n\n# Next.js\n.next\n`; + content = `${content}\n\n# Next.js\n.next\nout\n`; } host.write('.gitignore', content); diff --git a/packages/web/src/executors/file-server/file-server.impl.ts b/packages/web/src/executors/file-server/file-server.impl.ts index 12a432338eb44..2b042bee81821 100644 --- a/packages/web/src/executors/file-server/file-server.impl.ts +++ b/packages/web/src/executors/file-server/file-server.impl.ts @@ -149,6 +149,12 @@ export default async function* fileServerExecutor( const run = () => { if (!running) { running = true; + /** + * Expose a variable to the build target to know if it's being run by the serve-static executor + * This is useful because a config might need to change if it's being run by serve-static without the user's input + * or if being ran by another executor (eg. E2E tests) + * */ + process.env.NX_SERVE_STATIC_BUILD_RUNNING = 'true'; try { const args = getBuildTargetCommand(options, context); execFileSync(pmCmd, args, { @@ -159,6 +165,7 @@ export default async function* fileServerExecutor( `Build target failed: ${chalk.bold(options.buildTarget)}` ); } finally { + process.env.NX_SERVE_STATIC_BUILD_RUNNING = undefined; running = false; } }