diff --git a/fixtures/next-15-turbopack/.npmrc b/fixtures/next-15-turbopack/.npmrc
new file mode 100644
index 0000000..9cf9495
--- /dev/null
+++ b/fixtures/next-15-turbopack/.npmrc
@@ -0,0 +1 @@
+package-lock=false
\ No newline at end of file
diff --git a/fixtures/next-15-turbopack/app/layout.js b/fixtures/next-15-turbopack/app/layout.js
new file mode 100644
index 0000000..de69185
--- /dev/null
+++ b/fixtures/next-15-turbopack/app/layout.js
@@ -0,0 +1,11 @@
+export const metadata = {
+ title: 'Harper - Next.js v15 App',
+};
+
+export default function RootLayout({ children }) {
+ return (
+
+
{children}
+
+ );
+}
diff --git a/fixtures/next-15-turbopack/app/page.js b/fixtures/next-15-turbopack/app/page.js
new file mode 100644
index 0000000..4a48cf9
--- /dev/null
+++ b/fixtures/next-15-turbopack/app/page.js
@@ -0,0 +1,7 @@
+export default async function Page() {
+ return (
+
+
Next.js v15
+
+ );
+}
diff --git a/fixtures/next-15-turbopack/config.yaml b/fixtures/next-15-turbopack/config.yaml
new file mode 100644
index 0000000..ddeb17c
--- /dev/null
+++ b/fixtures/next-15-turbopack/config.yaml
@@ -0,0 +1,3 @@
+'@harperfast/nextjs':
+ package: '@harperfast/nextjs'
+ bundler: turbopack
diff --git a/fixtures/next-15-turbopack/next.config.mjs b/fixtures/next-15-turbopack/next.config.mjs
new file mode 100644
index 0000000..ada3f1d
--- /dev/null
+++ b/fixtures/next-15-turbopack/next.config.mjs
@@ -0,0 +1,3 @@
+import { withHarper } from '@harperfast/nextjs';
+
+export default withHarper({});
diff --git a/fixtures/next-15-turbopack/package.json b/fixtures/next-15-turbopack/package.json
new file mode 100644
index 0000000..bf94d84
--- /dev/null
+++ b/fixtures/next-15-turbopack/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "next-15",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint"
+ },
+ "dependencies": {
+ "@harperfast/nextjs": "file:../../",
+ "react": "^19",
+ "react-dom": "^19",
+ "next": "^15"
+ }
+}
diff --git a/fixtures/next-16-webpack/config.yaml b/fixtures/next-16-webpack/config.yaml
index 7707779..e7c727f 100644
--- a/fixtures/next-16-webpack/config.yaml
+++ b/fixtures/next-16-webpack/config.yaml
@@ -1,3 +1,3 @@
'@harperfast/nextjs':
package: '@harperfast/nextjs'
- webpack: true
+ bundler: webpack
diff --git a/integrationTests/next-15-turbopack.pw.ts b/integrationTests/next-15-turbopack.pw.ts
new file mode 100644
index 0000000..9590e3b
--- /dev/null
+++ b/integrationTests/next-15-turbopack.pw.ts
@@ -0,0 +1,18 @@
+import { fixture } from './fixture.ts';
+
+const { test, expect } = fixture('next-15-turbopack');
+
+test('home page renders', async ({ page, harper }) => {
+ await page.goto(harper.httpURL);
+ await expect(page.locator('h1')).toHaveText('Next.js v15');
+});
+
+test('page title is set', async ({ page, harper }) => {
+ await page.goto(harper.httpURL);
+ await expect(page).toHaveTitle('Harper - Next.js v15 App');
+});
+
+test('status endpoint returns 200', async ({ request, harper }) => {
+ const response = await request.get(`${harper.operationsAPIURL}/health`);
+ expect(response.status()).toBe(200);
+});
diff --git a/src/plugin.ts b/src/plugin.ts
index 30e048e..fad6802 100644
--- a/src/plugin.ts
+++ b/src/plugin.ts
@@ -14,8 +14,11 @@ import type NextBuildModule15 from 'next-15/dist/cli/next-build.d.ts';
import type NextModule16 from 'next-16';
import type NextBuildModule16 from 'next-16/dist/cli/next-build.d.ts';
+type Bundler = 'webpack' | 'turbopack';
+
interface NextPluginConfig extends Config {
buildOnly: boolean;
+ bundler: Bundler;
dev: boolean;
// @ts-expect-error
files?: FilesOption;
@@ -23,7 +26,6 @@ interface NextPluginConfig extends Config {
prebuilt: boolean;
runFirst: boolean;
securePort?: number;
- webpack: boolean;
}
// Bringing this forward from extension since some validation is better than none.
@@ -64,16 +66,22 @@ function resolveConfig(scope: Scope): NextPluginConfig {
}
assertType('buildOnly', options.buildOnly, 'boolean');
+ assertType('bundler', options.bundler, 'string');
assertType('dev', options.dev, 'boolean');
assertType('port', options.port, 'number');
assertType('prebuilt', options.prebuilt, 'boolean');
assertType('runFirst', options.runFirst, 'boolean');
assertType('securePort', options.securePort, 'number');
- assertType('webpack', options.webpack, 'boolean');
+
+ if (options.bundler && options.bundler !== 'webpack' && options.bundler !== 'turbopack') {
+ throw new Error(`bundler must be "webpack" or "turbopack". Received: "${options.bundler}"`);
+ }
// TODO: Remove type casts when we have more proper plugin option validation from core
return {
buildOnly: (options.buildOnly as boolean) ?? false,
+ // bundler default is set later in handleApplication() based on the detected Next.js version
+ bundler: options.bundler as Bundler,
dev: (options.dev as boolean) ?? false,
// @ts-expect-error
files: options.files,
@@ -81,7 +89,6 @@ function resolveConfig(scope: Scope): NextPluginConfig {
prebuilt: (options.prebuilt as boolean) ?? false,
runFirst: (options.runFirst as boolean) ?? false,
securePort: options.securePort as number,
- webpack: (options.webpack as boolean) ?? false,
} satisfies NextPluginConfig;
}
@@ -184,6 +191,17 @@ export async function handleApplication(scope: Scope) {
const next = importNext(scope);
+ // Set the bundler default based on the detected Next.js version if not explicitly configured.
+ // Next.js v16 defaults to turbopack; v14 and v15 default to webpack.
+ if (!config.bundler) {
+ config.bundler = next.version >= 16 ? 'turbopack' : 'webpack';
+ }
+
+ if (config.bundler === 'turbopack' && next.version === 14) {
+ scope.logger.error?.('Turbopack is not supported for Next.js v14. Falling back to webpack.');
+ config.bundler = 'webpack';
+ }
+
scope.logger.debug?.(`Detected Next.js version: ${next.version}`);
if (config.prebuilt) {
@@ -297,7 +315,7 @@ async function build(scope: Scope, config: NextPluginConfig, next: NextPackage)
{
lint: false,
mangling: true,
- turbopack: false,
+ ...(config.bundler === 'turbopack' && { turbopack: true }),
experimentalDebugMemoryUsage: false,
experimentalBuildMode: 'default',
},
@@ -308,7 +326,7 @@ async function build(scope: Scope, config: NextPluginConfig, next: NextPackage)
await next.build(
{
mangling: true,
- webpack: config.webpack,
+ ...(config.bundler === 'webpack' && { webpack: true }),
experimentalDebugMemoryUsage: false,
experimentalBuildMode: 'default',
},
@@ -341,10 +359,10 @@ async function serve(scope: Scope, config: NextPluginConfig, next: NextPackage)
app = next.server({ dir: scope.directory, dev: config.dev });
break;
case 15:
- app = next.server({ dir: scope.directory, dev: config.dev, turbopack: false });
+ app = next.server({ dir: scope.directory, dev: config.dev, ...(config.bundler === 'turbopack' && { turbopack: true }) });
break;
case 16:
- app = next.server({ dir: scope.directory, dev: config.dev, ...(config.webpack && { webpack: true }) });
+ app = next.server({ dir: scope.directory, dev: config.dev, ...(config.bundler === 'webpack' && { webpack: true }) });
break;
}