From 2afe782b029e6cef32a2aebda6258037907cde64 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Mon, 22 Sep 2025 17:41:28 +0200 Subject: [PATCH 01/39] auto setup dummy adapter --- package-lock.json | 1048 ++++++++++++++++++++++++++++++++++++++++++++- package.json | 1 + src/adapter.ts | 14 + src/index.ts | 5 + 4 files changed, 1056 insertions(+), 12 deletions(-) create mode 100644 src/adapter.ts diff --git a/package-lock.json b/package-lock.json index 0b5707e145..16b5466ed4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "msw": "^2.0.7", "netlify-cli": "23.6.0", "next": "^15.0.0-canary.28", + "next-with-adapters": "npm:next@15.6.0-canary.20", "next-with-cache-handler-v2": "npm:next@15.3.0-canary.13", "os": "^0.1.2", "outdent": "^0.8.0", @@ -1525,9 +1526,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", - "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", + "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", "dev": true, "license": "MIT", "optional": true, @@ -2879,6 +2880,17 @@ "dev": true, "license": "ISC" }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@img/sharp-darwin-arm64": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", @@ -2993,6 +3005,23 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.3.tgz", + "integrity": "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-libvips-linux-s390x": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", @@ -3107,6 +3136,29 @@ "@img/sharp-libvips-linux-arm64": "1.0.4" } }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.4.tgz", + "integrity": "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.3" + } + }, "node_modules/@img/sharp-linux-s390x": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", @@ -3219,6 +3271,26 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.4.tgz", + "integrity": "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-win32-ia32": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", @@ -8295,10 +8367,11 @@ } }, "node_modules/detect-libc": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", - "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.0.tgz", + "integrity": "sha512-vEtk+OcP7VBRtQZ1EJ3bdgzSfBjgnEalLTp5zjJrS+2Z1w2KZly4SBdac/WDU3hhsNAZ9E8SC96ME4Ey8MZ7cg==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=8" } @@ -28125,6 +28198,637 @@ } } }, + "node_modules/next-with-adapters": { + "name": "next", + "version": "15.6.0-canary.20", + "resolved": "https://registry.npmjs.org/next/-/next-15.6.0-canary.20.tgz", + "integrity": "sha512-FzC5rYa5JgeITRnWX69kqrwM2xgaDlSO1EoPDtmMewpAH/H5Yh1D7+MaYQr+cyfDM0luSpqD6PJd2ej8950RTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/env": "15.6.0-canary.20", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.6.0-canary.20", + "@next/swc-darwin-x64": "15.6.0-canary.20", + "@next/swc-linux-arm64-gnu": "15.6.0-canary.20", + "@next/swc-linux-arm64-musl": "15.6.0-canary.20", + "@next/swc-linux-x64-gnu": "15.6.0-canary.20", + "@next/swc-linux-x64-musl": "15.6.0-canary.20", + "@next/swc-win32-arm64-msvc": "15.6.0-canary.20", + "@next/swc-win32-x64-msvc": "15.6.0-canary.20", + "sharp": "^0.34.4" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next-with-adapters/node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.4.tgz", + "integrity": "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.3" + } + }, + "node_modules/next-with-adapters/node_modules/@img/sharp-darwin-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.4.tgz", + "integrity": "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.3" + } + }, + "node_modules/next-with-adapters/node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next-with-adapters/node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next-with-adapters/node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.3.tgz", + "integrity": "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next-with-adapters/node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.3.tgz", + "integrity": "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next-with-adapters/node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.3.tgz", + "integrity": "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next-with-adapters/node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.3.tgz", + "integrity": "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next-with-adapters/node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.3.tgz", + "integrity": "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next-with-adapters/node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.3.tgz", + "integrity": "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next-with-adapters/node_modules/@img/sharp-linux-arm": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.4.tgz", + "integrity": "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.3" + } + }, + "node_modules/next-with-adapters/node_modules/@img/sharp-linux-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.4.tgz", + "integrity": "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.3" + } + }, + "node_modules/next-with-adapters/node_modules/@img/sharp-linux-s390x": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.4.tgz", + "integrity": "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.3" + } + }, + "node_modules/next-with-adapters/node_modules/@img/sharp-linux-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.4.tgz", + "integrity": "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.3" + } + }, + "node_modules/next-with-adapters/node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.4.tgz", + "integrity": "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3" + } + }, + "node_modules/next-with-adapters/node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.4.tgz", + "integrity": "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.3" + } + }, + "node_modules/next-with-adapters/node_modules/@img/sharp-wasm32": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.4.tgz", + "integrity": "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.5.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next-with-adapters/node_modules/@img/sharp-win32-ia32": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.4.tgz", + "integrity": "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next-with-adapters/node_modules/@img/sharp-win32-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.4.tgz", + "integrity": "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/next-with-adapters/node_modules/@next/env": { + "version": "15.6.0-canary.20", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.6.0-canary.20.tgz", + "integrity": "sha512-+ljGWYCPxG5SNlTecwlcVcBnARQNv/CjzD73VlJg2oMvRnVrLCr+1zrjY1KnOVF4KsDxVTCD52V92YeAaJojNw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next-with-adapters/node_modules/@next/swc-darwin-arm64": { + "version": "15.6.0-canary.20", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.6.0-canary.20.tgz", + "integrity": "sha512-UFv71kNjbKhzdd7nd6f4UKALqKzal/f+dZ9X/ld9rfUmE/sVsCpBqbzu/Uw8KtGVwz1TKcsuR5A+p79SRhY39Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/next-with-adapters/node_modules/@next/swc-darwin-x64": { + "version": "15.6.0-canary.20", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.6.0-canary.20.tgz", + "integrity": "sha512-Re+46/ZpzquBczPruty09ywO/uTVo2i6yeCr1+x7YpgEj/7jevIIOr9qHoWtTxdceco8+NwmjyPX3jKwqK/IEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/next-with-adapters/node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.6.0-canary.20", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.6.0-canary.20.tgz", + "integrity": "sha512-NdReZ2W87z8HttNuWgDPlcpBkQdzaG0WfB2KCwH17mT3NNhaZ3WCrRfp1FICiMIB/TjNi8ewjqYb+7J4vrslBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/next-with-adapters/node_modules/@next/swc-linux-arm64-musl": { + "version": "15.6.0-canary.20", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.6.0-canary.20.tgz", + "integrity": "sha512-3ItqqT6eRyz3tTtO0H+s/ZnmHWLioNxvNQ+NrCopprMQX4aCqT14g8juSCWyCxUpUCJewu1qzH1b8MG/w49ynA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/next-with-adapters/node_modules/@next/swc-linux-x64-gnu": { + "version": "15.6.0-canary.20", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.6.0-canary.20.tgz", + "integrity": "sha512-4MwMRDSdkDk1URFfq8Jh4N2Ck6BY9QjSVukV0PqaF1BIncOdSd+OhdmawAI5h3GAmyhkEXajmqGz33U66uEHAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/next-with-adapters/node_modules/@next/swc-linux-x64-musl": { + "version": "15.6.0-canary.20", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.6.0-canary.20.tgz", + "integrity": "sha512-XLGkiwp5z7BUd6DbpAkU+Y12JbckzEhwcRmPC9LMWgPzZovsFscjrDyTmYuyRMqaq2qKP+TmXWBwkHvalH8JEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/next-with-adapters/node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.6.0-canary.20", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.6.0-canary.20.tgz", + "integrity": "sha512-rRmwdrIt4g/oX9m/oOiOvXf35cwmyDUbAgSJeE/sB5QZYz7dOgx7Cfj3K5YJJ8fYPCVIO9cALQCeWZuvIrVCBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/next-with-adapters/node_modules/@next/swc-win32-x64-msvc": { + "version": "15.6.0-canary.20", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.6.0-canary.20.tgz", + "integrity": "sha512-3jfmbFAOLgRvqs5TKclq3u25lS7ctB/RwLiflbCq8pd9rmu0kIoUlFQP8kiX67bNLSv/p6tWYCd1XMEbyMRn2w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/next-with-adapters/node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/next-with-adapters/node_modules/sharp": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.4.tgz", + "integrity": "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.0", + "semver": "^7.7.2" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.4", + "@img/sharp-darwin-x64": "0.34.4", + "@img/sharp-libvips-darwin-arm64": "1.2.3", + "@img/sharp-libvips-darwin-x64": "1.2.3", + "@img/sharp-libvips-linux-arm": "1.2.3", + "@img/sharp-libvips-linux-arm64": "1.2.3", + "@img/sharp-libvips-linux-ppc64": "1.2.3", + "@img/sharp-libvips-linux-s390x": "1.2.3", + "@img/sharp-libvips-linux-x64": "1.2.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", + "@img/sharp-libvips-linuxmusl-x64": "1.2.3", + "@img/sharp-linux-arm": "0.34.4", + "@img/sharp-linux-arm64": "0.34.4", + "@img/sharp-linux-ppc64": "0.34.4", + "@img/sharp-linux-s390x": "0.34.4", + "@img/sharp-linux-x64": "0.34.4", + "@img/sharp-linuxmusl-arm64": "0.34.4", + "@img/sharp-linuxmusl-x64": "0.34.4", + "@img/sharp-wasm32": "0.34.4", + "@img/sharp-win32-arm64": "0.34.4", + "@img/sharp-win32-ia32": "0.34.4", + "@img/sharp-win32-x64": "0.34.4" + } + }, "node_modules/next-with-cache-handler-v2": { "name": "next", "version": "15.3.0-canary.13", @@ -34258,9 +34962,9 @@ } }, "@emnapi/runtime": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", - "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", + "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", "dev": true, "optional": true, "requires": { @@ -35043,6 +35747,13 @@ "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==", "dev": true }, + "@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "dev": true, + "optional": true + }, "@img/sharp-darwin-arm64": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", @@ -35091,6 +35802,13 @@ "dev": true, "optional": true }, + "@img/sharp-libvips-linux-ppc64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.3.tgz", + "integrity": "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==", + "dev": true, + "optional": true + }, "@img/sharp-libvips-linux-s390x": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", @@ -35139,6 +35857,16 @@ "@img/sharp-libvips-linux-arm64": "1.0.4" } }, + "@img/sharp-linux-ppc64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.4.tgz", + "integrity": "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==", + "dev": true, + "optional": true, + "requires": { + "@img/sharp-libvips-linux-ppc64": "1.2.3" + } + }, "@img/sharp-linux-s390x": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", @@ -35189,6 +35917,13 @@ "@emnapi/runtime": "^1.2.0" } }, + "@img/sharp-win32-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.4.tgz", + "integrity": "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==", + "dev": true, + "optional": true + }, "@img/sharp-win32-ia32": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", @@ -38838,9 +39573,9 @@ "peer": true }, "detect-libc": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", - "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.0.tgz", + "integrity": "sha512-vEtk+OcP7VBRtQZ1EJ3bdgzSfBjgnEalLTp5zjJrS+2Z1w2KZly4SBdac/WDU3hhsNAZ9E8SC96ME4Ey8MZ7cg==", "dev": true }, "detective-amd": { @@ -52688,6 +53423,295 @@ "styled-jsx": "5.1.6" } }, + "next-with-adapters": { + "version": "npm:next@15.6.0-canary.20", + "resolved": "https://registry.npmjs.org/next/-/next-15.6.0-canary.20.tgz", + "integrity": "sha512-FzC5rYa5JgeITRnWX69kqrwM2xgaDlSO1EoPDtmMewpAH/H5Yh1D7+MaYQr+cyfDM0luSpqD6PJd2ej8950RTw==", + "dev": true, + "requires": { + "@next/env": "15.6.0-canary.20", + "@next/swc-darwin-arm64": "15.6.0-canary.20", + "@next/swc-darwin-x64": "15.6.0-canary.20", + "@next/swc-linux-arm64-gnu": "15.6.0-canary.20", + "@next/swc-linux-arm64-musl": "15.6.0-canary.20", + "@next/swc-linux-x64-gnu": "15.6.0-canary.20", + "@next/swc-linux-x64-musl": "15.6.0-canary.20", + "@next/swc-win32-arm64-msvc": "15.6.0-canary.20", + "@next/swc-win32-x64-msvc": "15.6.0-canary.20", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "sharp": "^0.34.4", + "styled-jsx": "5.1.6" + }, + "dependencies": { + "@img/sharp-darwin-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.4.tgz", + "integrity": "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==", + "dev": true, + "optional": true, + "requires": { + "@img/sharp-libvips-darwin-arm64": "1.2.3" + } + }, + "@img/sharp-darwin-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.4.tgz", + "integrity": "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==", + "dev": true, + "optional": true, + "requires": { + "@img/sharp-libvips-darwin-x64": "1.2.3" + } + }, + "@img/sharp-libvips-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==", + "dev": true, + "optional": true + }, + "@img/sharp-libvips-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==", + "dev": true, + "optional": true + }, + "@img/sharp-libvips-linux-arm": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.3.tgz", + "integrity": "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==", + "dev": true, + "optional": true + }, + "@img/sharp-libvips-linux-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.3.tgz", + "integrity": "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==", + "dev": true, + "optional": true + }, + "@img/sharp-libvips-linux-s390x": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.3.tgz", + "integrity": "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==", + "dev": true, + "optional": true + }, + "@img/sharp-libvips-linux-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.3.tgz", + "integrity": "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==", + "dev": true, + "optional": true + }, + "@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.3.tgz", + "integrity": "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==", + "dev": true, + "optional": true + }, + "@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.3.tgz", + "integrity": "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==", + "dev": true, + "optional": true + }, + "@img/sharp-linux-arm": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.4.tgz", + "integrity": "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==", + "dev": true, + "optional": true, + "requires": { + "@img/sharp-libvips-linux-arm": "1.2.3" + } + }, + "@img/sharp-linux-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.4.tgz", + "integrity": "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==", + "dev": true, + "optional": true, + "requires": { + "@img/sharp-libvips-linux-arm64": "1.2.3" + } + }, + "@img/sharp-linux-s390x": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.4.tgz", + "integrity": "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==", + "dev": true, + "optional": true, + "requires": { + "@img/sharp-libvips-linux-s390x": "1.2.3" + } + }, + "@img/sharp-linux-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.4.tgz", + "integrity": "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==", + "dev": true, + "optional": true, + "requires": { + "@img/sharp-libvips-linux-x64": "1.2.3" + } + }, + "@img/sharp-linuxmusl-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.4.tgz", + "integrity": "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==", + "dev": true, + "optional": true, + "requires": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3" + } + }, + "@img/sharp-linuxmusl-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.4.tgz", + "integrity": "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==", + "dev": true, + "optional": true, + "requires": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.3" + } + }, + "@img/sharp-wasm32": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.4.tgz", + "integrity": "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==", + "dev": true, + "optional": true, + "requires": { + "@emnapi/runtime": "^1.5.0" + } + }, + "@img/sharp-win32-ia32": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.4.tgz", + "integrity": "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==", + "dev": true, + "optional": true + }, + "@img/sharp-win32-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.4.tgz", + "integrity": "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==", + "dev": true, + "optional": true + }, + "@next/env": { + "version": "15.6.0-canary.20", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.6.0-canary.20.tgz", + "integrity": "sha512-+ljGWYCPxG5SNlTecwlcVcBnARQNv/CjzD73VlJg2oMvRnVrLCr+1zrjY1KnOVF4KsDxVTCD52V92YeAaJojNw==", + "dev": true + }, + "@next/swc-darwin-arm64": { + "version": "15.6.0-canary.20", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.6.0-canary.20.tgz", + "integrity": "sha512-UFv71kNjbKhzdd7nd6f4UKALqKzal/f+dZ9X/ld9rfUmE/sVsCpBqbzu/Uw8KtGVwz1TKcsuR5A+p79SRhY39Q==", + "dev": true, + "optional": true + }, + "@next/swc-darwin-x64": { + "version": "15.6.0-canary.20", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.6.0-canary.20.tgz", + "integrity": "sha512-Re+46/ZpzquBczPruty09ywO/uTVo2i6yeCr1+x7YpgEj/7jevIIOr9qHoWtTxdceco8+NwmjyPX3jKwqK/IEQ==", + "dev": true, + "optional": true + }, + "@next/swc-linux-arm64-gnu": { + "version": "15.6.0-canary.20", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.6.0-canary.20.tgz", + "integrity": "sha512-NdReZ2W87z8HttNuWgDPlcpBkQdzaG0WfB2KCwH17mT3NNhaZ3WCrRfp1FICiMIB/TjNi8ewjqYb+7J4vrslBg==", + "dev": true, + "optional": true + }, + "@next/swc-linux-arm64-musl": { + "version": "15.6.0-canary.20", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.6.0-canary.20.tgz", + "integrity": "sha512-3ItqqT6eRyz3tTtO0H+s/ZnmHWLioNxvNQ+NrCopprMQX4aCqT14g8juSCWyCxUpUCJewu1qzH1b8MG/w49ynA==", + "dev": true, + "optional": true + }, + "@next/swc-linux-x64-gnu": { + "version": "15.6.0-canary.20", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.6.0-canary.20.tgz", + "integrity": "sha512-4MwMRDSdkDk1URFfq8Jh4N2Ck6BY9QjSVukV0PqaF1BIncOdSd+OhdmawAI5h3GAmyhkEXajmqGz33U66uEHAg==", + "dev": true, + "optional": true + }, + "@next/swc-linux-x64-musl": { + "version": "15.6.0-canary.20", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.6.0-canary.20.tgz", + "integrity": "sha512-XLGkiwp5z7BUd6DbpAkU+Y12JbckzEhwcRmPC9LMWgPzZovsFscjrDyTmYuyRMqaq2qKP+TmXWBwkHvalH8JEw==", + "dev": true, + "optional": true + }, + "@next/swc-win32-arm64-msvc": { + "version": "15.6.0-canary.20", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.6.0-canary.20.tgz", + "integrity": "sha512-rRmwdrIt4g/oX9m/oOiOvXf35cwmyDUbAgSJeE/sB5QZYz7dOgx7Cfj3K5YJJ8fYPCVIO9cALQCeWZuvIrVCBw==", + "dev": true, + "optional": true + }, + "@next/swc-win32-x64-msvc": { + "version": "15.6.0-canary.20", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.6.0-canary.20.tgz", + "integrity": "sha512-3jfmbFAOLgRvqs5TKclq3u25lS7ctB/RwLiflbCq8pd9rmu0kIoUlFQP8kiX67bNLSv/p6tWYCd1XMEbyMRn2w==", + "dev": true, + "optional": true + }, + "@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "dev": true, + "requires": { + "tslib": "^2.8.0" + } + }, + "sharp": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.4.tgz", + "integrity": "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==", + "dev": true, + "optional": true, + "requires": { + "@img/colour": "^1.0.0", + "@img/sharp-darwin-arm64": "0.34.4", + "@img/sharp-darwin-x64": "0.34.4", + "@img/sharp-libvips-darwin-arm64": "1.2.3", + "@img/sharp-libvips-darwin-x64": "1.2.3", + "@img/sharp-libvips-linux-arm": "1.2.3", + "@img/sharp-libvips-linux-arm64": "1.2.3", + "@img/sharp-libvips-linux-ppc64": "1.2.3", + "@img/sharp-libvips-linux-s390x": "1.2.3", + "@img/sharp-libvips-linux-x64": "1.2.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", + "@img/sharp-libvips-linuxmusl-x64": "1.2.3", + "@img/sharp-linux-arm": "0.34.4", + "@img/sharp-linux-arm64": "0.34.4", + "@img/sharp-linux-ppc64": "0.34.4", + "@img/sharp-linux-s390x": "0.34.4", + "@img/sharp-linux-x64": "0.34.4", + "@img/sharp-linuxmusl-arm64": "0.34.4", + "@img/sharp-linuxmusl-x64": "0.34.4", + "@img/sharp-wasm32": "0.34.4", + "@img/sharp-win32-arm64": "0.34.4", + "@img/sharp-win32-ia32": "0.34.4", + "@img/sharp-win32-x64": "0.34.4", + "detect-libc": "^2.1.0", + "semver": "^7.7.2" + } + } + } + }, "next-with-cache-handler-v2": { "version": "npm:next@15.3.0-canary.13", "resolved": "https://registry.npmjs.org/next/-/next-15.3.0-canary.13.tgz", diff --git a/package.json b/package.json index b704d60f0a..d33e3b6376 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "netlify-cli": "23.6.0", "next": "^15.0.0-canary.28", "next-with-cache-handler-v2": "npm:next@15.3.0-canary.13", + "next-with-adapters": "npm:next@15.6.0-canary.20", "os": "^0.1.2", "outdent": "^0.8.0", "p-limit": "^6.0.0", diff --git a/src/adapter.ts b/src/adapter.ts new file mode 100644 index 0000000000..8c6b5a1ee1 --- /dev/null +++ b/src/adapter.ts @@ -0,0 +1,14 @@ +import type { NextAdapter } from 'next-with-adapters' + +const adapter: NextAdapter = { + name: 'Netlify', + modifyConfig(config) { + console.log('modifyConfig hook called') + return config + }, + async onBuildComplete(ctx) { + console.log('onBuildComplete hook called') + }, +} + +export default adapter diff --git a/src/index.ts b/src/index.ts index 27d9c1ff7b..b7f836a30f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -63,6 +63,11 @@ export const onPreBuild = async (options: NetlifyPluginOptions) => { await restoreBuildCache(ctx) } }) + + // We will have a build plugin that will contain the adapter, we will still use some build plugin features + // for operations that are more idiomatic to do in build plugin rather than adapter due to helpers we can + // use in a build plugin context. + process.env.NEXT_ADAPTER_PATH = `@netlify/plugin-nextjs/dist/adapter.js` } export const onBuild = async (options: NetlifyPluginOptions) => { From cee330c5b67fa09602a4f5f9b2c3f46dd9802ee6 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Mon, 22 Sep 2025 17:43:02 +0200 Subject: [PATCH 02/39] use modifyConfig to set standalone output --- src/adapter.ts | 3 +++ src/index.ts | 2 -- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/adapter.ts b/src/adapter.ts index 8c6b5a1ee1..a703ddd248 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -3,6 +3,9 @@ import type { NextAdapter } from 'next-with-adapters' const adapter: NextAdapter = { name: 'Netlify', modifyConfig(config) { + // Enable Next.js standalone mode at build time + config.output = 'standalone' + console.log('modifyConfig hook called') return config }, diff --git a/src/index.ts b/src/index.ts index b7f836a30f..6529ceeee9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -50,8 +50,6 @@ export const onPreBuild = async (options: NetlifyPluginOptions) => { } await tracer.withActiveSpan('onPreBuild', async () => { - // Enable Next.js standalone mode at build time - process.env.NEXT_PRIVATE_STANDALONE = 'true' const ctx = new PluginContext(options) if (options.constants.IS_LOCAL) { // Only clear directory if we are running locally as then we might have stale functions from previous From 0fe7e9abaf8308c54b4eccc0eb94a45ddce309c2 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Mon, 22 Sep 2025 18:11:28 +0200 Subject: [PATCH 03/39] setup loaderFile for next/image and avoid a rewrite --- src/adapter.ts | 7 ++++++- src/build/image-cdn.ts | 11 +---------- src/next-image-loader.cts | 12 ++++++++++++ 3 files changed, 19 insertions(+), 11 deletions(-) create mode 100644 src/next-image-loader.cts diff --git a/src/adapter.ts b/src/adapter.ts index a703ddd248..b4a9012e52 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -6,7 +6,12 @@ const adapter: NextAdapter = { // Enable Next.js standalone mode at build time config.output = 'standalone' - console.log('modifyConfig hook called') + if (config.images.loader === 'default') { + // Set up Netlify Image CDN image's loaderFile + config.images.loader = 'custom' + config.images.loaderFile = '@netlify/plugin-nextjs/dist/next-image-loader.cjs' + } + return config }, async onBuildComplete(ctx) { diff --git a/src/build/image-cdn.ts b/src/build/image-cdn.ts index 8572030724..355f5fc645 100644 --- a/src/build/image-cdn.ts +++ b/src/build/image-cdn.ts @@ -12,22 +12,13 @@ function generateRegexFromPattern(pattern: string): string { */ export const setImageConfig = async (ctx: PluginContext): Promise => { const { - images: { domains, remotePatterns, path: imageEndpointPath, loader: imageLoader }, + images: { domains, remotePatterns, loader: imageLoader }, } = await ctx.buildConfig if (imageLoader !== 'default') { return } ctx.netlifyConfig.redirects.push( - { - from: imageEndpointPath, - // w and q are too short to be used as params with id-length rule - // but we are forced to do so because of the next/image loader decides on their names - // eslint-disable-next-line id-length - query: { url: ':url', w: ':width', q: ':quality' }, - to: '/.netlify/images?url=:url&w=:width&q=:quality', - status: 200, - }, // when migrating from @netlify/plugin-nextjs@4 image redirect to ipx might be cached in the browser { from: '/_ipx/*', diff --git a/src/next-image-loader.cts b/src/next-image-loader.cts new file mode 100644 index 0000000000..e1c713c468 --- /dev/null +++ b/src/next-image-loader.cts @@ -0,0 +1,12 @@ +import type { ImageLoader } from 'next/dist/shared/lib/image-external.js' + +const netlifyImageLoader: ImageLoader = ({ src, width, quality }) => { + const url = new URL(`.netlify/images`, 'http://n') + url.searchParams.set('url', src) + url.searchParams.set('w', width.toString()) + url.searchParams.set('q', (quality || 75).toString()) + console.log(url) + return url.pathname + url.search +} + +export default netlifyImageLoader From ebf14077678e7cf5a9c7ce0aed9a79e628fb9c39 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Mon, 22 Sep 2025 18:22:26 +0200 Subject: [PATCH 04/39] test: run canary tests to use most recent adapters API --- .github/workflows/run-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 719e82df1d..5c57e1f5d0 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -38,7 +38,7 @@ jobs: elif [ "${{ github.event_name }}" = "schedule" ] || [ "${{ steps.check-labels.outputs.result }}" = "true" ]; then echo "matrix=[\"latest\", \"canary\", \"14.2.15\", \"13.5.1\"]" >> $GITHUB_OUTPUT else - echo "matrix=[\"latest\"]" >> $GITHUB_OUTPUT + echo "matrix=[\"canary\"]" >> $GITHUB_OUTPUT fi e2e: From 15e64f2d8b117d4a466376187e6fcfd4f05b3678 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Mon, 22 Sep 2025 18:26:29 +0200 Subject: [PATCH 05/39] fixup! setup loaderFile for next/image and avoid a rewrite --- src/adapter.ts | 1 + src/next-image-loader.cts | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/adapter.ts b/src/adapter.ts index b4a9012e52..81a7245e3a 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -8,6 +8,7 @@ const adapter: NextAdapter = { if (config.images.loader === 'default') { // Set up Netlify Image CDN image's loaderFile + // see https://nextjs.org/docs/app/api-reference/config/next-config-js/images config.images.loader = 'custom' config.images.loaderFile = '@netlify/plugin-nextjs/dist/next-image-loader.cjs' } diff --git a/src/next-image-loader.cts b/src/next-image-loader.cts index e1c713c468..05e63d4d82 100644 --- a/src/next-image-loader.cts +++ b/src/next-image-loader.cts @@ -5,7 +5,6 @@ const netlifyImageLoader: ImageLoader = ({ src, width, quality }) => { url.searchParams.set('url', src) url.searchParams.set('w', width.toString()) url.searchParams.set('q', (quality || 75).toString()) - console.log(url) return url.pathname + url.search } From ce070d62574d1dde9ab27bdf4c6756096495ec04 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Mon, 22 Sep 2025 19:12:49 +0200 Subject: [PATCH 06/39] move setting up remote images config to adapter --- src/adapter.ts | 91 +++++++++++++++++++++++++++++++++++++++++- src/build/image-cdn.ts | 68 +------------------------------ src/index.ts | 6 +-- 3 files changed, 94 insertions(+), 71 deletions(-) diff --git a/src/adapter.ts b/src/adapter.ts index 81a7245e3a..850f5daec5 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -1,4 +1,16 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' + import type { NextAdapter } from 'next-with-adapters' +import type { RemotePattern } from 'next-with-adapters/dist/shared/lib/image-config.js' +import { makeRe } from 'picomatch' + +const NETLIFY_FRAMEWORKS_API_CONFIG_PATH = '.netlify/v1/config.json' +const NETLIFY_IMAGE_LOADER_FILE = '@netlify/plugin-nextjs/dist/next-image-loader.cjs' + +function generateRegexFromPattern(pattern: string): string { + return makeRe(pattern).source +} const adapter: NextAdapter = { name: 'Netlify', @@ -10,13 +22,90 @@ const adapter: NextAdapter = { // Set up Netlify Image CDN image's loaderFile // see https://nextjs.org/docs/app/api-reference/config/next-config-js/images config.images.loader = 'custom' - config.images.loaderFile = '@netlify/plugin-nextjs/dist/next-image-loader.cjs' + config.images.loaderFile = NETLIFY_IMAGE_LOADER_FILE } return config }, async onBuildComplete(ctx) { console.log('onBuildComplete hook called') + + let frameworksAPIConfig: any = null + const { images } = ctx.config + if (images.loader === 'custom' && images.loaderFile === NETLIFY_IMAGE_LOADER_FILE) { + const { remotePatterns, domains } = images + // if Netlify image loader is used, configure allowed remote image sources + const remoteImageSources: string[] = [] + if (remotePatterns && remotePatterns.length !== 0) { + // convert images.remotePatterns to regexes for Frameworks API + for (const remotePattern of remotePatterns) { + if (remotePattern instanceof URL) { + // Note: even if URL notation is used in next.config.js, This will result in RemotePattern + // object here, so types for the complete config should not have URL as an possible type + throw new TypeError('Received not supported URL instance in remotePatterns') + } + let { protocol, hostname, port, pathname }: RemotePattern = remotePattern + + if (pathname) { + pathname = pathname.startsWith('/') ? pathname : `/${pathname}` + } + + const combinedRemotePattern = `${protocol ?? 'http?(s)'}://${hostname}${ + port ? `:${port}` : '' + }${pathname ?? '/**'}` + + try { + remoteImageSources.push(generateRegexFromPattern(combinedRemotePattern)) + } catch (error) { + throw new Error( + `Failed to generate Image CDN remote image regex from Next.js remote pattern: ${JSON.stringify( + { remotePattern, combinedRemotePattern }, + null, + 2, + )}`, + { + cause: error, + }, + ) + } + } + } + + if (domains && domains.length !== 0) { + for (const domain of domains) { + const patternFromDomain = `http?(s)://${domain}/**` + try { + remoteImageSources.push(generateRegexFromPattern(patternFromDomain)) + } catch (error) { + throw new Error( + `Failed to generate Image CDN remote image regex from Next.js domain: ${JSON.stringify( + { domain, patternFromDomain }, + null, + 2, + )}`, + { cause: error }, + ) + } + } + } + + if (remoteImageSources.length !== 0) { + // https://docs.netlify.com/build/frameworks/frameworks-api/#images + frameworksAPIConfig ??= {} + frameworksAPIConfig.images ??= {} + frameworksAPIConfig.images.remote_images = remoteImageSources + } + } + + if (frameworksAPIConfig) { + // write out config if there is any + // https://docs.netlify.com/build/frameworks/frameworks-api/#netlifyv1configjson + await mkdir(dirname(NETLIFY_FRAMEWORKS_API_CONFIG_PATH), { recursive: true }) + await writeFile( + NETLIFY_FRAMEWORKS_API_CONFIG_PATH, + JSON.stringify(frameworksAPIConfig, null, 2), + ) + } }, } diff --git a/src/build/image-cdn.ts b/src/build/image-cdn.ts index 355f5fc645..5d3ed58040 100644 --- a/src/build/image-cdn.ts +++ b/src/build/image-cdn.ts @@ -1,23 +1,9 @@ -import type { RemotePattern } from 'next/dist/shared/lib/image-config.js' -import { makeRe } from 'picomatch' - import { PluginContext } from './plugin-context.js' -function generateRegexFromPattern(pattern: string): string { - return makeRe(pattern).source -} - /** * Rewrite next/image to netlify image cdn */ -export const setImageConfig = async (ctx: PluginContext): Promise => { - const { - images: { domains, remotePatterns, loader: imageLoader }, - } = await ctx.buildConfig - if (imageLoader !== 'default') { - return - } - +export const setLegacyIpxRewrite = async (ctx: PluginContext): Promise => { ctx.netlifyConfig.redirects.push( // when migrating from @netlify/plugin-nextjs@4 image redirect to ipx might be cached in the browser { @@ -30,56 +16,4 @@ export const setImageConfig = async (ctx: PluginContext): Promise => { status: 200, }, ) - - if (remotePatterns?.length !== 0 || domains?.length !== 0) { - ctx.netlifyConfig.images ||= { remote_images: [] } - ctx.netlifyConfig.images.remote_images ||= [] - - if (remotePatterns && remotePatterns.length !== 0) { - for (const remotePattern of remotePatterns) { - let { protocol, hostname, port, pathname }: RemotePattern = remotePattern - - if (pathname) { - pathname = pathname.startsWith('/') ? pathname : `/${pathname}` - } - - const combinedRemotePattern = `${protocol ?? 'http?(s)'}://${hostname}${ - port ? `:${port}` : '' - }${pathname ?? '/**'}` - - try { - ctx.netlifyConfig.images.remote_images.push( - generateRegexFromPattern(combinedRemotePattern), - ) - } catch (error) { - ctx.failBuild( - `Failed to generate Image CDN remote image regex from Next.js remote pattern: ${JSON.stringify( - { remotePattern, combinedRemotePattern }, - null, - 2, - )}`, - error, - ) - } - } - } - - if (domains && domains.length !== 0) { - for (const domain of domains) { - const patternFromDomain = `http?(s)://${domain}/**` - try { - ctx.netlifyConfig.images.remote_images.push(generateRegexFromPattern(patternFromDomain)) - } catch (error) { - ctx.failBuild( - `Failed to generate Image CDN remote image regex from Next.js domain: ${JSON.stringify( - { domain, patternFromDomain }, - null, - 2, - )}`, - error, - ) - } - } - } - } } diff --git a/src/index.ts b/src/index.ts index 6529ceeee9..7724aa2e51 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,7 @@ import { } from './build/content/static.js' import { clearStaleEdgeHandlers, createEdgeHandlers } from './build/functions/edge.js' import { clearStaleServerHandlers, createServerHandler } from './build/functions/server.js' -import { setImageConfig } from './build/image-cdn.js' +import { setLegacyIpxRewrite } from './build/image-cdn.js' import { PluginContext } from './build/plugin-context.js' import { verifyAdvancedAPIRoutes, @@ -88,7 +88,7 @@ export const onBuild = async (options: NetlifyPluginOptions) => { // static exports only need to be uploaded to the CDN and setup /_next/image handler if (ctx.buildConfig.output === 'export') { - return Promise.all([copyStaticExport(ctx), setHeadersConfig(ctx), setImageConfig(ctx)]) + return Promise.all([copyStaticExport(ctx), setHeadersConfig(ctx), setLegacyIpxRewrite(ctx)]) } await verifyAdvancedAPIRoutes(ctx) @@ -101,7 +101,7 @@ export const onBuild = async (options: NetlifyPluginOptions) => { createServerHandler(ctx), createEdgeHandlers(ctx), setHeadersConfig(ctx), - setImageConfig(ctx), + setLegacyIpxRewrite(ctx), ]) }) } From 273fd1c96dd7b4c09ac482db9ae2f088464f39b6 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Tue, 23 Sep 2025 13:29:30 +0200 Subject: [PATCH 07/39] refactor adapter a bit and start creating modules per concern --- adapters-notes.md | 31 +++++++++ src/adapter.ts | 112 ------------------------------ src/adapter/adapter.ts | 46 ++++++++++++ src/adapter/image-cdn.ts | 92 ++++++++++++++++++++++++ src/adapter/next-image-loader.cts | 17 +++++ src/adapter/types.ts | 4 ++ src/index.ts | 5 +- src/next-image-loader.cts | 11 --- 8 files changed, 193 insertions(+), 125 deletions(-) create mode 100644 adapters-notes.md delete mode 100644 src/adapter.ts create mode 100644 src/adapter/adapter.ts create mode 100644 src/adapter/image-cdn.ts create mode 100644 src/adapter/next-image-loader.cts create mode 100644 src/adapter/types.ts delete mode 100644 src/next-image-loader.cts diff --git a/adapters-notes.md b/adapters-notes.md new file mode 100644 index 0000000000..12e0958c6c --- /dev/null +++ b/adapters-notes.md @@ -0,0 +1,31 @@ +## Feedback + +- Files from `public` not in `outputs.staticFiles` +- In `onBuildComplete` - `config.images.remotePatterns` type is `(RemotePattern | URL)[]` but in + reality `URL` inputs are converted to `RemotePattern` so type should be just `RemotePattern[]` + +## Plan + +1. There are some operations that are easier to do in a build plugin context due to helpers, so some + handling will remain in build plugin (cache save/restore, moving static assets dirs for + publishing them etc). + +2. We will use adapters API where it's most helpful: + +- adjusting next config: + - set standalone mode instead of using "private" env var (for now at least we will continue with + standalone mode as using outputs other than middleware require bigger changes which will be + explored in later phases) + - set image loader (url generator) to use Netlify Image CDN directly (no need for \_next/image + rewrite then) + - (maybe/explore) set build time cache handler to avoid having to read output of default cache + handler and convert those files into blobs to upload later +- use middleware output to generate middleware edge function +- don't glob for static files and use `outputs.staticFiles` instead +- don't read various manifest files manually and use provided context in `onBuildComplete` instead + +## To figure out + +- Can we export build time otel spans from adapter similarly how we do that now in a build plugin? +- Expose some constants from build plugin to adapter - what's best way to do that? (things like + packagePath, publishDir etc) diff --git a/src/adapter.ts b/src/adapter.ts deleted file mode 100644 index 850f5daec5..0000000000 --- a/src/adapter.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { mkdir, writeFile } from 'node:fs/promises' -import { dirname } from 'node:path' - -import type { NextAdapter } from 'next-with-adapters' -import type { RemotePattern } from 'next-with-adapters/dist/shared/lib/image-config.js' -import { makeRe } from 'picomatch' - -const NETLIFY_FRAMEWORKS_API_CONFIG_PATH = '.netlify/v1/config.json' -const NETLIFY_IMAGE_LOADER_FILE = '@netlify/plugin-nextjs/dist/next-image-loader.cjs' - -function generateRegexFromPattern(pattern: string): string { - return makeRe(pattern).source -} - -const adapter: NextAdapter = { - name: 'Netlify', - modifyConfig(config) { - // Enable Next.js standalone mode at build time - config.output = 'standalone' - - if (config.images.loader === 'default') { - // Set up Netlify Image CDN image's loaderFile - // see https://nextjs.org/docs/app/api-reference/config/next-config-js/images - config.images.loader = 'custom' - config.images.loaderFile = NETLIFY_IMAGE_LOADER_FILE - } - - return config - }, - async onBuildComplete(ctx) { - console.log('onBuildComplete hook called') - - let frameworksAPIConfig: any = null - const { images } = ctx.config - if (images.loader === 'custom' && images.loaderFile === NETLIFY_IMAGE_LOADER_FILE) { - const { remotePatterns, domains } = images - // if Netlify image loader is used, configure allowed remote image sources - const remoteImageSources: string[] = [] - if (remotePatterns && remotePatterns.length !== 0) { - // convert images.remotePatterns to regexes for Frameworks API - for (const remotePattern of remotePatterns) { - if (remotePattern instanceof URL) { - // Note: even if URL notation is used in next.config.js, This will result in RemotePattern - // object here, so types for the complete config should not have URL as an possible type - throw new TypeError('Received not supported URL instance in remotePatterns') - } - let { protocol, hostname, port, pathname }: RemotePattern = remotePattern - - if (pathname) { - pathname = pathname.startsWith('/') ? pathname : `/${pathname}` - } - - const combinedRemotePattern = `${protocol ?? 'http?(s)'}://${hostname}${ - port ? `:${port}` : '' - }${pathname ?? '/**'}` - - try { - remoteImageSources.push(generateRegexFromPattern(combinedRemotePattern)) - } catch (error) { - throw new Error( - `Failed to generate Image CDN remote image regex from Next.js remote pattern: ${JSON.stringify( - { remotePattern, combinedRemotePattern }, - null, - 2, - )}`, - { - cause: error, - }, - ) - } - } - } - - if (domains && domains.length !== 0) { - for (const domain of domains) { - const patternFromDomain = `http?(s)://${domain}/**` - try { - remoteImageSources.push(generateRegexFromPattern(patternFromDomain)) - } catch (error) { - throw new Error( - `Failed to generate Image CDN remote image regex from Next.js domain: ${JSON.stringify( - { domain, patternFromDomain }, - null, - 2, - )}`, - { cause: error }, - ) - } - } - } - - if (remoteImageSources.length !== 0) { - // https://docs.netlify.com/build/frameworks/frameworks-api/#images - frameworksAPIConfig ??= {} - frameworksAPIConfig.images ??= {} - frameworksAPIConfig.images.remote_images = remoteImageSources - } - } - - if (frameworksAPIConfig) { - // write out config if there is any - // https://docs.netlify.com/build/frameworks/frameworks-api/#netlifyv1configjson - await mkdir(dirname(NETLIFY_FRAMEWORKS_API_CONFIG_PATH), { recursive: true }) - await writeFile( - NETLIFY_FRAMEWORKS_API_CONFIG_PATH, - JSON.stringify(frameworksAPIConfig, null, 2), - ) - } - }, -} - -export default adapter diff --git a/src/adapter/adapter.ts b/src/adapter/adapter.ts new file mode 100644 index 0000000000..c6064800e8 --- /dev/null +++ b/src/adapter/adapter.ts @@ -0,0 +1,46 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' + +import type { NextAdapter } from 'next-with-adapters' + +import { + modifyConfig as modifyConfigForImageCDN, + onBuildComplete as onBuildCompleteForImageCDN, +} from './image-cdn.js' + +const NETLIFY_FRAMEWORKS_API_CONFIG_PATH = '.netlify/v1/config.json' + +const adapter: NextAdapter = { + name: 'Netlify', + modifyConfig(config) { + // Enable Next.js standalone mode at build time + config.output = 'standalone' + + modifyConfigForImageCDN(config) + + return config + }, + async onBuildComplete(ctx) { + console.log('onBuildComplete hook called') + + // TODO: do we have a type for this? https://docs.netlify.com/build/frameworks/frameworks-api/#netlifyv1configjson + let frameworksAPIConfig: any = null + + frameworksAPIConfig = onBuildCompleteForImageCDN(ctx, frameworksAPIConfig) + + if (frameworksAPIConfig) { + // write out config if there is any + await mkdir(dirname(NETLIFY_FRAMEWORKS_API_CONFIG_PATH), { recursive: true }) + await writeFile( + NETLIFY_FRAMEWORKS_API_CONFIG_PATH, + JSON.stringify(frameworksAPIConfig, null, 2), + ) + } + + // for dev/debugging purposes only + await writeFile('./onBuildComplete.json', JSON.stringify(ctx, null, 2)) + debugger + }, +} + +export default adapter diff --git a/src/adapter/image-cdn.ts b/src/adapter/image-cdn.ts new file mode 100644 index 0000000000..f748f8c842 --- /dev/null +++ b/src/adapter/image-cdn.ts @@ -0,0 +1,92 @@ +import { fileURLToPath } from 'node:url' + +import type { RemotePattern } from 'next-with-adapters/dist/shared/lib/image-config.js' +import { makeRe } from 'picomatch' + +import type { NextConfigComplete, OnBuildCompleteContext } from './types.js' + +const NETLIFY_IMAGE_LOADER_FILE = fileURLToPath(import.meta.resolve(`./next-image-loader.cjs`)) + +export function modifyConfig(config: NextConfigComplete) { + if (config.images.loader === 'default') { + // Set up Netlify Image CDN image's loaderFile + // see https://nextjs.org/docs/app/api-reference/config/next-config-js/images + config.images.loader = 'custom' + config.images.loaderFile = NETLIFY_IMAGE_LOADER_FILE + } +} + +function generateRegexFromPattern(pattern: string): string { + return makeRe(pattern).source +} + +export function onBuildComplete(ctx: OnBuildCompleteContext, frameworksAPIConfigArg: any) { + let frameworksAPIConfig: any = frameworksAPIConfigArg + + const { images } = ctx.config + if (images.loader === 'custom' && images.loaderFile === NETLIFY_IMAGE_LOADER_FILE) { + const { remotePatterns, domains } = images + // if Netlify image loader is used, configure allowed remote image sources + const remoteImageSources: string[] = [] + if (remotePatterns && remotePatterns.length !== 0) { + // convert images.remotePatterns to regexes for Frameworks API + for (const remotePattern of remotePatterns) { + if (remotePattern instanceof URL) { + // Note: even if URL notation is used in next.config.js, This will result in RemotePattern + // object here, so types for the complete config should not have URL as an possible type + throw new TypeError('Received not supported URL instance in remotePatterns') + } + let { protocol, hostname, port, pathname }: RemotePattern = remotePattern + + if (pathname) { + pathname = pathname.startsWith('/') ? pathname : `/${pathname}` + } + + const combinedRemotePattern = `${protocol ?? 'http?(s)'}://${hostname}${ + port ? `:${port}` : '' + }${pathname ?? '/**'}` + + try { + remoteImageSources.push(generateRegexFromPattern(combinedRemotePattern)) + } catch (error) { + throw new Error( + `Failed to generate Image CDN remote image regex from Next.js remote pattern: ${JSON.stringify( + { remotePattern, combinedRemotePattern }, + null, + 2, + )}`, + { + cause: error, + }, + ) + } + } + } + + if (domains && domains.length !== 0) { + for (const domain of domains) { + const patternFromDomain = `http?(s)://${domain}/**` + try { + remoteImageSources.push(generateRegexFromPattern(patternFromDomain)) + } catch (error) { + throw new Error( + `Failed to generate Image CDN remote image regex from Next.js domain: ${JSON.stringify( + { domain, patternFromDomain }, + null, + 2, + )}`, + { cause: error }, + ) + } + } + } + + if (remoteImageSources.length !== 0) { + // https://docs.netlify.com/build/frameworks/frameworks-api/#images + frameworksAPIConfig ??= {} + frameworksAPIConfig.images ??= {} + frameworksAPIConfig.images.remote_images = remoteImageSources + } + } + return frameworksAPIConfig +} diff --git a/src/adapter/next-image-loader.cts b/src/adapter/next-image-loader.cts new file mode 100644 index 0000000000..5b4a81c560 --- /dev/null +++ b/src/adapter/next-image-loader.cts @@ -0,0 +1,17 @@ +// this file is CJS because we add a `require` polyfill banner that attempt to use node:module in ESM modules +// this later cause problems because Next.js will use this file in browser context where node:module is not available +// ideally we would not add banner for this file and the we could make it ESM, but currently there is no conditional banners +// in esbuild, only workaround in form of this proof of concept https://www.npmjs.com/package/esbuild-plugin-transform-hook +// (or rolling our own esbuild plugin for that) + +import type { ImageLoader } from 'next/dist/shared/lib/image-external.js' + +const netlifyImageLoader: ImageLoader = ({ src, width, quality }) => { + const url = new URL(`.netlify/images`, 'http://n') + url.searchParams.set('url', src) + url.searchParams.set('w', width.toString()) + url.searchParams.set('q', (quality || 75).toString()) + return url.pathname + url.search +} + +export default netlifyImageLoader diff --git a/src/adapter/types.ts b/src/adapter/types.ts new file mode 100644 index 0000000000..970838f730 --- /dev/null +++ b/src/adapter/types.ts @@ -0,0 +1,4 @@ +import type { NextAdapter } from 'next-with-adapters' + +export type OnBuildCompleteContext = Parameters['onBuildComplete']>[0] +export type NextConfigComplete = OnBuildCompleteContext['config'] diff --git a/src/index.ts b/src/index.ts index 7724aa2e51..41eb239aa7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ -import { rm } from 'fs/promises' +import { rm } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' import type { NetlifyPluginOptions } from '@netlify/build' import { trace } from '@opentelemetry/api' @@ -65,7 +66,7 @@ export const onPreBuild = async (options: NetlifyPluginOptions) => { // We will have a build plugin that will contain the adapter, we will still use some build plugin features // for operations that are more idiomatic to do in build plugin rather than adapter due to helpers we can // use in a build plugin context. - process.env.NEXT_ADAPTER_PATH = `@netlify/plugin-nextjs/dist/adapter.js` + process.env.NEXT_ADAPTER_PATH = fileURLToPath(import.meta.resolve(`./adapter/adapter.js`)) } export const onBuild = async (options: NetlifyPluginOptions) => { diff --git a/src/next-image-loader.cts b/src/next-image-loader.cts deleted file mode 100644 index 05e63d4d82..0000000000 --- a/src/next-image-loader.cts +++ /dev/null @@ -1,11 +0,0 @@ -import type { ImageLoader } from 'next/dist/shared/lib/image-external.js' - -const netlifyImageLoader: ImageLoader = ({ src, width, quality }) => { - const url = new URL(`.netlify/images`, 'http://n') - url.searchParams.set('url', src) - url.searchParams.set('w', width.toString()) - url.searchParams.set('q', (quality || 75).toString()) - return url.pathname + url.search -} - -export default netlifyImageLoader From a698e1b7cd97f349145b76d18ab659da457dcfd5 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Tue, 23 Sep 2025 13:40:38 +0200 Subject: [PATCH 08/39] move legacy ipx redirect to adapter as well --- src/adapter/image-cdn.ts | 15 +++++- src/build/image-cdn.test.ts | 101 ------------------------------------ src/build/image-cdn.ts | 19 ------- src/index.ts | 4 +- 4 files changed, 14 insertions(+), 125 deletions(-) delete mode 100644 src/build/image-cdn.test.ts delete mode 100644 src/build/image-cdn.ts diff --git a/src/adapter/image-cdn.ts b/src/adapter/image-cdn.ts index f748f8c842..34cccaf434 100644 --- a/src/adapter/image-cdn.ts +++ b/src/adapter/image-cdn.ts @@ -21,7 +21,19 @@ function generateRegexFromPattern(pattern: string): string { } export function onBuildComplete(ctx: OnBuildCompleteContext, frameworksAPIConfigArg: any) { - let frameworksAPIConfig: any = frameworksAPIConfigArg + const frameworksAPIConfig: any = frameworksAPIConfigArg ?? {} + + // when migrating from @netlify/plugin-nextjs@4 image redirect to ipx might be cached in the browser + frameworksAPIConfig.redirects ??= [] + frameworksAPIConfig.redirects.push({ + from: '/_ipx/*', + // w and q are too short to be used as params with id-length rule + // but we are forced to do so because of the next/image loader decides on their names + // eslint-disable-next-line id-length + query: { url: ':url', w: ':width', q: ':quality' }, + to: '/.netlify/images?url=:url&w=:width&q=:quality', + status: 200, + }) const { images } = ctx.config if (images.loader === 'custom' && images.loaderFile === NETLIFY_IMAGE_LOADER_FILE) { @@ -83,7 +95,6 @@ export function onBuildComplete(ctx: OnBuildCompleteContext, frameworksAPIConfig if (remoteImageSources.length !== 0) { // https://docs.netlify.com/build/frameworks/frameworks-api/#images - frameworksAPIConfig ??= {} frameworksAPIConfig.images ??= {} frameworksAPIConfig.images.remote_images = remoteImageSources } diff --git a/src/build/image-cdn.test.ts b/src/build/image-cdn.test.ts deleted file mode 100644 index 508d7bc329..0000000000 --- a/src/build/image-cdn.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { NetlifyPluginOptions } from '@netlify/build' -import type { NextConfigComplete } from 'next/dist/server/config-shared.js' -import { beforeEach, describe, expect, test, TestContext } from 'vitest' - -import { setImageConfig } from './image-cdn.js' -import { PluginContext, type RequiredServerFilesManifest } from './plugin-context.js' - -type ImageCDNTestContext = TestContext & { - pluginContext: PluginContext -} - -describe('Image CDN', () => { - beforeEach((ctx) => { - ctx.pluginContext = new PluginContext({ - netlifyConfig: { - redirects: [], - }, - } as unknown as NetlifyPluginOptions) - }) - - test('adds redirect to Netlify Image CDN when default image loader is used', async (ctx) => { - ctx.pluginContext._requiredServerFiles = { - config: { - images: { - path: '/_next/image', - loader: 'default', - }, - } as NextConfigComplete, - } as RequiredServerFilesManifest - - await setImageConfig(ctx.pluginContext) - - expect(ctx.pluginContext.netlifyConfig.redirects).toEqual( - expect.arrayContaining([ - { - from: '/_next/image', - // eslint-disable-next-line id-length - query: { q: ':quality', url: ':url', w: ':width' }, - to: '/.netlify/images?url=:url&w=:width&q=:quality', - status: 200, - }, - ]), - ) - }) - - test('does not add redirect to Netlify Image CDN when non-default loader is used', async (ctx) => { - ctx.pluginContext._requiredServerFiles = { - config: { - images: { - path: '/_next/image', - loader: 'custom', - loaderFile: './custom-loader.js', - }, - } as NextConfigComplete, - } as RequiredServerFilesManifest - - await setImageConfig(ctx.pluginContext) - - expect(ctx.pluginContext.netlifyConfig.redirects).not.toEqual( - expect.arrayContaining([ - { - from: '/_next/image', - // eslint-disable-next-line id-length - query: { q: ':quality', url: ':url', w: ':width' }, - to: '/.netlify/images?url=:url&w=:width&q=:quality', - status: 200, - }, - ]), - ) - }) - - test('handles custom images.path', async (ctx) => { - ctx.pluginContext._requiredServerFiles = { - config: { - images: { - // Next.js automatically adds basePath to images.path (when user does not set custom `images.path` in their config) - // if user sets custom `images.path` - it will be used as-is (so user need to cover their basePath by themselves - // if they want to have it in their custom image endpoint - // see https://github.com/vercel/next.js/blob/bb105ef4fbfed9d96a93794eeaed956eda2116d8/packages/next/src/server/config.ts#L426-L432) - // either way `images.path` we get is final config with everything combined so we want to use it as-is - path: '/base/path/_custom/image/endpoint', - loader: 'default', - }, - } as NextConfigComplete, - } as RequiredServerFilesManifest - - await setImageConfig(ctx.pluginContext) - - expect(ctx.pluginContext.netlifyConfig.redirects).toEqual( - expect.arrayContaining([ - { - from: '/base/path/_custom/image/endpoint', - // eslint-disable-next-line id-length - query: { q: ':quality', url: ':url', w: ':width' }, - to: '/.netlify/images?url=:url&w=:width&q=:quality', - status: 200, - }, - ]), - ) - }) -}) diff --git a/src/build/image-cdn.ts b/src/build/image-cdn.ts deleted file mode 100644 index 5d3ed58040..0000000000 --- a/src/build/image-cdn.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { PluginContext } from './plugin-context.js' - -/** - * Rewrite next/image to netlify image cdn - */ -export const setLegacyIpxRewrite = async (ctx: PluginContext): Promise => { - ctx.netlifyConfig.redirects.push( - // when migrating from @netlify/plugin-nextjs@4 image redirect to ipx might be cached in the browser - { - from: '/_ipx/*', - // w and q are too short to be used as params with id-length rule - // but we are forced to do so because of the next/image loader decides on their names - // eslint-disable-next-line id-length - query: { url: ':url', w: ':width', q: ':quality' }, - to: '/.netlify/images?url=:url&w=:width&q=:quality', - status: 200, - }, - ) -} diff --git a/src/index.ts b/src/index.ts index 41eb239aa7..297124a42d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,7 +17,6 @@ import { } from './build/content/static.js' import { clearStaleEdgeHandlers, createEdgeHandlers } from './build/functions/edge.js' import { clearStaleServerHandlers, createServerHandler } from './build/functions/server.js' -import { setLegacyIpxRewrite } from './build/image-cdn.js' import { PluginContext } from './build/plugin-context.js' import { verifyAdvancedAPIRoutes, @@ -89,7 +88,7 @@ export const onBuild = async (options: NetlifyPluginOptions) => { // static exports only need to be uploaded to the CDN and setup /_next/image handler if (ctx.buildConfig.output === 'export') { - return Promise.all([copyStaticExport(ctx), setHeadersConfig(ctx), setLegacyIpxRewrite(ctx)]) + return Promise.all([copyStaticExport(ctx), setHeadersConfig(ctx)]) } await verifyAdvancedAPIRoutes(ctx) @@ -102,7 +101,6 @@ export const onBuild = async (options: NetlifyPluginOptions) => { createServerHandler(ctx), createEdgeHandlers(ctx), setHeadersConfig(ctx), - setLegacyIpxRewrite(ctx), ]) }) } From c4bf5e373a7313111e3e832eff899b64da18c4bb Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Tue, 23 Sep 2025 13:40:49 +0200 Subject: [PATCH 09/39] skip retries in CI for now --- playwright.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index 26627015e1..0c01e0e6f1 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -9,8 +9,8 @@ export default defineConfig({ fullyParallel: false, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: Boolean(process.env.CI), - /* Retry on CI only */ - retries: process.env.CI ? 2 : 0, + /* Retry on CI only - skipped for now as during exploration it is expected for lot of tests to fail and retries will slow down seeing tests that do pass */ + retries: process.env.CI ? 0 : 0, /* Limit the number of workers on CI, use default locally */ workers: process.env.CI ? 3 : undefined, globalSetup: './tests/test-setup-e2e.ts', From 4df59c128965ee9a7d68e25566a90a9905527014 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Tue, 23 Sep 2025 13:47:18 +0200 Subject: [PATCH 10/39] type frameworks api config --- src/adapter/adapter.ts | 4 ++-- src/adapter/image-cdn.ts | 11 +++++++---- src/adapter/types.ts | 5 +++++ 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/adapter/adapter.ts b/src/adapter/adapter.ts index c6064800e8..41239c7367 100644 --- a/src/adapter/adapter.ts +++ b/src/adapter/adapter.ts @@ -7,6 +7,7 @@ import { modifyConfig as modifyConfigForImageCDN, onBuildComplete as onBuildCompleteForImageCDN, } from './image-cdn.js' +import { FrameworksAPIConfig } from './types.js' const NETLIFY_FRAMEWORKS_API_CONFIG_PATH = '.netlify/v1/config.json' @@ -23,8 +24,7 @@ const adapter: NextAdapter = { async onBuildComplete(ctx) { console.log('onBuildComplete hook called') - // TODO: do we have a type for this? https://docs.netlify.com/build/frameworks/frameworks-api/#netlifyv1configjson - let frameworksAPIConfig: any = null + let frameworksAPIConfig: FrameworksAPIConfig = null frameworksAPIConfig = onBuildCompleteForImageCDN(ctx, frameworksAPIConfig) diff --git a/src/adapter/image-cdn.ts b/src/adapter/image-cdn.ts index 34cccaf434..ae3ec3d21f 100644 --- a/src/adapter/image-cdn.ts +++ b/src/adapter/image-cdn.ts @@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url' import type { RemotePattern } from 'next-with-adapters/dist/shared/lib/image-config.js' import { makeRe } from 'picomatch' -import type { NextConfigComplete, OnBuildCompleteContext } from './types.js' +import type { FrameworksAPIConfig, NextConfigComplete, OnBuildCompleteContext } from './types.js' const NETLIFY_IMAGE_LOADER_FILE = fileURLToPath(import.meta.resolve(`./next-image-loader.cjs`)) @@ -20,8 +20,11 @@ function generateRegexFromPattern(pattern: string): string { return makeRe(pattern).source } -export function onBuildComplete(ctx: OnBuildCompleteContext, frameworksAPIConfigArg: any) { - const frameworksAPIConfig: any = frameworksAPIConfigArg ?? {} +export function onBuildComplete( + ctx: OnBuildCompleteContext, + frameworksAPIConfigArg: FrameworksAPIConfig, +) { + const frameworksAPIConfig: FrameworksAPIConfig = frameworksAPIConfigArg ?? {} // when migrating from @netlify/plugin-nextjs@4 image redirect to ipx might be cached in the browser frameworksAPIConfig.redirects ??= [] @@ -95,7 +98,7 @@ export function onBuildComplete(ctx: OnBuildCompleteContext, frameworksAPIConfig if (remoteImageSources.length !== 0) { // https://docs.netlify.com/build/frameworks/frameworks-api/#images - frameworksAPIConfig.images ??= {} + frameworksAPIConfig.images ??= { remote_images: [] } frameworksAPIConfig.images.remote_images = remoteImageSources } } diff --git a/src/adapter/types.ts b/src/adapter/types.ts index 970838f730..1cf42c517d 100644 --- a/src/adapter/types.ts +++ b/src/adapter/types.ts @@ -1,4 +1,9 @@ +import type { NetlifyConfig } from '@netlify/build' import type { NextAdapter } from 'next-with-adapters' export type OnBuildCompleteContext = Parameters['onBuildComplete']>[0] export type NextConfigComplete = OnBuildCompleteContext['config'] + +export type FrameworksAPIConfig = Partial< + Pick +> | null From 66cd37f5105e4c733236302bf82be962f57e310f Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Tue, 23 Sep 2025 14:46:50 +0200 Subject: [PATCH 11/39] only set standalone if output is not export --- src/adapter/adapter.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/adapter/adapter.ts b/src/adapter/adapter.ts index 41239c7367..79428aeca7 100644 --- a/src/adapter/adapter.ts +++ b/src/adapter/adapter.ts @@ -14,8 +14,10 @@ const NETLIFY_FRAMEWORKS_API_CONFIG_PATH = '.netlify/v1/config.json' const adapter: NextAdapter = { name: 'Netlify', modifyConfig(config) { - // Enable Next.js standalone mode at build time - config.output = 'standalone' + if (config.output !== 'export') { + // Enable Next.js standalone mode at build time + config.output = 'standalone' + } modifyConfigForImageCDN(config) From 6c46c9db612cbd2134fd36a4093a13f12d650f73 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Tue, 23 Sep 2025 16:06:25 +0200 Subject: [PATCH 12/39] migrate immutable headers for next/static --- adapters-notes.md | 1 + src/adapter/adapter.ts | 8 +++++--- src/adapter/header.ts | 28 ++++++++++++++++++++++++++++ src/build/content/static.ts | 14 -------------- src/index.ts | 14 ++++++-------- 5 files changed, 40 insertions(+), 25 deletions(-) create mode 100644 src/adapter/header.ts diff --git a/adapters-notes.md b/adapters-notes.md index 12e0958c6c..17f827c3c3 100644 --- a/adapters-notes.md +++ b/adapters-notes.md @@ -3,6 +3,7 @@ - Files from `public` not in `outputs.staticFiles` - In `onBuildComplete` - `config.images.remotePatterns` type is `(RemotePattern | URL)[]` but in reality `URL` inputs are converted to `RemotePattern` so type should be just `RemotePattern[]` +- `routes.headers` does not contain immutable cache-control headers for \_next/static ## Plan diff --git a/src/adapter/adapter.ts b/src/adapter/adapter.ts index 79428aeca7..8e360b8a4c 100644 --- a/src/adapter/adapter.ts +++ b/src/adapter/adapter.ts @@ -3,6 +3,7 @@ import { dirname } from 'node:path' import type { NextAdapter } from 'next-with-adapters' +import { onBuildComplete as onBuildCompleteForHeaders } from './header.js' import { modifyConfig as modifyConfigForImageCDN, onBuildComplete as onBuildCompleteForImageCDN, @@ -23,12 +24,13 @@ const adapter: NextAdapter = { return config }, - async onBuildComplete(ctx) { + async onBuildComplete(nextAdapterContext) { console.log('onBuildComplete hook called') let frameworksAPIConfig: FrameworksAPIConfig = null - frameworksAPIConfig = onBuildCompleteForImageCDN(ctx, frameworksAPIConfig) + frameworksAPIConfig = onBuildCompleteForImageCDN(nextAdapterContext, frameworksAPIConfig) + frameworksAPIConfig = onBuildCompleteForHeaders(nextAdapterContext, frameworksAPIConfig) if (frameworksAPIConfig) { // write out config if there is any @@ -40,7 +42,7 @@ const adapter: NextAdapter = { } // for dev/debugging purposes only - await writeFile('./onBuildComplete.json', JSON.stringify(ctx, null, 2)) + await writeFile('./onBuildComplete.json', JSON.stringify(nextAdapterContext, null, 2)) debugger }, } diff --git a/src/adapter/header.ts b/src/adapter/header.ts new file mode 100644 index 0000000000..3f58e41d62 --- /dev/null +++ b/src/adapter/header.ts @@ -0,0 +1,28 @@ +import type { FrameworksAPIConfig, OnBuildCompleteContext } from './types.js' + +export function onBuildComplete( + ctx: OnBuildCompleteContext, + frameworksAPIConfigArg: FrameworksAPIConfig, +) { + const frameworksAPIConfig: FrameworksAPIConfig = frameworksAPIConfigArg ?? {} + + frameworksAPIConfig.headers ??= [] + + frameworksAPIConfig.headers.push({ + for: `${ctx.config.basePath}/_next/static/*`, + values: { + 'Cache-Control': 'public, max-age=31536000, immutable', + }, + }) + + // TODO: we should apply ctx.routes.headers here as well, but the matching + // is currently not compatible with anything we can express with our redirect engine + // { + // regex: "^(?:/((?:[^/]+?)(?:/(?:[^/]+?))*))?(?:/)?$" + // source: "/:path*" // <- this is defined in next.config + // } + // per https://docs.netlify.com/manage/routing/headers/#wildcards-and-placeholders-in-paths + // this is example of something we can't currently do + + return frameworksAPIConfig +} diff --git a/src/build/content/static.ts b/src/build/content/static.ts index 47dded47bb..a17f319e59 100644 --- a/src/build/content/static.ts +++ b/src/build/content/static.ts @@ -79,20 +79,6 @@ export const copyStaticAssets = async (ctx: PluginContext): Promise => { }) } -export const setHeadersConfig = async (ctx: PluginContext): Promise => { - // https://nextjs.org/docs/app/api-reference/config/next-config-js/headers#cache-control - // Next.js sets the Cache-Control header of public, max-age=31536000, immutable for truly - // immutable assets. It cannot be overridden. These immutable files contain a SHA-hash in - // the file name, so they can be safely cached indefinitely. - const { basePath } = ctx.buildConfig - ctx.netlifyConfig.headers.push({ - for: `${basePath}/_next/static/*`, - values: { - 'Cache-Control': 'public, max-age=31536000, immutable', - }, - }) -} - export const copyStaticExport = async (ctx: PluginContext): Promise => { await tracer.withActiveSpan('copyStaticExport', async () => { if (!ctx.exportDetail?.outDirectory) { diff --git a/src/index.ts b/src/index.ts index 297124a42d..0224d56ea6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,6 @@ import { copyStaticContent, copyStaticExport, publishStaticDir, - setHeadersConfig, unpublishStaticDir, } from './build/content/static.js' import { clearStaleEdgeHandlers, createEdgeHandlers } from './build/functions/edge.js' @@ -88,19 +87,18 @@ export const onBuild = async (options: NetlifyPluginOptions) => { // static exports only need to be uploaded to the CDN and setup /_next/image handler if (ctx.buildConfig.output === 'export') { - return Promise.all([copyStaticExport(ctx), setHeadersConfig(ctx)]) + return Promise.all([copyStaticExport(ctx)]) } await verifyAdvancedAPIRoutes(ctx) await verifyNetlifyFormsWorkaround(ctx) await Promise.all([ - copyStaticAssets(ctx), - copyStaticContent(ctx), - copyPrerenderedContent(ctx), - createServerHandler(ctx), - createEdgeHandlers(ctx), - setHeadersConfig(ctx), + copyStaticAssets(ctx), // this + copyStaticContent(ctx), // this + copyPrerenderedContent(ctx), // maybe this + createServerHandler(ctx), // not this while we use standalone + createEdgeHandlers(ctx), // this - middleware ]) }) } From 0c24a46a6ba2f3a5c4f79cbaf344f2b642d6fb61 Mon Sep 17 00:00:00 2001 From: Mateusz Bocian Date: Tue, 23 Sep 2025 10:58:18 -0400 Subject: [PATCH 13/39] testing out static file copying --- adapters-notes.md | 2 ++ src/adapter/adapter.ts | 5 +++++ src/adapter/static.ts | 23 +++++++++++++++++++++++ src/index.ts | 2 -- 4 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 src/adapter/static.ts diff --git a/adapters-notes.md b/adapters-notes.md index 17f827c3c3..be6ade7e1b 100644 --- a/adapters-notes.md +++ b/adapters-notes.md @@ -30,3 +30,5 @@ - Can we export build time otel spans from adapter similarly how we do that now in a build plugin? - Expose some constants from build plugin to adapter - what's best way to do that? (things like packagePath, publishDir etc) +- Looking forward - Platform change to accept a list of files to upload to cdn (avoids file system + operations such as `cp`) diff --git a/src/adapter/adapter.ts b/src/adapter/adapter.ts index 8e360b8a4c..eda4f8ea51 100644 --- a/src/adapter/adapter.ts +++ b/src/adapter/adapter.ts @@ -8,6 +8,7 @@ import { modifyConfig as modifyConfigForImageCDN, onBuildComplete as onBuildCompleteForImageCDN, } from './image-cdn.js' +import { onBuildComplete as onBuildCompleteForStaticFiles } from './static.js' import { FrameworksAPIConfig } from './types.js' const NETLIFY_FRAMEWORKS_API_CONFIG_PATH = '.netlify/v1/config.json' @@ -30,6 +31,10 @@ const adapter: NextAdapter = { let frameworksAPIConfig: FrameworksAPIConfig = null frameworksAPIConfig = onBuildCompleteForImageCDN(nextAdapterContext, frameworksAPIConfig) + frameworksAPIConfig = await onBuildCompleteForStaticFiles( + nextAdapterContext, + frameworksAPIConfig, + ) frameworksAPIConfig = onBuildCompleteForHeaders(nextAdapterContext, frameworksAPIConfig) if (frameworksAPIConfig) { diff --git a/src/adapter/static.ts b/src/adapter/static.ts new file mode 100644 index 0000000000..fa1bd3c2da --- /dev/null +++ b/src/adapter/static.ts @@ -0,0 +1,23 @@ +import { cp } from 'node:fs/promises' +import { join } from 'node:path/posix' + +import type { FrameworksAPIConfig, OnBuildCompleteContext } from './types.js' + +export async function onBuildComplete( + ctx: OnBuildCompleteContext, + frameworksAPIConfigArg: FrameworksAPIConfig, +) { + const frameworksAPIConfig: FrameworksAPIConfig = frameworksAPIConfigArg ?? {} + + for (const staticFile of ctx.outputs.staticFiles) { + try { + await cp(staticFile.filePath, join('./.netlify/static', staticFile.pathname), { + recursive: true, + }) + } catch (error) { + throw new Error(`Failed copying static assets`, { cause: error }) + } + } + + return frameworksAPIConfig +} diff --git a/src/index.ts b/src/index.ts index 0224d56ea6..56036d7eca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,6 @@ import { wrapTracer } from '@opentelemetry/api/experimental' import { restoreBuildCache, saveBuildCache } from './build/cache.js' import { copyPrerenderedContent } from './build/content/prerendered.js' import { - copyStaticAssets, copyStaticContent, copyStaticExport, publishStaticDir, @@ -94,7 +93,6 @@ export const onBuild = async (options: NetlifyPluginOptions) => { await verifyNetlifyFormsWorkaround(ctx) await Promise.all([ - copyStaticAssets(ctx), // this copyStaticContent(ctx), // this copyPrerenderedContent(ctx), // maybe this createServerHandler(ctx), // not this while we use standalone From 9f371fd679d582647bfe184e02a3132dd344286f Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Tue, 23 Sep 2025 17:21:48 +0200 Subject: [PATCH 14/39] remove no longer used --- src/build/content/static.ts | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/src/build/content/static.ts b/src/build/content/static.ts index a17f319e59..cb218f432b 100644 --- a/src/build/content/static.ts +++ b/src/build/content/static.ts @@ -54,31 +54,6 @@ export const copyStaticContent = async (ctx: PluginContext): Promise => { }) } -/** - * Copy static content to the static dir so it is uploaded to the CDN - */ -export const copyStaticAssets = async (ctx: PluginContext): Promise => { - return tracer.withActiveSpan('copyStaticAssets', async (span): Promise => { - try { - await rm(ctx.staticDir, { recursive: true, force: true }) - const { basePath } = await ctx.getRoutesManifest() - if (existsSync(ctx.resolveFromSiteDir('public'))) { - await cp(ctx.resolveFromSiteDir('public'), join(ctx.staticDir, basePath), { - recursive: true, - }) - } - if (existsSync(join(ctx.publishDir, 'static'))) { - await cp(join(ctx.publishDir, 'static'), join(ctx.staticDir, basePath, '_next/static'), { - recursive: true, - }) - } - } catch (error) { - span.end() - ctx.failBuild('Failed copying static assets', error) - } - }) -} - export const copyStaticExport = async (ctx: PluginContext): Promise => { await tracer.withActiveSpan('copyStaticExport', async () => { if (!ctx.exportDetail?.outDirectory) { From 4816bc375af36e7a58fa5e4896934c3c3f85ef3f Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Tue, 23 Sep 2025 17:34:25 +0200 Subject: [PATCH 15/39] maybe fix __vite_ssr_import_meta__ problems --- vitest.config.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/vitest.config.ts b/vitest.config.ts index e70537e12e..38f85556ab 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -78,5 +78,12 @@ export default defineConfig({ }, esbuild: { include: ['**/*.ts', '**/*.cts'], + footer: ` + if (typeof __vite_ssr_import_meta__ !== 'undefined') { + __vite_ssr_import_meta__.resolve = (path) => { + return 'file://' + require.resolve(path.replace('.js', '.ts')); + } + } + `, }, }) From 303ff66b7527f8cd4a7a4369846420886eda2ba3 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Tue, 23 Sep 2025 17:35:25 +0200 Subject: [PATCH 16/39] link to vitest issue --- vitest.config.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/vitest.config.ts b/vitest.config.ts index 38f85556ab..8ff7edb59e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -78,6 +78,8 @@ export default defineConfig({ }, esbuild: { include: ['**/*.ts', '**/*.cts'], + // https://github.com/vitest-dev/vitest/issues/6953, workaround for import.meta.resolve not being supported in vitest/esbuild + // that currently seems only fixed in prerelease version of vitest@4 footer: ` if (typeof __vite_ssr_import_meta__ !== 'undefined') { __vite_ssr_import_meta__.resolve = (path) => { From 650e534da82d3888a36d31c944d1183366af34a1 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Tue, 23 Sep 2025 19:05:52 +0200 Subject: [PATCH 17/39] move edge middleware setup to adapter --- adapters-notes.md | 4 + src/adapter/adapter.ts | 10 +- src/adapter/middleware.ts | 196 ++++++++++++++++++++++++++++++++++++++ src/index.ts | 1 - 4 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 src/adapter/middleware.ts diff --git a/adapters-notes.md b/adapters-notes.md index be6ade7e1b..2da8d0509c 100644 --- a/adapters-notes.md +++ b/adapters-notes.md @@ -4,6 +4,10 @@ - In `onBuildComplete` - `config.images.remotePatterns` type is `(RemotePattern | URL)[]` but in reality `URL` inputs are converted to `RemotePattern` so type should be just `RemotePattern[]` - `routes.headers` does not contain immutable cache-control headers for \_next/static +- `outputs.middleware` does not contain env that exist in `middleware-manifest.json` (i.e. + NEXT_SERVER_ACTIONS_ENCRYPTION_KEY, **NEXT_PREVIEW_MODE_ID, **NEXT_PREVIEW_MODE_SIGNING_KEY etc) +- `outputs.middleware.config.matchers` can be undefined per types - can that ever happen? Can we + just have empty array instead to simplify handling. ## Plan diff --git a/src/adapter/adapter.ts b/src/adapter/adapter.ts index eda4f8ea51..942a2763c8 100644 --- a/src/adapter/adapter.ts +++ b/src/adapter/adapter.ts @@ -8,6 +8,7 @@ import { modifyConfig as modifyConfigForImageCDN, onBuildComplete as onBuildCompleteForImageCDN, } from './image-cdn.js' +import { onBuildComplete as onBuildCompleteForMiddleware } from './middleware.js' import { onBuildComplete as onBuildCompleteForStaticFiles } from './static.js' import { FrameworksAPIConfig } from './types.js' @@ -26,11 +27,18 @@ const adapter: NextAdapter = { return config }, async onBuildComplete(nextAdapterContext) { + // for dev/debugging purposes only + await writeFile('./onBuildComplete.json', JSON.stringify(nextAdapterContext, null, 2)) + console.log('onBuildComplete hook called') let frameworksAPIConfig: FrameworksAPIConfig = null frameworksAPIConfig = onBuildCompleteForImageCDN(nextAdapterContext, frameworksAPIConfig) + frameworksAPIConfig = await onBuildCompleteForMiddleware( + nextAdapterContext, + frameworksAPIConfig, + ) frameworksAPIConfig = await onBuildCompleteForStaticFiles( nextAdapterContext, frameworksAPIConfig, @@ -46,8 +54,6 @@ const adapter: NextAdapter = { ) } - // for dev/debugging purposes only - await writeFile('./onBuildComplete.json', JSON.stringify(nextAdapterContext, null, 2)) debugger }, } diff --git a/src/adapter/middleware.ts b/src/adapter/middleware.ts new file mode 100644 index 0000000000..4edb2bee68 --- /dev/null +++ b/src/adapter/middleware.ts @@ -0,0 +1,196 @@ +import { dirname, join, parse } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { pathToRegexp } from 'path-to-regexp' + +import type { FrameworksAPIConfig, NextConfigComplete, OnBuildCompleteContext } from './types.js' +import { cp, mkdir, readFile, writeFile } from 'node:fs/promises' +import { glob } from 'fast-glob' + +const NETLIFY_FRAMEWORKS_API_EDGE_FUNCTIONS = '.netlify/v1/edge-functions' +const MIDDLEWARE_FUNCTION_NAME = 'middleware' + +const MIDDLEWARE_FUNCTION_DIR = join( + NETLIFY_FRAMEWORKS_API_EDGE_FUNCTIONS, + MIDDLEWARE_FUNCTION_NAME, +) + +const MODULE_DIR = fileURLToPath(new URL('.', import.meta.url)) +const PLUGIN_DIR = join(MODULE_DIR, '../..') + +export async function onBuildComplete( + ctx: OnBuildCompleteContext, + frameworksAPIConfigArg: FrameworksAPIConfig, +) { + const frameworksAPIConfig: FrameworksAPIConfig = frameworksAPIConfigArg ?? {} + + const { middleware } = ctx.outputs + if (!middleware) { + return frameworksAPIConfig + } + + if (middleware.runtime !== 'edge') { + // TODO: nodejs middleware + return frameworksAPIConfig + } + + await copyHandlerDependenciesForEdgeMiddleware(middleware) + await writeHandlerFile(middleware, ctx.config) + + return frameworksAPIConfig +} + +const copyHandlerDependenciesForEdgeMiddleware = async ( + middleware: Required['middleware'], +) => { + // const srcDir = join(ctx.standaloneDir, ctx.nextDistDir) + + const edgeRuntimeDir = join(PLUGIN_DIR, 'edge-runtime') + const shimPath = join(edgeRuntimeDir, 'shim/edge.js') + const shim = await readFile(shimPath, 'utf8') + + const parts = [shim] + + const outputFile = join(MIDDLEWARE_FUNCTION_DIR, `concatenated-file.js`) + + // TODO: env is not available in outputs.middleware + // if (env) { + // // Prepare environment variables for draft-mode (i.e. __NEXT_PREVIEW_MODE_ID, __NEXT_PREVIEW_MODE_SIGNING_KEY, __NEXT_PREVIEW_MODE_ENCRYPTION_KEY) + // for (const [key, value] of Object.entries(env)) { + // parts.push(`process.env.${key} = '${value}';`) + // } + // } + + for (const [relative, absolute] of Object.entries(middleware.assets)) { + if (absolute.endsWith('.wasm')) { + const data = await readFile(absolute) + + const { name } = parse(relative) + parts.push(`const ${name} = Uint8Array.from(${JSON.stringify([...data])})`) + } else if (absolute.endsWith('.js')) { + const entrypoint = await readFile(absolute, 'utf8') + parts.push(`;// Concatenated file: ${relative} \n`, entrypoint) + } + } + parts.push( + `const middlewareEntryKey = Object.keys(_ENTRIES).find(entryKey => entryKey.startsWith("middleware_${middleware.id}"));`, + // turbopack entries are promises so we await here to get actual entry + // non-turbopack entries are already resolved, so await does not change anything + `export default await _ENTRIES[middlewareEntryKey].default;`, + ) + await mkdir(dirname(outputFile), { recursive: true }) + + await writeFile(outputFile, parts.join('\n')) +} + +const writeHandlerFile = async ( + middleware: Required['middleware'], + nextConfig: NextConfigComplete, +) => { + const handlerRuntimeDirectory = join(MIDDLEWARE_FUNCTION_DIR, 'edge-runtime') + + // Copying the runtime files. These are the compatibility layer between + // Netlify Edge Functions and the Next.js edge runtime. + await copyRuntime(MIDDLEWARE_FUNCTION_DIR) + + // Writing a file with the matchers that should trigger this function. We'll + // read this file from the function at runtime. + await writeFile( + join(handlerRuntimeDirectory, 'matchers.json'), + JSON.stringify(middleware.config.matchers ?? []), + ) + + // The config is needed by the edge function to match and normalize URLs. To + // avoid shipping and parsing a large file at runtime, let's strip it down to + // just the properties that the edge function actually needs. + const minimalNextConfig = { + basePath: nextConfig.basePath, + i18n: nextConfig.i18n, + trailingSlash: nextConfig.trailingSlash, + skipMiddlewareUrlNormalize: nextConfig.skipMiddlewareUrlNormalize, + } + + await writeFile( + join(handlerRuntimeDirectory, 'next.config.json'), + JSON.stringify(minimalNextConfig), + ) + + const htmlRewriterWasm = await readFile( + join( + PLUGIN_DIR, + 'edge-runtime/vendor/deno.land/x/htmlrewriter@v1.0.0/pkg/htmlrewriter_bg.wasm', + ), + ) + + // Writing the function entry file. It wraps the middleware code with the + // compatibility layer mentioned above. + await writeFile( + join(MIDDLEWARE_FUNCTION_DIR, `middleware.js`), + ` + import { init as htmlRewriterInit } from './edge-runtime/vendor/deno.land/x/htmlrewriter@v1.0.0/src/index.ts' + import { handleMiddleware } from './edge-runtime/middleware.ts'; + import handler from './concatenated-file.js'; + + await htmlRewriterInit({ module_or_path: Uint8Array.from(${JSON.stringify([ + ...htmlRewriterWasm, + ])}) }); + + export default (req, context) => handleMiddleware(req, context, handler); + + export const config = ${JSON.stringify({ + pattern: augmentMatchers(middleware, nextConfig).map((matcher) => matcher.regexp), + cache: undefined, + })} + `, + ) +} + +const copyRuntime = async (handlerDirectory: string): Promise => { + const files = await glob('edge-runtime/**/*', { + cwd: PLUGIN_DIR, + ignore: ['**/*.test.ts'], + dot: true, + }) + await Promise.all( + files.map((path) => + cp(join(PLUGIN_DIR, path), join(handlerDirectory, path), { recursive: true }), + ), + ) +} + +/** + * When i18n is enabled the matchers assume that paths _always_ include the + * locale. We manually add an extra matcher for the original path without + * the locale to ensure that the edge function can handle it. + * We don't need to do this for data routes because they always have the locale. + */ +const augmentMatchers = ( + middleware: Required['middleware'], + nextConfig: NextConfigComplete, +) => { + const i18NConfig = nextConfig.i18n + if (!i18NConfig) { + return middleware.config.matchers ?? [] + } + return (middleware.config.matchers ?? []).flatMap((matcher) => { + if (matcher.originalSource && matcher.locale !== false) { + return [ + matcher.regexp + ? { + ...matcher, + // https://github.com/vercel/next.js/blob/5e236c9909a768dc93856fdfad53d4f4adc2db99/packages/next/src/build/analysis/get-page-static-info.ts#L332-L336 + // Next is producing pretty broad matcher for i18n locale. Presumably rest of their infrastructure protects this broad matcher + // from matching on non-locale paths. For us this becomes request entry point, so we need to narrow it down to just defined locales + // otherwise users might get unexpected matches on paths like `/api*` + regexp: matcher.regexp.replace(/\[\^\/\.]+/g, `(${i18NConfig.locales.join('|')})`), + } + : matcher, + { + ...matcher, + regexp: pathToRegexp(matcher.originalSource).source, + }, + ] + } + return matcher + }) +} diff --git a/src/index.ts b/src/index.ts index 56036d7eca..9fcafd52ad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -96,7 +96,6 @@ export const onBuild = async (options: NetlifyPluginOptions) => { copyStaticContent(ctx), // this copyPrerenderedContent(ctx), // maybe this createServerHandler(ctx), // not this while we use standalone - createEdgeHandlers(ctx), // this - middleware ]) }) } From 0e4bee3331213d5a890dab33789208b1533d16d0 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Tue, 23 Sep 2025 19:18:56 +0200 Subject: [PATCH 18/39] remove unit test for deleted copyStaticAssets --- src/build/content/static.test.ts | 203 +------------------------------ 1 file changed, 1 insertion(+), 202 deletions(-) diff --git a/src/build/content/static.test.ts b/src/build/content/static.test.ts index 6d1b811472..f0bba728b0 100644 --- a/src/build/content/static.test.ts +++ b/src/build/content/static.test.ts @@ -13,7 +13,7 @@ import { createFsFixture } from '../../../tests/utils/fixture.js' import { HtmlBlob } from '../../shared/blob-types.cjs' import { PluginContext, RequiredServerFilesManifest } from '../plugin-context.js' -import { copyStaticAssets, copyStaticContent } from './static.js' +import { copyStaticContent } from './static.js' type Context = FixtureTestContext & { pluginContext: PluginContext @@ -90,113 +90,6 @@ describe('Regular Repository layout', () => { } as NetlifyPluginOptions) }) - test('should clear the static directory contents', async ({ pluginContext }) => { - failBuildMock.mockImplementation(dontFailTest) - const { vol } = mockFileSystem({ - [`${pluginContext.staticDir}/remove-me.js`]: '', - }) - await copyStaticAssets(pluginContext) - expect(Object.keys(vol.toJSON())).toEqual( - expect.not.arrayContaining([`${pluginContext.staticDir}/remove-me.js`]), - ) - // routes manifest fails to load because it doesn't exist and we expect that to fail the build - expect(failBuildMock).toBeCalled() - }) - - test('should link static content from the publish directory to the static directory (no basePath)', async ({ - pluginContext, - ...ctx - }) => { - const { cwd } = await createFsFixtureWithBasePath( - { - '.next/static/test.js': '', - '.next/static/sub-dir/test2.js': '', - }, - ctx, - ) - - await copyStaticAssets(pluginContext) - expect(await readDirRecursive(cwd)).toEqual( - expect.arrayContaining([ - join(cwd, '.next/static/test.js'), - join(cwd, '.next/static/sub-dir/test2.js'), - join(pluginContext.staticDir, '/_next/static/test.js'), - join(pluginContext.staticDir, '/_next/static/sub-dir/test2.js'), - ]), - ) - }) - - test('should link static content from the publish directory to the static directory (with basePath)', async ({ - pluginContext, - ...ctx - }) => { - const { cwd } = await createFsFixtureWithBasePath( - { - '.next/static/test.js': '', - '.next/static/sub-dir/test2.js': '', - }, - ctx, - { basePath: '/base/path' }, - ) - - await copyStaticAssets(pluginContext) - expect(await readDirRecursive(cwd)).toEqual( - expect.arrayContaining([ - join(cwd, '.next/static/test.js'), - join(cwd, '.next/static/sub-dir/test2.js'), - join(pluginContext.staticDir, 'base/path/_next/static/test.js'), - join(pluginContext.staticDir, 'base/path/_next/static/sub-dir/test2.js'), - ]), - ) - }) - - test('should link static content from the public directory to the static directory (no basePath)', async ({ - pluginContext, - ...ctx - }) => { - const { cwd } = await createFsFixtureWithBasePath( - { - 'public/fake-image.svg': '', - 'public/another-asset.json': '', - }, - ctx, - ) - - await copyStaticAssets(pluginContext) - expect(await readDirRecursive(cwd)).toEqual( - expect.arrayContaining([ - join(cwd, 'public/another-asset.json'), - join(cwd, 'public/fake-image.svg'), - join(pluginContext.staticDir, '/another-asset.json'), - join(pluginContext.staticDir, '/fake-image.svg'), - ]), - ) - }) - - test('should link static content from the public directory to the static directory (with basePath)', async ({ - pluginContext, - ...ctx - }) => { - const { cwd } = await createFsFixtureWithBasePath( - { - 'public/fake-image.svg': '', - 'public/another-asset.json': '', - }, - ctx, - { basePath: '/base/path' }, - ) - - await copyStaticAssets(pluginContext) - expect(await readDirRecursive(cwd)).toEqual( - expect.arrayContaining([ - join(cwd, 'public/another-asset.json'), - join(cwd, 'public/fake-image.svg'), - join(pluginContext.staticDir, '/base/path/another-asset.json'), - join(pluginContext.staticDir, '/base/path/fake-image.svg'), - ]), - ) - }) - describe('should copy the static pages to the publish directory if there are no corresponding JSON files and mark wether html file is a fully static pages router page', () => { test('no i18n', async ({ pluginContext, ...ctx }) => { await createFsFixtureWithBasePath( @@ -353,100 +246,6 @@ describe('Mono Repository', () => { } as NetlifyPluginOptions) }) - test('should link static content from the publish directory to the static directory (no basePath)', async ({ - pluginContext, - ...ctx - }) => { - const { cwd } = await createFsFixtureWithBasePath( - { - 'apps/app-1/.next/static/test.js': '', - 'apps/app-1/.next/static/sub-dir/test2.js': '', - }, - ctx, - ) - - await copyStaticAssets(pluginContext) - expect(await readDirRecursive(cwd)).toEqual( - expect.arrayContaining([ - join(cwd, 'apps/app-1/.next/static/test.js'), - join(cwd, 'apps/app-1/.next/static/sub-dir/test2.js'), - join(pluginContext.staticDir, '/_next/static/test.js'), - join(pluginContext.staticDir, '/_next/static/sub-dir/test2.js'), - ]), - ) - }) - - test('should link static content from the publish directory to the static directory (with basePath)', async ({ - pluginContext, - ...ctx - }) => { - const { cwd } = await createFsFixtureWithBasePath( - { - 'apps/app-1/.next/static/test.js': '', - 'apps/app-1/.next/static/sub-dir/test2.js': '', - }, - ctx, - { basePath: '/base/path' }, - ) - - await copyStaticAssets(pluginContext) - expect(await readDirRecursive(cwd)).toEqual( - expect.arrayContaining([ - join(cwd, 'apps/app-1/.next/static/test.js'), - join(cwd, 'apps/app-1/.next/static/sub-dir/test2.js'), - join(pluginContext.staticDir, '/base/path/_next/static/test.js'), - join(pluginContext.staticDir, '/base/path/_next/static/sub-dir/test2.js'), - ]), - ) - }) - - test('should link static content from the public directory to the static directory (no basePath)', async ({ - pluginContext, - ...ctx - }) => { - const { cwd } = await createFsFixtureWithBasePath( - { - 'apps/app-1/public/fake-image.svg': '', - 'apps/app-1/public/another-asset.json': '', - }, - ctx, - ) - - await copyStaticAssets(pluginContext) - expect(await readDirRecursive(cwd)).toEqual( - expect.arrayContaining([ - join(cwd, 'apps/app-1/public/another-asset.json'), - join(cwd, 'apps/app-1/public/fake-image.svg'), - join(pluginContext.staticDir, '/another-asset.json'), - join(pluginContext.staticDir, '/fake-image.svg'), - ]), - ) - }) - - test('should link static content from the public directory to the static directory (with basePath)', async ({ - pluginContext, - ...ctx - }) => { - const { cwd } = await createFsFixtureWithBasePath( - { - 'apps/app-1/public/fake-image.svg': '', - 'apps/app-1/public/another-asset.json': '', - }, - ctx, - { basePath: '/base/path' }, - ) - - await copyStaticAssets(pluginContext) - expect(await readDirRecursive(cwd)).toEqual( - expect.arrayContaining([ - join(cwd, 'apps/app-1/public/another-asset.json'), - join(cwd, 'apps/app-1/public/fake-image.svg'), - join(pluginContext.staticDir, '/base/path/another-asset.json'), - join(pluginContext.staticDir, '/base/path/fake-image.svg'), - ]), - ) - }) - describe('should copy the static pages to the publish directory if there are no corresponding JSON files and mark wether html file is a fully static pages router page', () => { test('no i18n', async ({ pluginContext, ...ctx }) => { await createFsFixtureWithBasePath( From f369128d637d9cdfdc9edd135cf8847d77b7233c Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Tue, 23 Sep 2025 19:31:08 +0200 Subject: [PATCH 19/39] fix lint --- src/adapter/adapter.ts | 3 +-- src/adapter/middleware.ts | 4 ++-- src/build/content/static.test.ts | 14 +------------- src/index.ts | 2 +- 4 files changed, 5 insertions(+), 18 deletions(-) diff --git a/src/adapter/adapter.ts b/src/adapter/adapter.ts index 942a2763c8..aed852a03e 100644 --- a/src/adapter/adapter.ts +++ b/src/adapter/adapter.ts @@ -29,6 +29,7 @@ const adapter: NextAdapter = { async onBuildComplete(nextAdapterContext) { // for dev/debugging purposes only await writeFile('./onBuildComplete.json', JSON.stringify(nextAdapterContext, null, 2)) + // debugger console.log('onBuildComplete hook called') @@ -53,8 +54,6 @@ const adapter: NextAdapter = { JSON.stringify(frameworksAPIConfig, null, 2), ) } - - debugger }, } diff --git a/src/adapter/middleware.ts b/src/adapter/middleware.ts index 4edb2bee68..7c035cb567 100644 --- a/src/adapter/middleware.ts +++ b/src/adapter/middleware.ts @@ -1,11 +1,11 @@ +import { cp, mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname, join, parse } from 'node:path' import { fileURLToPath } from 'node:url' +import { glob } from 'fast-glob' import { pathToRegexp } from 'path-to-regexp' import type { FrameworksAPIConfig, NextConfigComplete, OnBuildCompleteContext } from './types.js' -import { cp, mkdir, readFile, writeFile } from 'node:fs/promises' -import { glob } from 'fast-glob' const NETLIFY_FRAMEWORKS_API_EDGE_FUNCTIONS = '.netlify/v1/edge-functions' const MIDDLEWARE_FUNCTION_NAME = 'middleware' diff --git a/src/build/content/static.test.ts b/src/build/content/static.test.ts index f0bba728b0..a483e9b30d 100644 --- a/src/build/content/static.test.ts +++ b/src/build/content/static.test.ts @@ -7,7 +7,7 @@ import glob from 'fast-glob' import type { PrerenderManifest } from 'next/dist/build/index.js' import { beforeEach, describe, expect, Mock, test, vi } from 'vitest' -import { decodeBlobKey, encodeBlobKey, mockFileSystem } from '../../../tests/index.js' +import { decodeBlobKey, encodeBlobKey } from '../../../tests/index.js' import { type FixtureTestContext } from '../../../tests/utils/contexts.js' import { createFsFixture } from '../../../tests/utils/fixture.js' import { HtmlBlob } from '../../shared/blob-types.cjs' @@ -57,20 +57,8 @@ const createFsFixtureWithBasePath = ( ) } -async function readDirRecursive(dir: string) { - const posixPaths = await glob('**/*', { cwd: dir, dot: true, absolute: true }) - // glob always returns unix-style paths, even on Windows! - // To compare them more easily in our tests running on Windows, we convert them to the platform-specific paths. - const paths = posixPaths.map((posixPath) => join(posixPath)) - return paths -} - let failBuildMock: Mock -const dontFailTest: PluginContext['utils']['build']['failBuild'] = () => { - return undefined as never -} - describe('Regular Repository layout', () => { beforeEach((ctx) => { failBuildMock = vi.fn((msg, err) => { diff --git a/src/index.ts b/src/index.ts index 9fcafd52ad..f08a496ec3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,7 @@ import { publishStaticDir, unpublishStaticDir, } from './build/content/static.js' -import { clearStaleEdgeHandlers, createEdgeHandlers } from './build/functions/edge.js' +import { clearStaleEdgeHandlers } from './build/functions/edge.js' import { clearStaleServerHandlers, createServerHandler } from './build/functions/server.js' import { PluginContext } from './build/plugin-context.js' import { From 204cb6687111e18643f8cb4555eed84bb48a33a3 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Tue, 23 Sep 2025 19:58:20 +0200 Subject: [PATCH 20/39] update notes --- adapters-notes.md | 38 +++++++++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/adapters-notes.md b/adapters-notes.md index 2da8d0509c..c4d9e1d586 100644 --- a/adapters-notes.md +++ b/adapters-notes.md @@ -1,13 +1,31 @@ ## Feedback -- Files from `public` not in `outputs.staticFiles` +- Files from `public` directory not listed in `outputs.staticFiles` - In `onBuildComplete` - `config.images.remotePatterns` type is `(RemotePattern | URL)[]` but in reality `URL` inputs are converted to `RemotePattern` so type should be just `RemotePattern[]` -- `routes.headers` does not contain immutable cache-control headers for \_next/static +- `routes.headers` does not contain immutable cache-control headers for `_next/static` - `outputs.middleware` does not contain env that exist in `middleware-manifest.json` (i.e. - NEXT_SERVER_ACTIONS_ENCRYPTION_KEY, **NEXT_PREVIEW_MODE_ID, **NEXT_PREVIEW_MODE_SIGNING_KEY etc) + `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY`, `NEXT_PREVIEW_MODE_ID`, `NEXT_PREVIEW_MODE_SIGNING_KEY` etc) - `outputs.middleware.config.matchers` can be undefined per types - can that ever happen? Can we just have empty array instead to simplify handling. +- `outputs.staticFiles` (i18n enabled) custom fully static (no `getStaticProps`) `/pages/404.js` + `filePath` point to not existing file (it doesn't have i18n locale prefix in `staticFiles` array, + actual 404.html are written to i18n locale prefixed directories) +- `outputs.staticFiles` (i18n enabled) custom `/pages/404.js` with `getStaticProps` result in fatal + `Error: Invariant: failed to find source route /en/404 for prerender /en/404` directly from + Next.js: + + ``` + ⨯ Failed to run onBuildComplete from Netlify + + > Build error occurred + Error: Invariant: failed to find source route /en/404 for prerender /en/404 + ``` + + (additionally - invariant is reported as failing to run `onBuildComplete` from adapter, but it + happens before adapter's `onBuildComplete` runs, would be good to clear this up a bit so users + could report issues in correct place in such cases. Not that important for nearest future / not + blocking) ## Plan @@ -18,15 +36,15 @@ 2. We will use adapters API where it's most helpful: - adjusting next config: - - set standalone mode instead of using "private" env var (for now at least we will continue with - standalone mode as using outputs other than middleware require bigger changes which will be + - [done] set standalone mode instead of using "private" env var (for now at least we will continue + with standalone mode as using outputs other than middleware require bigger changes which will be explored in later phases) - - set image loader (url generator) to use Netlify Image CDN directly (no need for \_next/image - rewrite then) + - [done] set image loader (url generator) to use Netlify Image CDN directly (no need for + \_next/image rewrite then) - (maybe/explore) set build time cache handler to avoid having to read output of default cache handler and convert those files into blobs to upload later -- use middleware output to generate middleware edge function -- don't glob for static files and use `outputs.staticFiles` instead +- [partially done - for edge runtime] use middleware output to generate middleware edge function +- [done] don't glob for static files and use `outputs.staticFiles` instead - don't read various manifest files manually and use provided context in `onBuildComplete` instead ## To figure out @@ -36,3 +54,5 @@ packagePath, publishDir etc) - Looking forward - Platform change to accept a list of files to upload to cdn (avoids file system operations such as `cp`) +- Looking forward - allow using regexes for static headers matcher (needed to apply next.config.js + defined headers to apply to static assets) From 1799786f54d6895493b769e6aa5cebbffe388e6a Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 24 Sep 2025 12:02:21 +0200 Subject: [PATCH 21/39] test: adjust tests to look for .netlify/images and not _next/image --- adapters-notes.md | 21 ++++++++++++++------- tests/e2e/export.test.ts | 4 ++-- tests/e2e/simple-app.test.ts | 16 ++++++++-------- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/adapters-notes.md b/adapters-notes.md index c4d9e1d586..3d959d889b 100644 --- a/adapters-notes.md +++ b/adapters-notes.md @@ -1,13 +1,19 @@ ## Feedback -- Files from `public` directory not listed in `outputs.staticFiles` -- In `onBuildComplete` - `config.images.remotePatterns` type is `(RemotePattern | URL)[]` but in - reality `URL` inputs are converted to `RemotePattern` so type should be just `RemotePattern[]` +- Files from `public` directory not listed in `outputs.staticFiles`. Should they be? - `routes.headers` does not contain immutable cache-control headers for `_next/static` -- `outputs.middleware` does not contain env that exist in `middleware-manifest.json` (i.e. - `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY`, `NEXT_PREVIEW_MODE_ID`, `NEXT_PREVIEW_MODE_SIGNING_KEY` etc) +- In `onBuildComplete` - `config.images.remotePatterns` type is `(RemotePattern | URL)[]` but in + reality `URL` inputs are converted to `RemotePattern` so type should be just `RemotePattern[]` in + `onBuildComplete` (this would require different config type for `modifyConfig` (allow inputs + here?) and `onBuildComplete` (final, normalized config shape)?) - `outputs.middleware.config.matchers` can be undefined per types - can that ever happen? Can we - just have empty array instead to simplify handling. + just have empty array instead to simplify handling (possibly similar as above point where type is + for the input, while "output" will have a default matcher if not defined by user). +- `outputs.middleware` does not contain `env` that exist in `middleware-manifest.json` (i.e. + `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY`, `NEXT_PREVIEW_MODE_ID`, `NEXT_PREVIEW_MODE_SIGNING_KEY` etc) + or `wasm` (tho wasm files are included in assets, so I think I have a way to support those as-is, + but need to to make some assumption about using extension-less file name of wasm file as + identifier) - `outputs.staticFiles` (i18n enabled) custom fully static (no `getStaticProps`) `/pages/404.js` `filePath` point to not existing file (it doesn't have i18n locale prefix in `staticFiles` array, actual 404.html are written to i18n locale prefixed directories) @@ -45,7 +51,8 @@ handler and convert those files into blobs to upload later - [partially done - for edge runtime] use middleware output to generate middleware edge function - [done] don't glob for static files and use `outputs.staticFiles` instead -- don't read various manifest files manually and use provided context in `onBuildComplete` instead +- note any remaining manual manifest files reading in build plugin once everything that could be + adjusted was handled ## To figure out diff --git a/tests/e2e/export.test.ts b/tests/e2e/export.test.ts index ec930d0ff8..9057720ef4 100644 --- a/tests/e2e/export.test.ts +++ b/tests/e2e/export.test.ts @@ -54,12 +54,12 @@ test('Renders the Home page correctly with output export and custom dist dir', a test.describe('next/image is using Netlify Image CDN', () => { test('Local images', async ({ page, outputExport }) => { - const nextImageResponsePromise = page.waitForResponse('**/_next/image**') + const nextImageResponsePromise = page.waitForResponse('**/.netlify/images**') await page.goto(`${outputExport.url}/image/local`) const nextImageResponse = await nextImageResponsePromise - expect(nextImageResponse.request().url()).toContain('_next/image?url=%2Fsquirrel.jpg') + expect(nextImageResponse.request().url()).toContain('.netlify/images?url=%2Fsquirrel.jpg') expect(nextImageResponse.status()).toBe(200) // ensure next/image is using Image CDN diff --git a/tests/e2e/simple-app.test.ts b/tests/e2e/simple-app.test.ts index fb790afcff..1d0ee82a17 100644 --- a/tests/e2e/simple-app.test.ts +++ b/tests/e2e/simple-app.test.ts @@ -110,12 +110,12 @@ test.skip('streams stale responses', async ({ simple }) => { test.describe('next/image is using Netlify Image CDN', () => { test('Local images', async ({ page, simple }) => { - const nextImageResponsePromise = page.waitForResponse('**/_next/image**') + const nextImageResponsePromise = page.waitForResponse('**/.netlify/images**') await page.goto(`${simple.url}/image/local`) const nextImageResponse = await nextImageResponsePromise - expect(nextImageResponse.request().url()).toContain('_next/image?url=%2Fsquirrel.jpg') + expect(nextImageResponse.request().url()).toContain('.netlify/images?url=%2Fsquirrel.jpg') expect(nextImageResponse.status()).toBe(200) // ensure next/image is using Image CDN @@ -131,14 +131,14 @@ test.describe('next/image is using Netlify Image CDN', () => { page, simple, }) => { - const nextImageResponsePromise = page.waitForResponse('**/_next/image**') + const nextImageResponsePromise = page.waitForResponse('**/.netlify/images**') await page.goto(`${simple.url}/image/remote-pattern-1`) const nextImageResponse = await nextImageResponsePromise expect(nextImageResponse.url()).toContain( - `_next/image?url=${encodeURIComponent( + `.netlify/images?url=${encodeURIComponent( 'https://images.unsplash.com/photo-1574870111867-089730e5a72b', )}`, ) @@ -155,14 +155,14 @@ test.describe('next/image is using Netlify Image CDN', () => { page, simple, }) => { - const nextImageResponsePromise = page.waitForResponse('**/_next/image**') + const nextImageResponsePromise = page.waitForResponse('**/.netlify/images**') await page.goto(`${simple.url}/image/remote-pattern-2`) const nextImageResponse = await nextImageResponsePromise expect(nextImageResponse.url()).toContain( - `_next/image?url=${encodeURIComponent( + `.netlify/images?url=${encodeURIComponent( 'https://cdn.pixabay.com/photo/2017/02/20/18/03/cat-2083492_1280.jpg', )}`, ) @@ -176,14 +176,14 @@ test.describe('next/image is using Netlify Image CDN', () => { }) test('Remote images: domains', async ({ page, simple }) => { - const nextImageResponsePromise = page.waitForResponse('**/_next/image**') + const nextImageResponsePromise = page.waitForResponse('**/.netlify/images**') await page.goto(`${simple.url}/image/remote-domain`) const nextImageResponse = await nextImageResponsePromise expect(nextImageResponse.url()).toContain( - `_next/image?url=${encodeURIComponent( + `.netlify/images?url=${encodeURIComponent( 'https://images.pexels.com/photos/406014/pexels-photo-406014.jpeg', )}`, ) From 242d71f4d5d88bb4d3e5be91b7b89ac986a5adac Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 24 Sep 2025 12:54:11 +0200 Subject: [PATCH 22/39] lets try to annotate some tests to see if it helps with finding common causes --- tests/e2e/dynamic-cms.test.ts | 202 +-- tests/e2e/export.test.ts | 110 +- tests/e2e/middleware.test.ts | 985 ++++++------ tests/e2e/nx-integrated.test.ts | 89 +- tests/e2e/page-router.test.ts | 2392 +++++++++++++++-------------- tests/e2e/simple-app.test.ts | 577 +++---- tests/utils/create-e2e-fixture.ts | 2 +- tests/utils/playwright-helpers.ts | 45 + 8 files changed, 2295 insertions(+), 2107 deletions(-) diff --git a/tests/e2e/dynamic-cms.test.ts b/tests/e2e/dynamic-cms.test.ts index fe8d6df551..6378cf3ae6 100644 --- a/tests/e2e/dynamic-cms.test.ts +++ b/tests/e2e/dynamic-cms.test.ts @@ -1,108 +1,114 @@ import { expect } from '@playwright/test' -import { test } from '../utils/playwright-helpers.js' +import { generateTestTags, test } from '../utils/playwright-helpers.js' -test.describe('Dynamic CMS', () => { - test.describe('Invalidates 404 pages from durable cache', () => { - // using postFix allows to rerun tests without having to redeploy the app because paths/keys will be unique for each test run - const postFix = Date.now() - for (const { label, contentKey, expectedCacheTag, urlPath, pathToRevalidate } of [ - { - label: 'Invalidates 404 html from durable cache (implicit default locale)', - urlPath: `/content/html-implicit-default-locale-${postFix}`, - contentKey: `html-implicit-default-locale-${postFix}`, - expectedCacheTag: `_n_t_/en/content/html-implicit-default-locale-${postFix}`, - }, - { - label: 'Invalidates 404 html from durable cache (explicit default locale)', - urlPath: `/en/content/html-explicit-default-locale-${postFix}`, - contentKey: `html-explicit-default-locale-${postFix}`, - expectedCacheTag: `_n_t_/en/content/html-explicit-default-locale-${postFix}`, - }, - // json paths don't have implicit locale routing - { - label: 'Invalidates 404 json from durable cache (default locale)', - urlPath: `/_next/data/build-id/en/content/json-default-locale-${postFix}.json`, - // for html, we can use html path as param for revalidate, - // for json we can't use json path and instead use one of html paths - // let's use implicit default locale here, as we will have another case for - // non-default locale which will have to use explicit one - pathToRevalidate: `/content/json-default-locale-${postFix}`, - contentKey: `json-default-locale-${postFix}`, - expectedCacheTag: `_n_t_/en/content/json-default-locale-${postFix}`, - }, - { - label: 'Invalidates 404 html from durable cache (non-default locale)', - urlPath: `/fr/content/html-non-default-locale-${postFix}`, - contentKey: `html-non-default-locale-${postFix}`, - expectedCacheTag: `_n_t_/fr/content/html-non-default-locale-${postFix}`, - }, - { - label: 'Invalidates 404 json from durable cache (non-default locale)', - urlPath: `/_next/data/build-id/fr/content/json-non-default-locale-${postFix}.json`, - pathToRevalidate: `/fr/content/json-non-default-locale-${postFix}`, - contentKey: `json-non-default-locale-${postFix}`, - expectedCacheTag: `_n_t_/fr/content/json-non-default-locale-${postFix}`, - }, - ]) { - test(label, async ({ page, dynamicCms }) => { - const routeUrl = new URL(urlPath, dynamicCms.url).href - const revalidateAPiUrl = new URL( - `/api/revalidate?path=${pathToRevalidate ?? urlPath}`, - dynamicCms.url, - ).href +test.describe( + 'Dynamic CMS', + { + tag: generateTestTags({ pagesRouter: true, i18n: true }), + }, + () => { + test.describe('Invalidates 404 pages from durable cache', () => { + // using postFix allows to rerun tests without having to redeploy the app because paths/keys will be unique for each test run + const postFix = Date.now() + for (const { label, contentKey, expectedCacheTag, urlPath, pathToRevalidate } of [ + { + label: 'Invalidates 404 html from durable cache (implicit default locale)', + urlPath: `/content/html-implicit-default-locale-${postFix}`, + contentKey: `html-implicit-default-locale-${postFix}`, + expectedCacheTag: `_n_t_/en/content/html-implicit-default-locale-${postFix}`, + }, + { + label: 'Invalidates 404 html from durable cache (explicit default locale)', + urlPath: `/en/content/html-explicit-default-locale-${postFix}`, + contentKey: `html-explicit-default-locale-${postFix}`, + expectedCacheTag: `_n_t_/en/content/html-explicit-default-locale-${postFix}`, + }, + // json paths don't have implicit locale routing + { + label: 'Invalidates 404 json from durable cache (default locale)', + urlPath: `/_next/data/build-id/en/content/json-default-locale-${postFix}.json`, + // for html, we can use html path as param for revalidate, + // for json we can't use json path and instead use one of html paths + // let's use implicit default locale here, as we will have another case for + // non-default locale which will have to use explicit one + pathToRevalidate: `/content/json-default-locale-${postFix}`, + contentKey: `json-default-locale-${postFix}`, + expectedCacheTag: `_n_t_/en/content/json-default-locale-${postFix}`, + }, + { + label: 'Invalidates 404 html from durable cache (non-default locale)', + urlPath: `/fr/content/html-non-default-locale-${postFix}`, + contentKey: `html-non-default-locale-${postFix}`, + expectedCacheTag: `_n_t_/fr/content/html-non-default-locale-${postFix}`, + }, + { + label: 'Invalidates 404 json from durable cache (non-default locale)', + urlPath: `/_next/data/build-id/fr/content/json-non-default-locale-${postFix}.json`, + pathToRevalidate: `/fr/content/json-non-default-locale-${postFix}`, + contentKey: `json-non-default-locale-${postFix}`, + expectedCacheTag: `_n_t_/fr/content/json-non-default-locale-${postFix}`, + }, + ]) { + test(label, async ({ page, dynamicCms }) => { + const routeUrl = new URL(urlPath, dynamicCms.url).href + const revalidateAPiUrl = new URL( + `/api/revalidate?path=${pathToRevalidate ?? urlPath}`, + dynamicCms.url, + ).href - // 1. Verify the status and headers of the dynamic page - const response1 = await page.goto(routeUrl) - const headers1 = response1?.headers() || {} + // 1. Verify the status and headers of the dynamic page + const response1 = await page.goto(routeUrl) + const headers1 = response1?.headers() || {} - expect(response1?.status()).toEqual(404) - expect(headers1['cache-control']).toEqual('public,max-age=0,must-revalidate') - expect(headers1['cache-status']).toMatch( - /"Next.js"; fwd=miss\s*(,|\n)\s*"Netlify Durable"; fwd=uri-miss; stored\s*(, |\n)\s*"Netlify Edge"; fwd=miss/, - ) - expect(headers1['debug-netlify-cache-tag']).toEqual(expectedCacheTag) - expect(headers1['debug-netlify-cdn-cache-control']).toMatch( - /s-maxage=31536000,( stale-while-revalidate=31536000,)? durable/, - ) + expect(response1?.status()).toEqual(404) + expect(headers1['cache-control']).toEqual('public,max-age=0,must-revalidate') + expect(headers1['cache-status']).toMatch( + /"Next.js"; fwd=miss\s*(,|\n)\s*"Netlify Durable"; fwd=uri-miss; stored\s*(, |\n)\s*"Netlify Edge"; fwd=miss/, + ) + expect(headers1['debug-netlify-cache-tag']).toEqual(expectedCacheTag) + expect(headers1['debug-netlify-cdn-cache-control']).toMatch( + /s-maxage=31536000,( stale-while-revalidate=31536000,)? durable/, + ) - // 2. Publish the blob, revalidate the dynamic page, and wait to regenerate - await page.goto(new URL(`/cms/publish/${contentKey}`, dynamicCms.url).href) - await page.goto(revalidateAPiUrl) - await page.waitForTimeout(1000) + // 2. Publish the blob, revalidate the dynamic page, and wait to regenerate + await page.goto(new URL(`/cms/publish/${contentKey}`, dynamicCms.url).href) + await page.goto(revalidateAPiUrl) + await page.waitForTimeout(1000) - // 3. Verify the status and headers of the dynamic page - const response2 = await page.goto(routeUrl) - const headers2 = response2?.headers() || {} + // 3. Verify the status and headers of the dynamic page + const response2 = await page.goto(routeUrl) + const headers2 = response2?.headers() || {} - expect(response2?.status()).toEqual(200) - expect(headers2['cache-control']).toEqual('public,max-age=0,must-revalidate') - expect(headers2['cache-status']).toMatch( - /"Next.js"; hit\s*(,|\n)\s*"Netlify Durable"; fwd=stale; ttl=[0-9]+; stored\s*(,|\n)\s*"Netlify Edge"; fwd=(stale|miss)/, - ) - expect(headers2['debug-netlify-cache-tag']).toEqual(expectedCacheTag) - expect(headers2['debug-netlify-cdn-cache-control']).toMatch( - /s-maxage=31536000,( stale-while-revalidate=31536000,)? durable/, - ) + expect(response2?.status()).toEqual(200) + expect(headers2['cache-control']).toEqual('public,max-age=0,must-revalidate') + expect(headers2['cache-status']).toMatch( + /"Next.js"; hit\s*(,|\n)\s*"Netlify Durable"; fwd=stale; ttl=[0-9]+; stored\s*(,|\n)\s*"Netlify Edge"; fwd=(stale|miss)/, + ) + expect(headers2['debug-netlify-cache-tag']).toEqual(expectedCacheTag) + expect(headers2['debug-netlify-cdn-cache-control']).toMatch( + /s-maxage=31536000,( stale-while-revalidate=31536000,)? durable/, + ) - // 4. Unpublish the blob, revalidate the dynamic page, and wait to regenerate - await page.goto(new URL(`/cms/unpublish/${contentKey}`, dynamicCms.url).href) - await page.goto(revalidateAPiUrl) - await page.waitForTimeout(1000) + // 4. Unpublish the blob, revalidate the dynamic page, and wait to regenerate + await page.goto(new URL(`/cms/unpublish/${contentKey}`, dynamicCms.url).href) + await page.goto(revalidateAPiUrl) + await page.waitForTimeout(1000) - // 5. Verify the status and headers of the dynamic page - const response3 = await page.goto(routeUrl) - const headers3 = response3?.headers() || {} + // 5. Verify the status and headers of the dynamic page + const response3 = await page.goto(routeUrl) + const headers3 = response3?.headers() || {} - expect(response3?.status()).toEqual(404) - expect(headers3['cache-control']).toEqual('public,max-age=0,must-revalidate') - expect(headers3['cache-status']).toMatch( - /"Next.js"; fwd=miss\s*(,|\n)\s*"Netlify Durable"; fwd=stale; ttl=[0-9]+; stored\s*(,|\n)\s*"Netlify Edge"; fwd=(stale|miss)/, - ) - expect(headers3['debug-netlify-cache-tag']).toEqual(expectedCacheTag) - expect(headers3['debug-netlify-cdn-cache-control']).toMatch( - /s-maxage=31536000,( stale-while-revalidate=31536000,)? durable/, - ) - }) - } - }) -}) + expect(response3?.status()).toEqual(404) + expect(headers3['cache-control']).toEqual('public,max-age=0,must-revalidate') + expect(headers3['cache-status']).toMatch( + /"Next.js"; fwd=miss\s*(,|\n)\s*"Netlify Durable"; fwd=stale; ttl=[0-9]+; stored\s*(,|\n)\s*"Netlify Edge"; fwd=(stale|miss)/, + ) + expect(headers3['debug-netlify-cache-tag']).toEqual(expectedCacheTag) + expect(headers3['debug-netlify-cdn-cache-control']).toMatch( + /s-maxage=31536000,( stale-while-revalidate=31536000,)? durable/, + ) + }) + } + }) + }, +) diff --git a/tests/e2e/export.test.ts b/tests/e2e/export.test.ts index 9057720ef4..8079954cc0 100644 --- a/tests/e2e/export.test.ts +++ b/tests/e2e/export.test.ts @@ -1,73 +1,85 @@ import { expect, type Locator } from '@playwright/test' -import { test } from '../utils/playwright-helpers.js' +import { generateTestTags, test } from '../utils/playwright-helpers.js' const expectImageWasLoaded = async (locator: Locator) => { expect(await locator.evaluate((img: HTMLImageElement) => img.naturalHeight)).toBeGreaterThan(0) } -test('Renders the Home page correctly with output export', async ({ page, outputExport }) => { - const response = await page.goto(outputExport.url) - const headers = response?.headers() || {} - await expect(page).toHaveTitle('Simple Next App') +test.describe( + 'Static export', + { + tag: generateTestTags({ appRouter: true, export: true }), + }, + () => { + test('Renders the Home page correctly with output export', async ({ page, outputExport }) => { + const response = await page.goto(outputExport.url) + const headers = response?.headers() || {} - expect(headers['cache-status']).toBe('"Netlify Edge"; fwd=miss') + await expect(page).toHaveTitle('Simple Next App') - const h1 = page.locator('h1') - await expect(h1).toHaveText('Home') + expect(headers['cache-status']).toBe('"Netlify Edge"; fwd=miss') - await expectImageWasLoaded(page.locator('img')) -}) + const h1 = page.locator('h1') + await expect(h1).toHaveText('Home') -test('Renders the Home page correctly with output export and publish set to out', async ({ - page, - ouputExportPublishOut, -}) => { - const response = await page.goto(ouputExportPublishOut.url) - const headers = response?.headers() || {} + await expectImageWasLoaded(page.locator('img')) + }) - await expect(page).toHaveTitle('Simple Next App') + test('Renders the Home page correctly with output export and publish set to out', async ({ + page, + outputExportPublishOut, + }) => { + const response = await page.goto(outputExportPublishOut.url) + const headers = response?.headers() || {} - expect(headers['cache-status']).toBe('"Netlify Edge"; fwd=miss') + await expect(page).toHaveTitle('Simple Next App') - const h1 = page.locator('h1') - await expect(h1).toHaveText('Home') + expect(headers['cache-status']).toBe('"Netlify Edge"; fwd=miss') - await expectImageWasLoaded(page.locator('img')) -}) + const h1 = page.locator('h1') + await expect(h1).toHaveText('Home') -test('Renders the Home page correctly with output export and custom dist dir', async ({ - page, - outputExportCustomDist, -}) => { - const response = await page.goto(outputExportCustomDist.url) - const headers = response?.headers() || {} + await expectImageWasLoaded(page.locator('img')) + }) - await expect(page).toHaveTitle('Simple Next App') + test( + 'Renders the Home page correctly with output export and custom dist dir', + { + tag: generateTestTags({ customDistDir: true }), + }, + async ({ page, outputExportCustomDist }) => { + const response = await page.goto(outputExportCustomDist.url) + const headers = response?.headers() || {} - expect(headers['cache-status']).toBe('"Netlify Edge"; fwd=miss') + await expect(page).toHaveTitle('Simple Next App') - const h1 = page.locator('h1') - await expect(h1).toHaveText('Home') + expect(headers['cache-status']).toBe('"Netlify Edge"; fwd=miss') - await expectImageWasLoaded(page.locator('img')) -}) + const h1 = page.locator('h1') + await expect(h1).toHaveText('Home') -test.describe('next/image is using Netlify Image CDN', () => { - test('Local images', async ({ page, outputExport }) => { - const nextImageResponsePromise = page.waitForResponse('**/.netlify/images**') + await expectImageWasLoaded(page.locator('img')) + }, + ) - await page.goto(`${outputExport.url}/image/local`) + test.describe('next/image is using Netlify Image CDN', () => { + test('Local images', async ({ page, outputExport }) => { + const nextImageResponsePromise = page.waitForResponse('**/.netlify/images**') - const nextImageResponse = await nextImageResponsePromise - expect(nextImageResponse.request().url()).toContain('.netlify/images?url=%2Fsquirrel.jpg') + await page.goto(`${outputExport.url}/image/local`) - expect(nextImageResponse.status()).toBe(200) - // ensure next/image is using Image CDN - // source image is jpg, but when requesting it through Image CDN avif or webp will be returned - expect(['image/avif', 'image/webp']).toContain( - await nextImageResponse.headerValue('content-type'), - ) + const nextImageResponse = await nextImageResponsePromise + expect(nextImageResponse.request().url()).toContain('.netlify/images?url=%2Fsquirrel.jpg') + + expect(nextImageResponse.status()).toBe(200) + // ensure next/image is using Image CDN + // source image is jpg, but when requesting it through Image CDN avif or webp will be returned + expect(['image/avif', 'image/webp']).toContain( + await nextImageResponse.headerValue('content-type'), + ) - await expectImageWasLoaded(page.locator('img')) - }) -}) + await expectImageWasLoaded(page.locator('img')) + }) + }) + }, +) diff --git a/tests/e2e/middleware.test.ts b/tests/e2e/middleware.test.ts index a25de675a4..b106284da5 100644 --- a/tests/e2e/middleware.test.ts +++ b/tests/e2e/middleware.test.ts @@ -1,6 +1,6 @@ import { expect, Response } from '@playwright/test' import { hasNodeMiddlewareSupport, nextVersionSatisfies } from '../utils/next-version-helpers.mjs' -import { test } from '../utils/playwright-helpers.js' +import { generateTestTags, test } from '../utils/playwright-helpers.js' import { getImageSize } from 'next/dist/server/image-optimizer.js' import type { Fixture } from '../utils/create-e2e-fixture.js' @@ -118,523 +118,584 @@ for (const { expectedRuntime, isNodeMiddleware, label, testWithSwitchableMiddlew })) { const test = testWithSwitchableMiddlewareRuntime - test.describe(label, () => { - test('Runs middleware', async ({ page, edgeOrNodeMiddleware }) => { - const res = await page.goto(`${edgeOrNodeMiddleware.url}/test/redirect`) + test.describe( + label, + { + tag: generateTestTags({ middleware: isNodeMiddleware ? 'node' : 'edge' }), + }, + () => { + test.describe( + 'With App Router', + { + tag: generateTestTags({ appRouter: true }), + }, + () => { + test('Runs middleware', async ({ page, edgeOrNodeMiddleware }) => { + const res = await page.goto(`${edgeOrNodeMiddleware.url}/test/redirect`) - await expect(page).toHaveTitle('Simple Next App') + await expect(page).toHaveTitle('Simple Next App') - const h1 = page.locator('h1') - await expect(h1).toHaveText('Other') - }) + const h1 = page.locator('h1') + await expect(h1).toHaveText('Other') + }) - test('Does not run middleware at the origin', async ({ page, edgeOrNodeMiddleware }) => { - const res = await page.goto(`${edgeOrNodeMiddleware.url}/test/next`) + test('Does not run middleware at the origin', async ({ page, edgeOrNodeMiddleware }) => { + const res = await page.goto(`${edgeOrNodeMiddleware.url}/test/next`) - expect(await res?.headerValue('x-deno')).toBeTruthy() - expect(await res?.headerValue('x-node')).toBeNull() + expect(await res?.headerValue('x-deno')).toBeTruthy() + expect(await res?.headerValue('x-node')).toBeNull() - await expect(page).toHaveTitle('Simple Next App') + await expect(page).toHaveTitle('Simple Next App') - const h1 = page.locator('h1') - await expect(h1).toHaveText('Message from middleware: hello') + const h1 = page.locator('h1') + await expect(h1).toHaveText('Message from middleware: hello') - expect(await res?.headerValue('x-runtime')).toEqual(expectedRuntime) - }) + expect(await res?.headerValue('x-runtime')).toEqual(expectedRuntime) + }) - test('does not run middleware again for rewrite target', async ({ - page, - edgeOrNodeMiddleware, - }) => { - const direct = await page.goto(`${edgeOrNodeMiddleware.url}/test/rewrite-target`) - expect(await direct?.headerValue('x-added-rewrite-target')).toBeTruthy() + test('does not run middleware again for rewrite target', async ({ + page, + edgeOrNodeMiddleware, + }) => { + const direct = await page.goto(`${edgeOrNodeMiddleware.url}/test/rewrite-target`) + expect(await direct?.headerValue('x-added-rewrite-target')).toBeTruthy() - const rewritten = await page.goto(`${edgeOrNodeMiddleware.url}/test/rewrite-loop-detect`) + const rewritten = await page.goto( + `${edgeOrNodeMiddleware.url}/test/rewrite-loop-detect`, + ) - expect(await rewritten?.headerValue('x-added-rewrite-target')).toBeNull() - const h1 = page.locator('h1') - await expect(h1).toHaveText('Hello rewrite') + expect(await rewritten?.headerValue('x-added-rewrite-target')).toBeNull() + const h1 = page.locator('h1') + await expect(h1).toHaveText('Hello rewrite') - expect(await direct?.headerValue('x-runtime')).toEqual(expectedRuntime) - }) + expect(await direct?.headerValue('x-runtime')).toEqual(expectedRuntime) + }) - test('Supports CJS dependencies in Edge Middleware', async ({ page, edgeOrNodeMiddleware }) => { - const res = await page.goto(`${edgeOrNodeMiddleware.url}/test/next`) + test('Supports CJS dependencies in Edge Middleware', async ({ + page, + edgeOrNodeMiddleware, + }) => { + const res = await page.goto(`${edgeOrNodeMiddleware.url}/test/next`) - expect(await res?.headerValue('x-cjs-module-works')).toEqual('true') - expect(await res?.headerValue('x-runtime')).toEqual(expectedRuntime) - }) + expect(await res?.headerValue('x-cjs-module-works')).toEqual('true') + expect(await res?.headerValue('x-runtime')).toEqual(expectedRuntime) + }) - if (expectedRuntime !== 'node') { - // adaptation of https://github.com/vercel/next.js/blob/8aa9a52c36f338320d55bd2ec292ffb0b8c7cb35/test/e2e/app-dir/metadata-edge/index.test.ts#L24C5-L31C7 - test('it should render OpenGraph image meta tag correctly', async ({ - page, - middlewareOg, - }) => { - test.skip(!nextVersionSatisfies('>=14.0.0'), 'This test is only for Next.js 14+') - await page.goto(`${middlewareOg.url}/`) - const ogURL = await page.locator('meta[property="og:image"]').getAttribute('content') - expect(ogURL).toBeTruthy() - const ogResponse = await fetch(new URL(new URL(ogURL!).pathname, middlewareOg.url)) - const imageBuffer = await ogResponse.arrayBuffer() - const size = await getImageSize(Buffer.from(imageBuffer), 'png') - expect([size.width, size.height]).toEqual([1200, 630]) - }) - } + if (expectedRuntime !== 'node') { + // adaptation of https://github.com/vercel/next.js/blob/8aa9a52c36f338320d55bd2ec292ffb0b8c7cb35/test/e2e/app-dir/metadata-edge/index.test.ts#L24C5-L31C7 + test('it should render OpenGraph image meta tag correctly', async ({ + page, + middlewareOg, + }) => { + test.skip(!nextVersionSatisfies('>=14.0.0'), 'This test is only for Next.js 14+') + await page.goto(`${middlewareOg.url}/`) + const ogURL = await page.locator('meta[property="og:image"]').getAttribute('content') + expect(ogURL).toBeTruthy() + const ogResponse = await fetch(new URL(new URL(ogURL!).pathname, middlewareOg.url)) + const imageBuffer = await ogResponse.arrayBuffer() + const size = await getImageSize(Buffer.from(imageBuffer), 'png') + expect([size.width, size.height]).toEqual([1200, 630]) + }) + } + + test('requests with different encoding than matcher match anyway', async ({ + edgeOrNodeMiddlewareStaticAssetMatcher, + }) => { + const response = await fetch( + `${edgeOrNodeMiddlewareStaticAssetMatcher.url}/hello%2Fworld.txt`, + ) + + // middleware was not skipped + expect(await response.text()).toBe('hello from middleware') + expect(response.headers.get('x-runtime')).toEqual(expectedRuntime) + }) - test.describe('json data', () => { - const testConfigs = [ - { - describeLabel: 'NextResponse.next() -> getServerSideProps page', - selector: 'NextResponse.next()#getServerSideProps', - jsonPathMatcher: '/link/next-getserversideprops.json', - }, - { - describeLabel: 'NextResponse.next() -> getStaticProps page', - selector: 'NextResponse.next()#getStaticProps', - jsonPathMatcher: '/link/next-getstaticprops.json', - }, - { - describeLabel: 'NextResponse.next() -> fully static page', - selector: 'NextResponse.next()#fullyStatic', - jsonPathMatcher: '/link/next-fullystatic.json', - }, - { - describeLabel: 'NextResponse.rewrite() -> getServerSideProps page', - selector: 'NextResponse.rewrite()#getServerSideProps', - jsonPathMatcher: '/link/rewrite-me-getserversideprops.json', - }, - { - describeLabel: 'NextResponse.rewrite() -> getStaticProps page', - selector: 'NextResponse.rewrite()#getStaticProps', - jsonPathMatcher: '/link/rewrite-me-getstaticprops.json', - }, - ] - - // Linking to static pages reloads on rewrite for versions below 14 - if (nextVersionSatisfies('>=14.0.0')) { - testConfigs.push({ - describeLabel: 'NextResponse.rewrite() -> fully static page', - selector: 'NextResponse.rewrite()#fullyStatic', - jsonPathMatcher: '/link/rewrite-me-fullystatic.json', - }) - } + test.describe('RSC cache poisoning', () => { + test('Middleware rewrite', async ({ page, edgeOrNodeMiddleware }) => { + const prefetchResponsePromise = new Promise((resolve) => { + page.on('response', (response) => { + if ( + (response.url().includes('/test/rewrite-to-cached-page') || + response.url().includes('/caching-rewrite-target')) && + response.status() === 200 + ) { + resolve(response) + } + }) + }) + await page.goto(`${edgeOrNodeMiddleware.url}/link-to-rewrite-to-cached-page`) + + // ensure prefetch + await page.hover('text=NextResponse.rewrite') + + // wait for prefetch request to finish + const prefetchResponse = await prefetchResponsePromise + + // ensure prefetch respond with RSC data + expect(prefetchResponse.headers()['content-type']).toMatch(/text\/x-component/) + expect(prefetchResponse.headers()['debug-netlify-cdn-cache-control']).toMatch( + /s-maxage=31536000/, + ) + + const htmlResponse = await page.goto( + `${edgeOrNodeMiddleware.url}/test/rewrite-to-cached-page`, + ) - test.describe('no 18n', () => { - for (const testConfig of testConfigs) { - test.describe(testConfig.describeLabel, () => { - test('json data fetch', async ({ edgeOrNodeMiddlewarePages, page }) => { - const dataFetchPromise = new Promise((resolve) => { + // ensure we get HTML response + expect(htmlResponse?.headers()['content-type']).toMatch(/text\/html/) + expect(htmlResponse?.headers()['debug-netlify-cdn-cache-control']).toMatch( + /s-maxage=31536000/, + ) + }) + + test('Middleware redirect', async ({ page, edgeOrNodeMiddleware }) => { + const prefetchResponsePromise = new Promise((resolve) => { page.on('response', (response) => { - if (response.url().includes(testConfig.jsonPathMatcher)) { + if ( + response.url().includes('/caching-redirect-target') && + response.status() === 200 + ) { resolve(response) } }) }) + await page.goto(`${edgeOrNodeMiddleware.url}/link-to-redirect-to-cached-page`) - const pageResponse = await page.goto(`${edgeOrNodeMiddlewarePages.url}/link`) - expect(await pageResponse?.headerValue('x-runtime')).toEqual(expectedRuntime) + // ensure prefetch + await page.hover('text=NextResponse.redirect') - await page.hover(`[data-link="${testConfig.selector}"]`) + // wait for prefetch request to finish + const prefetchResponse = await prefetchResponsePromise - const dataResponse = await dataFetchPromise + // ensure prefetch respond with RSC data + expect(prefetchResponse.headers()['content-type']).toMatch(/text\/x-component/) + expect(prefetchResponse.headers()['debug-netlify-cdn-cache-control']).toMatch( + /s-maxage=31536000/, + ) - expect(dataResponse.ok()).toBe(true) + const htmlResponse = await page.goto( + `${edgeOrNodeMiddleware.url}/test/redirect-to-cached-page`, + ) + + // ensure we get HTML response + expect(htmlResponse?.headers()['content-type']).toMatch(/text\/html/) + expect(htmlResponse?.headers()['debug-netlify-cdn-cache-control']).toMatch( + /s-maxage=31536000/, + ) }) + }) + + if (isNodeMiddleware) { + // Node.js Middleware specific tests to test features not available in Edge Runtime + test.describe('Node.js Middleware specific', () => { + test.describe('npm package manager', () => { + test('node:crypto module', async ({ middlewareNodeRuntimeSpecific }) => { + const response = await fetch(`${middlewareNodeRuntimeSpecific.url}/test/crypto`) + expect(response.status).toBe(200) + const body = await response.json() + expect( + body.random, + 'random should have 16 random bytes generated with `randomBytes` function from node:crypto in hex format', + ).toMatch(/[0-9a-f]{32}/) + }) + + test('node:http(s) module', async ({ middlewareNodeRuntimeSpecific }) => { + const response = await fetch(`${middlewareNodeRuntimeSpecific.url}/test/http`) + expect(response.status).toBe(200) + const body = await response.json() + expect( + body.proxiedWithHttpRequest, + 'proxiedWithHttpRequest should be the result of `http.request` from node:http fetching static asset', + ).toStrictEqual({ hello: 'world' }) + }) + + test('node:path module', async ({ middlewareNodeRuntimeSpecific }) => { + const response = await fetch(`${middlewareNodeRuntimeSpecific.url}/test/path`) + expect(response.status).toBe(200) + const body = await response.json() + expect( + body.joined, + 'joined should be the result of `join` function from node:path', + ).toBe('a/b') + }) + }) + + test.describe('pnpm package manager', () => { + test('node:crypto module', async ({ middlewareNodeRuntimeSpecificPnpm }) => { + const response = await fetch( + `${middlewareNodeRuntimeSpecificPnpm.url}/test/crypto`, + ) + expect(response.status).toBe(200) + const body = await response.json() + expect( + body.random, + 'random should have 16 random bytes generated with `randomBytes` function from node:crypto in hex format', + ).toMatch(/[0-9a-f]{32}/) + }) - test('navigation', async ({ edgeOrNodeMiddlewarePages, page }) => { - const pageResponse = await page.goto(`${edgeOrNodeMiddlewarePages.url}/link`) - expect(await pageResponse?.headerValue('x-runtime')).toEqual(expectedRuntime) + test('node:http(s) module', async ({ middlewareNodeRuntimeSpecificPnpm }) => { + const response = await fetch(`${middlewareNodeRuntimeSpecificPnpm.url}/test/http`) + expect(response.status).toBe(200) + const body = await response.json() + expect( + body.proxiedWithHttpRequest, + 'proxiedWithHttpRequest should be the result of `http.request` from node:http fetching static asset', + ).toStrictEqual({ hello: 'world' }) + }) + + test('node:path module', async ({ middlewareNodeRuntimeSpecificPnpm }) => { + const response = await fetch(`${middlewareNodeRuntimeSpecificPnpm.url}/test/path`) + expect(response.status).toBe(200) + const body = await response.json() + expect( + body.joined, + 'joined should be the result of `join` function from node:path', + ).toBe('a/b') + }) + }) + }) + } + }, + ) - await page.evaluate(() => { - // set some value to window to check later if browser did reload and lost this state - ;(window as ExtendedWindow).didReload = false + test.describe( + 'With Pages Router', + { + tag: generateTestTags({ pagesRouter: true }), + }, + () => { + test.describe('json data', () => { + const testConfigs = [ + { + describeLabel: 'NextResponse.next() -> getServerSideProps page', + selector: 'NextResponse.next()#getServerSideProps', + jsonPathMatcher: '/link/next-getserversideprops.json', + }, + { + describeLabel: 'NextResponse.next() -> getStaticProps page', + selector: 'NextResponse.next()#getStaticProps', + jsonPathMatcher: '/link/next-getstaticprops.json', + }, + { + describeLabel: 'NextResponse.next() -> fully static page', + selector: 'NextResponse.next()#fullyStatic', + jsonPathMatcher: '/link/next-fullystatic.json', + }, + { + describeLabel: 'NextResponse.rewrite() -> getServerSideProps page', + selector: 'NextResponse.rewrite()#getServerSideProps', + jsonPathMatcher: '/link/rewrite-me-getserversideprops.json', + }, + { + describeLabel: 'NextResponse.rewrite() -> getStaticProps page', + selector: 'NextResponse.rewrite()#getStaticProps', + jsonPathMatcher: '/link/rewrite-me-getstaticprops.json', + }, + ] + + // Linking to static pages reloads on rewrite for versions below 14 + if (nextVersionSatisfies('>=14.0.0')) { + testConfigs.push({ + describeLabel: 'NextResponse.rewrite() -> fully static page', + selector: 'NextResponse.rewrite()#fullyStatic', + jsonPathMatcher: '/link/rewrite-me-fullystatic.json', }) + } + + test.describe('no 18n', () => { + for (const testConfig of testConfigs) { + test.describe(testConfig.describeLabel, () => { + test('json data fetch', async ({ edgeOrNodeMiddlewarePages, page }) => { + const dataFetchPromise = new Promise((resolve) => { + page.on('response', (response) => { + if (response.url().includes(testConfig.jsonPathMatcher)) { + resolve(response) + } + }) + }) + + const pageResponse = await page.goto(`${edgeOrNodeMiddlewarePages.url}/link`) + expect(await pageResponse?.headerValue('x-runtime')).toEqual(expectedRuntime) + + await page.hover(`[data-link="${testConfig.selector}"]`) - await page.click(`[data-link="${testConfig.selector}"]`) + const dataResponse = await dataFetchPromise - // wait for page to be rendered - await page.waitForSelector(`[data-page="${testConfig.selector}"]`) + expect(dataResponse.ok()).toBe(true) + }) + + test('navigation', async ({ edgeOrNodeMiddlewarePages, page }) => { + const pageResponse = await page.goto(`${edgeOrNodeMiddlewarePages.url}/link`) + expect(await pageResponse?.headerValue('x-runtime')).toEqual(expectedRuntime) + + await page.evaluate(() => { + // set some value to window to check later if browser did reload and lost this state + ;(window as ExtendedWindow).didReload = false + }) - // check if browser navigation worked by checking if state was preserved - const browserNavigationWorked = - (await page.evaluate(() => { - return (window as ExtendedWindow).didReload - })) === false + await page.click(`[data-link="${testConfig.selector}"]`) - // we expect client navigation to work without browser reload - expect(browserNavigationWorked).toBe(true) + // wait for page to be rendered + await page.waitForSelector(`[data-page="${testConfig.selector}"]`) + + // check if browser navigation worked by checking if state was preserved + const browserNavigationWorked = + (await page.evaluate(() => { + return (window as ExtendedWindow).didReload + })) === false + + // we expect client navigation to work without browser reload + expect(browserNavigationWorked).toBe(true) + }) + }) + } }) + + test.describe( + 'with 18n', + { + tag: generateTestTags({ i18n: true }), + }, + () => { + for (const testConfig of testConfigs) { + test.describe(testConfig.describeLabel, () => { + for (const { localeLabel, pageWithLinksPathname } of [ + { localeLabel: 'implicit default locale', pageWithLinksPathname: '/link' }, + { localeLabel: 'explicit default locale', pageWithLinksPathname: '/en/link' }, + { + localeLabel: 'explicit non-default locale', + pageWithLinksPathname: '/fr/link', + }, + ]) { + test.describe(localeLabel, () => { + test('json data fetch', async ({ edgeOrNodeMiddlewareI18n, page }) => { + const dataFetchPromise = new Promise((resolve) => { + page.on('response', (response) => { + if (response.url().includes(testConfig.jsonPathMatcher)) { + resolve(response) + } + }) + }) + + const pageResponse = await page.goto( + `${edgeOrNodeMiddlewareI18n.url}${pageWithLinksPathname}`, + ) + expect(await pageResponse?.headerValue('x-runtime')).toEqual( + expectedRuntime, + ) + + await page.hover(`[data-link="${testConfig.selector}"]`) + + const dataResponse = await dataFetchPromise + + expect(dataResponse.ok()).toBe(true) + }) + + test('navigation', async ({ edgeOrNodeMiddlewareI18n, page }) => { + const pageResponse = await page.goto( + `${edgeOrNodeMiddlewareI18n.url}${pageWithLinksPathname}`, + ) + expect(await pageResponse?.headerValue('x-runtime')).toEqual( + expectedRuntime, + ) + + await page.evaluate(() => { + // set some value to window to check later if browser did reload and lost this state + ;(window as ExtendedWindow).didReload = false + }) + + await page.click(`[data-link="${testConfig.selector}"]`) + + // wait for page to be rendered + await page.waitForSelector(`[data-page="${testConfig.selector}"]`) + + // check if browser navigation worked by checking if state was preserved + const browserNavigationWorked = + (await page.evaluate(() => { + return (window as ExtendedWindow).didReload + })) === false + + // we expect client navigation to work without browser reload + expect(browserNavigationWorked).toBe(true) + }) + }) + } + }) + } + }, + ) }) - } - }) - - test.describe('with 18n', () => { - for (const testConfig of testConfigs) { - test.describe(testConfig.describeLabel, () => { - for (const { localeLabel, pageWithLinksPathname } of [ - { localeLabel: 'implicit default locale', pageWithLinksPathname: '/link' }, - { localeLabel: 'explicit default locale', pageWithLinksPathname: '/en/link' }, - { localeLabel: 'explicit non-default locale', pageWithLinksPathname: '/fr/link' }, - ]) { - test.describe(localeLabel, () => { - test('json data fetch', async ({ edgeOrNodeMiddlewareI18n, page }) => { - const dataFetchPromise = new Promise((resolve) => { - page.on('response', (response) => { - if (response.url().includes(testConfig.jsonPathMatcher)) { - resolve(response) - } - }) + + // those tests use `fetch` instead of `page.goto` intentionally to avoid potential client rendering + // hiding any potential edge/server issues + test.describe( + 'Middleware with i18n and excluded paths', + { + tag: generateTestTags({ i18n: true }), + }, + () => { + const DEFAULT_LOCALE = 'en' + + /** helper function to extract JSON data from page rendering data with `
{JSON.stringify(data)}
` */ + function extractDataFromHtml(html: string): Record { + const match = html.match(/
(?[^<]+)<\/pre>/)
+                if (!match || !match.groups?.rawInput) {
+                  console.error('
 not found in html input', {
+                    html,
+                  })
+                  throw new Error('Failed to extract data from HTML')
+                }
+
+                const { rawInput } = match.groups
+                const unescapedInput = rawInput.replaceAll('"', '"')
+                try {
+                  return JSON.parse(unescapedInput)
+                } catch (originalError) {
+                  console.error('Failed to parse JSON', {
+                    originalError,
+                    rawInput,
+                    unescapedInput,
                   })
+                }
+                throw new Error('Failed to extract data from HTML')
+              }
+
+              // those tests hit paths ending with `/json` which has special handling in middleware
+              // to return JSON response from middleware itself
+              test.describe('Middleware response path', () => {
+                test('should match on non-localized not excluded page path', async ({
+                  edgeOrNodeMiddlewareI18nExcludedPaths,
+                }) => {
+                  const response = await fetch(`${edgeOrNodeMiddlewareI18nExcludedPaths.url}/json`)
+
+                  expect(response.headers.get('x-test-used-middleware')).toBe('true')
+                  expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
+                  expect(response.status).toBe(200)
+
+                  const { nextUrlPathname, nextUrlLocale } = await response.json()
+
+                  expect(nextUrlPathname).toBe('/json')
+                  expect(nextUrlLocale).toBe(DEFAULT_LOCALE)
+                })
 
-                  const pageResponse = await page.goto(
-                    `${edgeOrNodeMiddlewareI18n.url}${pageWithLinksPathname}`,
+                test('should match on localized not excluded page path', async ({
+                  edgeOrNodeMiddlewareI18nExcludedPaths,
+                }) => {
+                  const response = await fetch(
+                    `${edgeOrNodeMiddlewareI18nExcludedPaths.url}/fr/json`,
                   )
-                  expect(await pageResponse?.headerValue('x-runtime')).toEqual(expectedRuntime)
 
-                  await page.hover(`[data-link="${testConfig.selector}"]`)
+                  expect(response.headers.get('x-test-used-middleware')).toBe('true')
+                  expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
+                  expect(response.status).toBe(200)
 
-                  const dataResponse = await dataFetchPromise
+                  const { nextUrlPathname, nextUrlLocale } = await response.json()
 
-                  expect(dataResponse.ok()).toBe(true)
+                  expect(nextUrlPathname).toBe('/json')
+                  expect(nextUrlLocale).toBe('fr')
                 })
+              })
 
-                test('navigation', async ({ edgeOrNodeMiddlewareI18n, page }) => {
-                  const pageResponse = await page.goto(
-                    `${edgeOrNodeMiddlewareI18n.url}${pageWithLinksPathname}`,
-                  )
-                  expect(await pageResponse?.headerValue('x-runtime')).toEqual(expectedRuntime)
+              // those tests hit paths that don't end with `/json` while still satisfying middleware matcher
+              // so middleware should pass them through to origin
+              test.describe('Middleware passthrough', () => {
+                test('should match on non-localized not excluded page path', async ({
+                  edgeOrNodeMiddlewareI18nExcludedPaths,
+                }) => {
+                  const response = await fetch(`${edgeOrNodeMiddlewareI18nExcludedPaths.url}/html`)
 
-                  await page.evaluate(() => {
-                    // set some value to window to check later if browser did reload and lost this state
-                    ;(window as ExtendedWindow).didReload = false
-                  })
+                  expect(response.headers.get('x-test-used-middleware')).toBe('true')
+                  expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
+                  expect(response.status).toBe(200)
+                  expect(response.headers.get('content-type')).toMatch(/text\/html/)
+
+                  const html = await response.text()
+                  const { locale, params } = extractDataFromHtml(html)
+
+                  expect(params).toMatchObject({ catchall: ['html'] })
+                  expect(locale).toBe(DEFAULT_LOCALE)
+                })
 
-                  await page.click(`[data-link="${testConfig.selector}"]`)
+                test('should match on localized not excluded page path', async ({
+                  edgeOrNodeMiddlewareI18nExcludedPaths,
+                }) => {
+                  const response = await fetch(
+                    `${edgeOrNodeMiddlewareI18nExcludedPaths.url}/fr/html`,
+                  )
 
-                  // wait for page to be rendered
-                  await page.waitForSelector(`[data-page="${testConfig.selector}"]`)
+                  expect(response.headers.get('x-test-used-middleware')).toBe('true')
+                  expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
+                  expect(response.status).toBe(200)
+                  expect(response.headers.get('content-type')).toMatch(/text\/html/)
 
-                  // check if browser navigation worked by checking if state was preserved
-                  const browserNavigationWorked =
-                    (await page.evaluate(() => {
-                      return (window as ExtendedWindow).didReload
-                    })) === false
+                  const html = await response.text()
+                  const { locale, params } = extractDataFromHtml(html)
 
-                  // we expect client navigation to work without browser reload
-                  expect(browserNavigationWorked).toBe(true)
+                  expect(params).toMatchObject({ catchall: ['html'] })
+                  expect(locale).toBe('fr')
                 })
               })
-            }
-          })
-        }
-      })
-    })
-
-    // those tests use `fetch` instead of `page.goto` intentionally to avoid potential client rendering
-    // hiding any potential edge/server issues
-    test.describe('Middleware with i18n and excluded paths', () => {
-      const DEFAULT_LOCALE = 'en'
-
-      /** helper function to extract JSON data from page rendering data with `
{JSON.stringify(data)}
` */ - function extractDataFromHtml(html: string): Record { - const match = html.match(/
(?[^<]+)<\/pre>/)
-        if (!match || !match.groups?.rawInput) {
-          console.error('
 not found in html input', {
-            html,
-          })
-          throw new Error('Failed to extract data from HTML')
-        }
-
-        const { rawInput } = match.groups
-        const unescapedInput = rawInput.replaceAll('"', '"')
-        try {
-          return JSON.parse(unescapedInput)
-        } catch (originalError) {
-          console.error('Failed to parse JSON', {
-            originalError,
-            rawInput,
-            unescapedInput,
-          })
-        }
-        throw new Error('Failed to extract data from HTML')
-      }
 
-      // those tests hit paths ending with `/json` which has special handling in middleware
-      // to return JSON response from middleware itself
-      test.describe('Middleware response path', () => {
-        test('should match on non-localized not excluded page path', async ({
-          edgeOrNodeMiddlewareI18nExcludedPaths,
-        }) => {
-          const response = await fetch(`${edgeOrNodeMiddlewareI18nExcludedPaths.url}/json`)
-
-          expect(response.headers.get('x-test-used-middleware')).toBe('true')
-          expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
-          expect(response.status).toBe(200)
-
-          const { nextUrlPathname, nextUrlLocale } = await response.json()
-
-          expect(nextUrlPathname).toBe('/json')
-          expect(nextUrlLocale).toBe(DEFAULT_LOCALE)
-        })
-
-        test('should match on localized not excluded page path', async ({
-          edgeOrNodeMiddlewareI18nExcludedPaths,
-        }) => {
-          const response = await fetch(`${edgeOrNodeMiddlewareI18nExcludedPaths.url}/fr/json`)
-
-          expect(response.headers.get('x-test-used-middleware')).toBe('true')
-          expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
-          expect(response.status).toBe(200)
-
-          const { nextUrlPathname, nextUrlLocale } = await response.json()
-
-          expect(nextUrlPathname).toBe('/json')
-          expect(nextUrlLocale).toBe('fr')
-        })
-      })
-
-      // those tests hit paths that don't end with `/json` while still satisfying middleware matcher
-      // so middleware should pass them through to origin
-      test.describe('Middleware passthrough', () => {
-        test('should match on non-localized not excluded page path', async ({
-          edgeOrNodeMiddlewareI18nExcludedPaths,
-        }) => {
-          const response = await fetch(`${edgeOrNodeMiddlewareI18nExcludedPaths.url}/html`)
-
-          expect(response.headers.get('x-test-used-middleware')).toBe('true')
-          expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
-          expect(response.status).toBe(200)
-          expect(response.headers.get('content-type')).toMatch(/text\/html/)
-
-          const html = await response.text()
-          const { locale, params } = extractDataFromHtml(html)
-
-          expect(params).toMatchObject({ catchall: ['html'] })
-          expect(locale).toBe(DEFAULT_LOCALE)
-        })
-
-        test('should match on localized not excluded page path', async ({
-          edgeOrNodeMiddlewareI18nExcludedPaths,
-        }) => {
-          const response = await fetch(`${edgeOrNodeMiddlewareI18nExcludedPaths.url}/fr/html`)
-
-          expect(response.headers.get('x-test-used-middleware')).toBe('true')
-          expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
-          expect(response.status).toBe(200)
-          expect(response.headers.get('content-type')).toMatch(/text\/html/)
-
-          const html = await response.text()
-          const { locale, params } = extractDataFromHtml(html)
-
-          expect(params).toMatchObject({ catchall: ['html'] })
-          expect(locale).toBe('fr')
-        })
-      })
-
-      // those tests hit paths that don't satisfy middleware matcher, so should go directly to origin
-      // without going through middleware
-      test.describe('Middleware skipping (paths not satisfying middleware matcher)', () => {
-        test('should NOT match on non-localized excluded API path', async ({
-          edgeOrNodeMiddlewareI18nExcludedPaths,
-        }) => {
-          const response = await fetch(`${edgeOrNodeMiddlewareI18nExcludedPaths.url}/api/html`)
-
-          expect(response.headers.get('x-test-used-middleware')).not.toBe('true')
-          expect(response.status).toBe(200)
-
-          const { params } = await response.json()
-
-          expect(params).toMatchObject({ catchall: ['html'] })
-        })
-
-        test('should NOT match on non-localized excluded page path', async ({
-          edgeOrNodeMiddlewareI18nExcludedPaths,
-        }) => {
-          const response = await fetch(`${edgeOrNodeMiddlewareI18nExcludedPaths.url}/excluded`)
-
-          expect(response.headers.get('x-test-used-middleware')).not.toBe('true')
-          expect(response.status).toBe(200)
-          expect(response.headers.get('content-type')).toMatch(/text\/html/)
-
-          const html = await response.text()
-          const { locale, params } = extractDataFromHtml(html)
-
-          expect(params).toMatchObject({ catchall: ['excluded'] })
-          expect(locale).toBe(DEFAULT_LOCALE)
-        })
-
-        test('should NOT match on localized excluded page path', async ({
-          edgeOrNodeMiddlewareI18nExcludedPaths,
-        }) => {
-          const response = await fetch(`${edgeOrNodeMiddlewareI18nExcludedPaths.url}/fr/excluded`)
-
-          expect(response.headers.get('x-test-used-middleware')).not.toBe('true')
-          expect(response.status).toBe(200)
-          expect(response.headers.get('content-type')).toMatch(/text\/html/)
-
-          const html = await response.text()
-          const { locale, params } = extractDataFromHtml(html)
-
-          expect(params).toMatchObject({ catchall: ['excluded'] })
-          expect(locale).toBe('fr')
-        })
-      })
-    })
-
-    test('requests with different encoding than matcher match anyway', async ({
-      edgeOrNodeMiddlewareStaticAssetMatcher,
-    }) => {
-      const response = await fetch(
-        `${edgeOrNodeMiddlewareStaticAssetMatcher.url}/hello%2Fworld.txt`,
-      )
+              // those tests hit paths that don't satisfy middleware matcher, so should go directly to origin
+              // without going through middleware
+              test.describe('Middleware skipping (paths not satisfying middleware matcher)', () => {
+                test('should NOT match on non-localized excluded API path', async ({
+                  edgeOrNodeMiddlewareI18nExcludedPaths,
+                }) => {
+                  const response = await fetch(
+                    `${edgeOrNodeMiddlewareI18nExcludedPaths.url}/api/html`,
+                  )
 
-      // middleware was not skipped
-      expect(await response.text()).toBe('hello from middleware')
-      expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
-    })
-
-    test.describe('RSC cache poisoning', () => {
-      test('Middleware rewrite', async ({ page, edgeOrNodeMiddleware }) => {
-        const prefetchResponsePromise = new Promise((resolve) => {
-          page.on('response', (response) => {
-            if (
-              (response.url().includes('/test/rewrite-to-cached-page') ||
-                response.url().includes('/caching-rewrite-target')) &&
-              response.status() === 200
-            ) {
-              resolve(response)
-            }
-          })
-        })
-        await page.goto(`${edgeOrNodeMiddleware.url}/link-to-rewrite-to-cached-page`)
-
-        // ensure prefetch
-        await page.hover('text=NextResponse.rewrite')
-
-        // wait for prefetch request to finish
-        const prefetchResponse = await prefetchResponsePromise
-
-        // ensure prefetch respond with RSC data
-        expect(prefetchResponse.headers()['content-type']).toMatch(/text\/x-component/)
-        expect(prefetchResponse.headers()['debug-netlify-cdn-cache-control']).toMatch(
-          /s-maxage=31536000/,
-        )
-
-        const htmlResponse = await page.goto(
-          `${edgeOrNodeMiddleware.url}/test/rewrite-to-cached-page`,
-        )
-
-        // ensure we get HTML response
-        expect(htmlResponse?.headers()['content-type']).toMatch(/text\/html/)
-        expect(htmlResponse?.headers()['debug-netlify-cdn-cache-control']).toMatch(
-          /s-maxage=31536000/,
-        )
-      })
-
-      test('Middleware redirect', async ({ page, edgeOrNodeMiddleware }) => {
-        const prefetchResponsePromise = new Promise((resolve) => {
-          page.on('response', (response) => {
-            if (response.url().includes('/caching-redirect-target') && response.status() === 200) {
-              resolve(response)
-            }
-          })
-        })
-        await page.goto(`${edgeOrNodeMiddleware.url}/link-to-redirect-to-cached-page`)
-
-        // ensure prefetch
-        await page.hover('text=NextResponse.redirect')
-
-        // wait for prefetch request to finish
-        const prefetchResponse = await prefetchResponsePromise
-
-        // ensure prefetch respond with RSC data
-        expect(prefetchResponse.headers()['content-type']).toMatch(/text\/x-component/)
-        expect(prefetchResponse.headers()['debug-netlify-cdn-cache-control']).toMatch(
-          /s-maxage=31536000/,
-        )
-
-        const htmlResponse = await page.goto(
-          `${edgeOrNodeMiddleware.url}/test/redirect-to-cached-page`,
-        )
-
-        // ensure we get HTML response
-        expect(htmlResponse?.headers()['content-type']).toMatch(/text\/html/)
-        expect(htmlResponse?.headers()['debug-netlify-cdn-cache-control']).toMatch(
-          /s-maxage=31536000/,
-        )
-      })
-    })
-
-    if (isNodeMiddleware) {
-      // Node.js Middleware specific tests to test features not available in Edge Runtime
-      test.describe('Node.js Middleware specific', () => {
-        test.describe('npm package manager', () => {
-          test('node:crypto module', async ({ middlewareNodeRuntimeSpecific }) => {
-            const response = await fetch(`${middlewareNodeRuntimeSpecific.url}/test/crypto`)
-            expect(response.status).toBe(200)
-            const body = await response.json()
-            expect(
-              body.random,
-              'random should have 16 random bytes generated with `randomBytes` function from node:crypto in hex format',
-            ).toMatch(/[0-9a-f]{32}/)
-          })
+                  expect(response.headers.get('x-test-used-middleware')).not.toBe('true')
+                  expect(response.status).toBe(200)
 
-          test('node:http(s) module', async ({ middlewareNodeRuntimeSpecific }) => {
-            const response = await fetch(`${middlewareNodeRuntimeSpecific.url}/test/http`)
-            expect(response.status).toBe(200)
-            const body = await response.json()
-            expect(
-              body.proxiedWithHttpRequest,
-              'proxiedWithHttpRequest should be the result of `http.request` from node:http fetching static asset',
-            ).toStrictEqual({ hello: 'world' })
-          })
+                  const { params } = await response.json()
 
-          test('node:path module', async ({ middlewareNodeRuntimeSpecific }) => {
-            const response = await fetch(`${middlewareNodeRuntimeSpecific.url}/test/path`)
-            expect(response.status).toBe(200)
-            const body = await response.json()
-            expect(
-              body.joined,
-              'joined should be the result of `join` function from node:path',
-            ).toBe('a/b')
-          })
-        })
-
-        test.describe('pnpm package manager', () => {
-          test('node:crypto module', async ({ middlewareNodeRuntimeSpecificPnpm }) => {
-            const response = await fetch(`${middlewareNodeRuntimeSpecificPnpm.url}/test/crypto`)
-            expect(response.status).toBe(200)
-            const body = await response.json()
-            expect(
-              body.random,
-              'random should have 16 random bytes generated with `randomBytes` function from node:crypto in hex format',
-            ).toMatch(/[0-9a-f]{32}/)
-          })
+                  expect(params).toMatchObject({ catchall: ['html'] })
+                })
 
-          test('node:http(s) module', async ({ middlewareNodeRuntimeSpecificPnpm }) => {
-            const response = await fetch(`${middlewareNodeRuntimeSpecificPnpm.url}/test/http`)
-            expect(response.status).toBe(200)
-            const body = await response.json()
-            expect(
-              body.proxiedWithHttpRequest,
-              'proxiedWithHttpRequest should be the result of `http.request` from node:http fetching static asset',
-            ).toStrictEqual({ hello: 'world' })
-          })
+                test('should NOT match on non-localized excluded page path', async ({
+                  edgeOrNodeMiddlewareI18nExcludedPaths,
+                }) => {
+                  const response = await fetch(
+                    `${edgeOrNodeMiddlewareI18nExcludedPaths.url}/excluded`,
+                  )
 
-          test('node:path module', async ({ middlewareNodeRuntimeSpecificPnpm }) => {
-            const response = await fetch(`${middlewareNodeRuntimeSpecificPnpm.url}/test/path`)
-            expect(response.status).toBe(200)
-            const body = await response.json()
-            expect(
-              body.joined,
-              'joined should be the result of `join` function from node:path',
-            ).toBe('a/b')
-          })
-        })
-      })
-    }
-  })
+                  expect(response.headers.get('x-test-used-middleware')).not.toBe('true')
+                  expect(response.status).toBe(200)
+                  expect(response.headers.get('content-type')).toMatch(/text\/html/)
+
+                  const html = await response.text()
+                  const { locale, params } = extractDataFromHtml(html)
+
+                  expect(params).toMatchObject({ catchall: ['excluded'] })
+                  expect(locale).toBe(DEFAULT_LOCALE)
+                })
+
+                test('should NOT match on localized excluded page path', async ({
+                  edgeOrNodeMiddlewareI18nExcludedPaths,
+                }) => {
+                  const response = await fetch(
+                    `${edgeOrNodeMiddlewareI18nExcludedPaths.url}/fr/excluded`,
+                  )
+
+                  expect(response.headers.get('x-test-used-middleware')).not.toBe('true')
+                  expect(response.status).toBe(200)
+                  expect(response.headers.get('content-type')).toMatch(/text\/html/)
+
+                  const html = await response.text()
+                  const { locale, params } = extractDataFromHtml(html)
+
+                  expect(params).toMatchObject({ catchall: ['excluded'] })
+                  expect(locale).toBe('fr')
+                })
+              })
+            },
+          )
+        },
+      )
+    },
+  )
 }
 
 // this test is using pinned next version that doesn't support node middleware
diff --git a/tests/e2e/nx-integrated.test.ts b/tests/e2e/nx-integrated.test.ts
index 40f4ba4d48..d024e1b553 100644
--- a/tests/e2e/nx-integrated.test.ts
+++ b/tests/e2e/nx-integrated.test.ts
@@ -1,44 +1,57 @@
 import { expect, type Locator } from '@playwright/test'
-import { test } from '../utils/playwright-helpers.js'
+import { generateTestTags, test } from '../utils/playwright-helpers.js'
+import { generate } from 'fast-glob/out/managers/tasks.js'
 
 const expectImageWasLoaded = async (locator: Locator) => {
   expect(await locator.evaluate((img: HTMLImageElement) => img.naturalHeight)).toBeGreaterThan(0)
 }
 
-test('Renders the Home page correctly', async ({ page, nxIntegrated }) => {
-  await page.goto(nxIntegrated.url)
-
-  await expect(page).toHaveTitle('Welcome to next-app')
-
-  const h1 = page.locator('h1')
-  await expect(h1).toHaveText('Hello there,\nWelcome next-app 👋')
-
-  // test additional netlify.toml settings
-  await page.goto(`${nxIntegrated.url}/api/static`)
-  const body = (await page.$('body').then((el) => el?.textContent())) || '{}'
-  expect(body).toBe('{"words":"hello world"}')
-})
-
-test('Renders the Home page correctly with distDir', async ({ page, nxIntegratedDistDir }) => {
-  await page.goto(nxIntegratedDistDir.url)
-
-  await expect(page).toHaveTitle('Simple Next App')
-
-  const h1 = page.locator('h1')
-  await expect(h1).toHaveText('Home')
-
-  await expectImageWasLoaded(page.locator('img'))
-})
-
-test('environment variables from .env files should be available for functions', async ({
-  nxIntegratedDistDir,
-}) => {
-  const response = await fetch(`${nxIntegratedDistDir.url}/api/env`)
-  const data = await response.json()
-  expect(data).toEqual({
-    '.env': 'defined in .env',
-    '.env.local': 'defined in .env.local',
-    '.env.production': 'defined in .env.production',
-    '.env.production.local': 'defined in .env.production.local',
-  })
-})
+test.describe(
+  'NX integrated',
+  { tag: generateTestTags({ appRouter: true, monorepo: true }) },
+  () => {
+    test('Renders the Home page correctly', async ({ page, nxIntegrated }) => {
+      await page.goto(nxIntegrated.url)
+
+      await expect(page).toHaveTitle('Welcome to next-app')
+
+      const h1 = page.locator('h1')
+      await expect(h1).toHaveText('Hello there,\nWelcome next-app 👋')
+
+      // test additional netlify.toml settings
+      await page.goto(`${nxIntegrated.url}/api/static`)
+      const body = (await page.$('body').then((el) => el?.textContent())) || '{}'
+      expect(body).toBe('{"words":"hello world"}')
+    })
+
+    test(
+      'Renders the Home page correctly with distDir',
+      { tag: generateTestTags({ customDistDir: true }) },
+      async ({ page, nxIntegratedDistDir }) => {
+        await page.goto(nxIntegratedDistDir.url)
+
+        await expect(page).toHaveTitle('Simple Next App')
+
+        const h1 = page.locator('h1')
+        await expect(h1).toHaveText('Home')
+
+        await expectImageWasLoaded(page.locator('img'))
+      },
+    )
+
+    test(
+      'environment variables from .env files should be available for functions',
+      { tag: generateTestTags({ customDistDir: true }) },
+      async ({ nxIntegratedDistDir }) => {
+        const response = await fetch(`${nxIntegratedDistDir.url}/api/env`)
+        const data = await response.json()
+        expect(data).toEqual({
+          '.env': 'defined in .env',
+          '.env.local': 'defined in .env.local',
+          '.env.production': 'defined in .env.production',
+          '.env.production.local': 'defined in .env.production.local',
+        })
+      },
+    )
+  },
+)
diff --git a/tests/e2e/page-router.test.ts b/tests/e2e/page-router.test.ts
index d0c8f2ee11..b577386c11 100644
--- a/tests/e2e/page-router.test.ts
+++ b/tests/e2e/page-router.test.ts
@@ -1,6 +1,6 @@
 import { expect } from '@playwright/test'
 import { nextVersionSatisfies } from '../utils/next-version-helpers.mjs'
-import { test } from '../utils/playwright-helpers.js'
+import { generateTestTags, test } from '../utils/playwright-helpers.js'
 
 export function waitFor(millis: number) {
   return new Promise((resolve) => setTimeout(resolve, millis))
@@ -49,695 +49,126 @@ export async function check(
   return false
 }
 
-test.describe('Simple Page Router (no basePath, no i18n)', () => {
-  test.describe('On-demand revalidate works correctly', () => {
-    for (const {
-      label,
-      useFallback,
-      prerendered,
-      pagePath,
-      revalidateApiBasePath,
-      expectedH1Content,
-    } of [
-      {
-        label:
-          'prerendered page with static path with fallback: blocking and awaited res.revalidate()',
-        prerendered: true,
-        pagePath: '/static/revalidate-manual',
-        revalidateApiBasePath: '/api/revalidate',
-        expectedH1Content: 'Show #71',
-      },
-      {
-        label:
-          'prerendered page with dynamic path with fallback: blocking and awaited res.revalidate()',
-        prerendered: true,
-        pagePath: '/products/prerendered',
-        revalidateApiBasePath: '/api/revalidate',
-        expectedH1Content: 'Product prerendered',
-      },
-      {
-        label:
-          'not prerendered page with dynamic path with fallback: blocking and awaited res.revalidate()',
-        prerendered: false,
-        pagePath: '/products/not-prerendered',
-        revalidateApiBasePath: '/api/revalidate',
-        expectedH1Content: 'Product not-prerendered',
-      },
-      {
-        label:
-          'not prerendered page with dynamic path with fallback: blocking and not awaited res.revalidate()',
-        prerendered: false,
-        pagePath: '/products/not-prerendered-and-not-awaited-revalidation',
-        revalidateApiBasePath: '/api/revalidate-no-await',
-        expectedH1Content: 'Product not-prerendered-and-not-awaited-revalidation',
-      },
-      {
-        label:
-          'prerendered page with dynamic path with fallback: blocking and awaited res.revalidate() - non-ASCII variant',
-        prerendered: true,
-        pagePath: '/products/事前レンダリング,test',
-        revalidateApiBasePath: '/api/revalidate',
-        expectedH1Content: 'Product 事前レンダリング,test',
-      },
-      {
-        label:
-          'not prerendered page with dynamic path with fallback: blocking and awaited res.revalidate() - non-ASCII variant',
-        prerendered: false,
-        pagePath: '/products/事前レンダリングされていない,test',
-        revalidateApiBasePath: '/api/revalidate',
-        expectedH1Content: 'Product 事前レンダリングされていない,test',
-      },
-      {
-        label:
-          'prerendered page with dynamic path with fallback: true and awaited res.revalidate()',
-        prerendered: true,
-        pagePath: '/fallback-true/prerendered',
-        revalidateApiBasePath: '/api/revalidate',
-        expectedH1Content: 'Product prerendered',
-      },
-      {
-        label:
-          'not prerendered page with dynamic path with fallback: true and awaited res.revalidate()',
-        prerendered: false,
-        useFallback: true,
-        pagePath: '/fallback-true/not-prerendered',
-        revalidateApiBasePath: '/api/revalidate',
-        expectedH1Content: 'Product not-prerendered',
-      },
-    ]) {
-      test(label, async ({ page, pollUntilHeadersMatch, pageRouter }) => {
-        // in case there is retry or some other test did hit that path before
-        // we want to make sure that cdn cache is not warmed up
-        const purgeCdnCache = await page.goto(
-          new URL(`/api/purge-cdn?path=${encodeURI(pagePath)}`, pageRouter.url).href,
-        )
-        expect(purgeCdnCache?.status()).toBe(200)
-
-        // wait a bit until cdn cache purge propagates
-        await page.waitForTimeout(500)
-
-        const response1 = await pollUntilHeadersMatch(new URL(pagePath, pageRouter.url).href, {
-          headersToMatch: {
-            // either first time hitting this route or we invalidated
-            // just CDN node in earlier step
-            // we will invoke function and see Next cache hit status
-            // in the response because it was prerendered at build time
-            // or regenerated in previous attempt to run this test
-            'cache-status': [
-              /"Netlify Edge"; fwd=(miss|stale)/m,
-              prerendered ? /"Next.js"; hit/m : /"Next.js"; (hit|fwd=miss)/m,
-            ],
-          },
-          headersNotMatchedMessage:
-            'First request to tested page (html) should be a miss or stale on the Edge and hit in Next.js',
-        })
-        const headers1 = response1?.headers() || {}
-        expect(response1?.status()).toBe(200)
-        expect(headers1['x-nextjs-cache']).toBeUndefined()
-
-        const fallbackWasServed =
-          useFallback && headers1['cache-status'].includes('"Next.js"; fwd=miss')
-        if (!fallbackWasServed) {
-          expect(headers1['debug-netlify-cache-tag']).toBe(
-            `_n_t_${encodeURI(pagePath).toLowerCase()}`,
-          )
-        }
-        expect(headers1['debug-netlify-cdn-cache-control']).toBe(
-          fallbackWasServed
-            ? // fallback should not be cached
-              nextVersionSatisfies('>=15.4.0-canary.95')
-              ? `private, no-cache, no-store, max-age=0, must-revalidate, durable`
-              : undefined
-            : nextVersionSatisfies('>=15.0.0-canary.187')
-              ? 's-maxage=31536000, durable'
-              : 's-maxage=31536000, stale-while-revalidate=31536000, durable',
-        )
-
-        if (fallbackWasServed) {
-          const loading = await page.textContent('[data-testid="loading"]')
-          expect(loading, 'Fallback should be shown').toBe('Loading...')
-        }
-
-        const date1 = await page.textContent('[data-testid="date-now"]')
-        const h1 = await page.textContent('h1')
-        expect(h1).toBe(expectedH1Content)
-
-        // check json route
-        const response1Json = await pollUntilHeadersMatch(
-          new URL(`_next/data/build-id${pagePath}.json`, pageRouter.url).href,
-          {
-            headersToMatch: {
-              // either first time hitting this route or we invalidated
-              // just CDN node in earlier step
-              // we will invoke function and see Next cache hit status \
-              // in the response because it was prerendered at build time
-              // or regenerated in previous attempt to run this test
-              'cache-status': [/"Netlify Edge"; fwd=(miss|stale)/m, /"Next.js"; hit/m],
-            },
-            headersNotMatchedMessage:
-              'First request to tested page (data) should be a miss or stale on the Edge and hit in Next.js',
-          },
-        )
-        const headers1Json = response1Json?.headers() || {}
-        expect(response1Json?.status()).toBe(200)
-        expect(headers1Json['x-nextjs-cache']).toBeUndefined()
-        expect(headers1Json['debug-netlify-cache-tag']).toBe(
-          `_n_t_${encodeURI(pagePath).toLowerCase()}`,
-        )
-        expect(headers1Json['debug-netlify-cdn-cache-control']).toBe(
-          nextVersionSatisfies('>=15.0.0-canary.187')
-            ? 's-maxage=31536000, durable'
-            : 's-maxage=31536000, stale-while-revalidate=31536000, durable',
-        )
-        const data1 = (await response1Json?.json()) || {}
-        expect(data1?.pageProps?.time).toBe(date1)
-
-        const response2 = await pollUntilHeadersMatch(new URL(pagePath, pageRouter.url).href, {
-          headersToMatch: {
-            // we are hitting the same page again and we most likely will see
-            // CDN hit (in this case Next reported cache status is omitted
-            // as it didn't actually take place in handling this request)
-            // or we will see CDN miss because different CDN node handled request
-            'cache-status': /"Netlify Edge"; (hit|fwd=miss|fwd=stale)/m,
-          },
-          headersNotMatchedMessage:
-            'Second request to tested page (html) should most likely be a hit on the Edge (optionally miss or stale if different CDN node)',
-        })
-        const headers2 = response2?.headers() || {}
-        expect(response2?.status()).toBe(200)
-        expect(headers2['x-nextjs-cache']).toBeUndefined()
-        if (!headers2['cache-status'].includes('"Netlify Edge"; hit')) {
-          // if we missed CDN cache, we will see Next cache hit status
-          // as we reuse cached response
-          expect(headers2['cache-status']).toMatch(/"Next.js"; hit/m)
-        }
-        expect(headers2['debug-netlify-cdn-cache-control']).toBe(
-          nextVersionSatisfies('>=15.0.0-canary.187')
-            ? 's-maxage=31536000, durable'
-            : 's-maxage=31536000, stale-while-revalidate=31536000, durable',
-        )
-
-        // the page is cached
-        const date2 = await page.textContent('[data-testid="date-now"]')
-        expect(date2).toBe(date1)
-
-        // check json route
-        const response2Json = await pollUntilHeadersMatch(
-          new URL(`/_next/data/build-id${pagePath}.json`, pageRouter.url).href,
-          {
-            headersToMatch: {
-              // we are hitting the same page again and we most likely will see
-              // CDN hit (in this case Next reported cache status is omitted
-              // as it didn't actually take place in handling this request)
-              // or we will see CDN miss because different CDN node handled request
-              'cache-status': /"Netlify Edge"; (hit|fwd=miss|fwd=stale)/m,
-            },
-            headersNotMatchedMessage:
-              'Second request to tested page (data) should most likely be a hit on the Edge (optionally miss or stale if different CDN node)',
-          },
-        )
-        const headers2Json = response2Json?.headers() || {}
-        expect(response2Json?.status()).toBe(200)
-        expect(headers2Json['x-nextjs-cache']).toBeUndefined()
-        if (!headers2Json['cache-status'].includes('"Netlify Edge"; hit')) {
-          // if we missed CDN cache, we will see Next cache hit status
-          // as we reuse cached response
-          expect(headers2Json['cache-status']).toMatch(/"Next.js"; hit/m)
-        }
-        expect(headers2Json['debug-netlify-cdn-cache-control']).toBe(
-          nextVersionSatisfies('>=15.0.0-canary.187')
-            ? 's-maxage=31536000, durable'
-            : 's-maxage=31536000, stale-while-revalidate=31536000, durable',
-        )
-
-        const data2 = (await response2Json?.json()) || {}
-        expect(data2?.pageProps?.time).toBe(date1)
-
-        const revalidate = await page.goto(
-          new URL(`${revalidateApiBasePath}?path=${pagePath}`, pageRouter.url).href,
-        )
-        expect(revalidate?.status()).toBe(200)
-
-        // wait a bit until the page got regenerated
-        await page.waitForTimeout(1000)
-
-        // now after the revalidation it should have a different date
-        const response3 = await pollUntilHeadersMatch(new URL(pagePath, pageRouter.url).href, {
-          headersToMatch: {
-            // revalidate refreshes Next cache, but not CDN cache
-            // so our request after revalidation means that Next cache is already
-            // warmed up with fresh response, but CDN cache just knows that previously
-            // cached response is stale, so we are hitting our function that serve
-            // already cached response
-            'cache-status': [/"Next.js"; hit/m, /"Netlify Edge"; fwd=(miss|stale)/m],
-          },
-          headersNotMatchedMessage:
-            'Third request to tested page (html) should be a miss or stale on the Edge and hit in Next.js after on-demand revalidation',
-        })
-        const headers3 = response3?.headers() || {}
-        expect(response3?.status()).toBe(200)
-        expect(headers3?.['x-nextjs-cache']).toBeUndefined()
-
-        // the page has now an updated date
-        const date3 = await page.textContent('[data-testid="date-now"]')
-        expect(date3).not.toBe(date2)
-
-        // check json route
-        const response3Json = await pollUntilHeadersMatch(
-          new URL(`/_next/data/build-id${pagePath}.json`, pageRouter.url).href,
-          {
-            headersToMatch: {
-              // revalidate refreshes Next cache, but not CDN cache
-              // so our request after revalidation means that Next cache is already
-              // warmed up with fresh response, but CDN cache just knows that previously
-              // cached response is stale, so we are hitting our function that serve
-              // already cached response
-              'cache-status': [/"Next.js"; hit/m, /"Netlify Edge"; fwd=(miss|stale)/m],
-            },
-            headersNotMatchedMessage:
-              'Third request to tested page (data) should be a miss or stale on the Edge and hit in Next.js after on-demand revalidation',
-          },
-        )
-        const headers3Json = response3Json?.headers() || {}
-        expect(response3Json?.status()).toBe(200)
-        expect(headers3Json['x-nextjs-cache']).toBeUndefined()
-        expect(headers3Json['debug-netlify-cdn-cache-control']).toBe(
-          nextVersionSatisfies('>=15.0.0-canary.187')
-            ? 's-maxage=31536000, durable'
-            : 's-maxage=31536000, stale-while-revalidate=31536000, durable',
-        )
-
-        const data3 = (await response3Json?.json()) || {}
-        expect(data3?.pageProps?.time).toBe(date3)
-      })
-    }
-  })
-
-  test('Time based revalidate works correctly', async ({
-    page,
-    pollUntilHeadersMatch,
-    pageRouter,
-  }) => {
-    // in case there is retry or some other test did hit that path before
-    // we want to make sure that cdn cache is not warmed up
-    const purgeCdnCache = await page.goto(
-      new URL('/api/purge-cdn?path=/static/revalidate-slow-data', pageRouter.url).href,
-    )
-    expect(purgeCdnCache?.status()).toBe(200)
-
-    // wait a bit until cdn cache purge propagates and make sure page gets stale (revalidate 10)
-    await page.waitForTimeout(10_000)
-
-    const beforeFetch = new Date().toISOString()
-
-    const response1 = await pollUntilHeadersMatch(
-      new URL('static/revalidate-slow-data', pageRouter.url).href,
-      {
-        headersToMatch: {
-          // either first time hitting this route or we invalidated
-          // just CDN node in earlier step
-          // we will invoke function and see Next cache hit status \
-          // in the response because it was prerendered at build time
-          // or regenerated in previous attempt to run this test
-          'cache-status': [/"Netlify Edge"; fwd=(miss|stale)/m, /"Next.js"; hit/m],
+test.describe(
+  'Simple Page Router (no basePath, no i18n)',
+  {
+    tag: generateTestTags({ pagesRouter: true }),
+  },
+  () => {
+    test.describe('On-demand revalidate works correctly', () => {
+      for (const {
+        label,
+        useFallback,
+        prerendered,
+        pagePath,
+        revalidateApiBasePath,
+        expectedH1Content,
+      } of [
+        {
+          label:
+            'prerendered page with static path with fallback: blocking and awaited res.revalidate()',
+          prerendered: true,
+          pagePath: '/static/revalidate-manual',
+          revalidateApiBasePath: '/api/revalidate',
+          expectedH1Content: 'Show #71',
         },
-        headersNotMatchedMessage:
-          'First request to tested page (html) should be a miss or stale on the Edge and stale in Next.js',
-      },
-    )
-    expect(response1?.status()).toBe(200)
-    const date1 = (await page.textContent('[data-testid="date-now"]')) ?? ''
-
-    // ensure response was produced before invocation (served from cache)
-    expect(date1.localeCompare(beforeFetch)).toBeLessThan(0)
-
-    // wait a bit to ensure background work has a chance to finish
-    // (page is fresh for 10 seconds and it should take at least 5 seconds to regenerate, so we should wait at least more than 15 seconds)
-    await page.waitForTimeout(20_000)
-
-    const response2 = await pollUntilHeadersMatch(
-      new URL('static/revalidate-slow-data', pageRouter.url).href,
-      {
-        headersToMatch: {
-          // either first time hitting this route or we invalidated
-          // just CDN node in earlier step
-          // we will invoke function and see Next cache hit status \
-          // in the response because it was prerendered at build time
-          // or regenerated in previous attempt to run this test
-          'cache-status': [/"Netlify Edge"; fwd=(miss|stale)/m, /"Next.js"; hit;/m],
+        {
+          label:
+            'prerendered page with dynamic path with fallback: blocking and awaited res.revalidate()',
+          prerendered: true,
+          pagePath: '/products/prerendered',
+          revalidateApiBasePath: '/api/revalidate',
+          expectedH1Content: 'Product prerendered',
         },
-        headersNotMatchedMessage:
-          'Second request to tested page (html) should be a miss or stale on the Edge and hit or stale in Next.js',
-      },
-    )
-    expect(response2?.status()).toBe(200)
-    const date2 = (await page.textContent('[data-testid="date-now"]')) ?? ''
-
-    // ensure response was produced after initial invocation
-    expect(beforeFetch.localeCompare(date2)).toBeLessThan(0)
-  })
-
-  test('Background SWR invocations can store fresh responses in CDN cache', async ({
-    page,
-    pageRouter,
-  }) => {
-    const slug = Date.now()
-    const pathname = `/revalidate-60/${slug}`
-
-    const beforeFirstFetch = new Date().toISOString()
-
-    const response1 = await page.goto(new URL(pathname, pageRouter.url).href)
-    expect(response1?.status()).toBe(200)
-    expect(response1?.headers()['cache-status']).toMatch(
-      /"Netlify (Edge|Durable)"; fwd=(uri-miss(; stored)?|miss)/m,
-    )
-    expect(response1?.headers()['debug-netlify-cdn-cache-control']).toMatch(
-      /s-maxage=60, stale-while-revalidate=[0-9]+, durable/,
-    )
-
-    // ensure response was NOT produced before invocation
-    const date1 = (await page.textContent('[data-testid="date-now"]')) ?? ''
-    expect(date1.localeCompare(beforeFirstFetch)).toBeGreaterThan(0)
-
-    // allow page to get stale
-    await page.waitForTimeout(61_000)
-
-    const response2 = await page.goto(new URL(pathname, pageRouter.url).href)
-    expect(response2?.status()).toBe(200)
-    expect(response2?.headers()['cache-status']).toMatch(
-      /("Netlify Edge"; hit; fwd=stale|"Netlify Durable"; hit; ttl=-[0-9]+)/m,
-    )
-    expect(response2?.headers()['debug-netlify-cdn-cache-control']).toMatch(
-      /s-maxage=60, stale-while-revalidate=[0-9]+, durable/,
-    )
-
-    const date2 = (await page.textContent('[data-testid="date-now"]')) ?? ''
-    expect(date2).toBe(date1)
-
-    // wait a bit to ensure background work has a chance to finish
-    // (it should take at least 5 seconds to regenerate, so we should wait at least that much to get fresh response)
-    await page.waitForTimeout(10_000)
-
-    // subsequent request should be served with fresh response from cdn cache, as previous request
-    // should result in background SWR invocation that serves fresh response that was stored in CDN cache
-    const response3 = await page.goto(new URL(pathname, pageRouter.url).href)
-    expect(response3?.status()).toBe(200)
-    expect(response3?.headers()['cache-status']).toMatch(
-      // hit, without being followed by ';fwd=stale' for edge or negative TTL for durable, optionally with fwd=stale
-      /("Netlify Edge"; hit(?!; fwd=stale)|"Netlify Durable"; hit(?!; ttl=-[0-9]+))/m,
-    )
-    expect(response3?.headers()['debug-netlify-cdn-cache-control']).toMatch(
-      /s-maxage=60, stale-while-revalidate=[0-9]+, durable/,
-    )
-
-    const date3 = (await page.textContent('[data-testid="date-now"]')) ?? ''
-    expect(date3.localeCompare(date2)).toBeGreaterThan(0)
-  })
-
-  test('should serve 404 page when requesting non existing page (no matching route)', async ({
-    page,
-    pageRouter,
-  }) => {
-    // 404 page is built and uploaded to blobs at build time
-    // when Next.js serves 404 it will try to fetch it from the blob store
-    // if request handler function is unable to get from blob store it will
-    // fail request handling and serve 500 error.
-    // This implicitly tests that request handler function is able to read blobs
-    // that are uploaded as part of site deploy.
-
-    const response = await page.goto(new URL('non-existing', pageRouter.url).href)
-    const headers = response?.headers() || {}
-    expect(response?.status()).toBe(404)
-
-    expect(await page.textContent('p')).toBe('Custom 404 page')
-
-    // https://github.com/vercel/next.js/pull/69802 made changes to returned cache-control header,
-    // after that (14.2.10 and canary.147) 404 pages would have `private` directive, before that
-    // it would not
-    const shouldHavePrivateDirective = nextVersionSatisfies('^14.2.10 || >=15.0.0-canary.147')
-    expect(headers['debug-netlify-cdn-cache-control']).toBe(
-      (shouldHavePrivateDirective ? 'private, ' : '') +
-        'no-cache, no-store, max-age=0, must-revalidate, durable',
-    )
-    expect(headers['cache-control']).toBe(
-      (shouldHavePrivateDirective ? 'private,' : '') +
-        'no-cache,no-store,max-age=0,must-revalidate',
-    )
-  })
-
-  test('should serve 404 page when requesting non existing page (marked with notFound: true in getStaticProps)', async ({
-    page,
-    pageRouter,
-  }) => {
-    const response = await page.goto(new URL('static/not-found', pageRouter.url).href)
-    const headers = response?.headers() || {}
-    expect(response?.status()).toBe(404)
-
-    expect(await page.textContent('p')).toBe('Custom 404 page')
-
-    expect(headers['debug-netlify-cdn-cache-control']).toBe(
-      nextVersionSatisfies('>=15.0.0-canary.187')
-        ? 's-maxage=31536000, durable'
-        : 's-maxage=31536000, stale-while-revalidate=31536000, durable',
-    )
-    expect(headers['cache-control']).toBe('public,max-age=0,must-revalidate')
-  })
-
-  test('requesting a page with a very long name works', async ({ page, pageRouter }) => {
-    const response = await page.goto(
-      new URL(
-        '/products/an-incredibly-long-product-name-thats-impressively-repetetively-needlessly-overdimensioned-and-should-be-shortened-to-less-than-255-characters-for-the-sake-of-seo-and-ux-and-first-and-foremost-for-gods-sake-but-nobody-wont-ever-read-this-anyway',
-        pageRouter.url,
-      ).href,
-    )
-    expect(response?.status()).toBe(200)
-  })
-
-  // adapted from https://github.com/vercel/next.js/blob/89fcf68c6acd62caf91a8cf0bfd3fdc566e75d9d/test/e2e/app-dir/app-static/app-static.test.ts#L108
-
-  test('unstable-cache should work', async ({ pageRouter }) => {
-    const pathname = `${pageRouter.url}/api/unstable-cache-node`
-    let res = await fetch(`${pageRouter.url}/api/unstable-cache-node`)
-    expect(res.status).toBe(200)
-    let prevData = await res.json()
-
-    expect(prevData.data.random).toBeTruthy()
-
-    await check(async () => {
-      res = await fetch(pathname)
-      expect(res.status).toBe(200)
-      const curData = await res.json()
-
-      try {
-        expect(curData.data.random).toBeTruthy()
-        expect(curData.data.random).toBe(prevData.data.random)
-      } finally {
-        prevData = curData
-      }
-      return 'success'
-    }, 'success')
-  })
-
-  test('Fully static pages should be cached permanently', async ({ page, pageRouter }) => {
-    const response = await page.goto(new URL('static/fully-static', pageRouter.url).href)
-    const headers = response?.headers() || {}
-
-    expect(headers['debug-netlify-cdn-cache-control']).toBe('max-age=31536000, durable')
-    expect(headers['cache-control']).toBe('public,max-age=0,must-revalidate')
-  })
-
-  test('environment variables from .env files should be available for functions', async ({
-    pageRouter,
-  }) => {
-    const response = await fetch(`${pageRouter.url}/api/env`)
-    const data = await response.json()
-    expect(data).toEqual({
-      '.env': 'defined in .env',
-      '.env.local': 'defined in .env.local',
-      '.env.production': 'defined in .env.production',
-      '.env.production.local': 'defined in .env.production.local',
-    })
-  })
-
-  test('ISR pages that are the same after regeneration execute background getStaticProps uninterrupted', async ({
-    page,
-    pageRouter,
-  }) => {
-    const slug = Date.now()
-
-    await page.goto(new URL(`always-the-same-body/${slug}`, pageRouter.url).href)
-
-    await new Promise((resolve) => setTimeout(resolve, 15_000))
-
-    await page.goto(new URL(`always-the-same-body/${slug}`, pageRouter.url).href)
-
-    await new Promise((resolve) => setTimeout(resolve, 15_000))
-
-    await page.goto(new URL(`always-the-same-body/${slug}`, pageRouter.url).href)
-
-    await new Promise((resolve) => setTimeout(resolve, 15_000))
-
-    // keep lambda executing to allow for background getStaticProps to finish in case background work execution was suspended
-    await fetch(new URL(`api/sleep-5`, pageRouter.url).href)
-
-    const response = await fetch(new URL(`read-static-props-blobs/${slug}`, pageRouter.url).href)
-    expect(response.ok, 'response for stored data status should not fail').toBe(true)
-
-    const data = await response.json()
-
-    expect(typeof data.start, 'timestamp of getStaticProps start should be a number').toEqual(
-      'number',
-    )
-    expect(typeof data.end, 'timestamp of getStaticProps end should be a number').toEqual('number')
-
-    // duration should be around 5s overall, due to 5s timeout, but this is not exact so let's be generous and allow 10 seconds
-    // which is still less than 15 seconds between requests
-    expect(
-      data.end - data.start,
-      'getStaticProps duration should not be longer than 10 seconds',
-    ).toBeLessThan(10_000)
-  })
-
-  test('API route calling res.revalidate() on page returning notFound: true is not cacheable', async ({
-    page,
-    pageRouter,
-  }) => {
-    // note: known conditions for problematic case is
-    // 1. API route needs to call res.revalidate()
-    // 2. revalidated page's getStaticProps must return notFound: true
-    const response = await page.goto(
-      new URL('/api/revalidate?path=/static/not-found', pageRouter.url).href,
-    )
-
-    expect(response?.status()).toEqual(200)
-    expect(response?.headers()['debug-netlify-cdn-cache-control'] ?? '').not.toMatch(
-      /(s-maxage|max-age)/,
-    )
-  })
-})
-
-test.describe('Page Router with basePath and i18n', () => {
-  test.describe('Static revalidate works correctly', () => {
-    for (const {
-      label,
-      useFallback,
-      prerendered,
-      pagePath,
-      revalidateApiBasePath,
-      expectedH1Content,
-    } of [
-      {
-        label: 'prerendered page with static path and awaited res.revalidate()',
-        prerendered: true,
-        pagePath: '/static/revalidate-manual',
-        revalidateApiBasePath: '/api/revalidate',
-        expectedH1Content: 'Show #71',
-      },
-      {
-        label:
-          'prerendered page with dynamic path with fallback: blocking and awaited res.revalidate()',
-        prerendered: true,
-        pagePath: '/products/prerendered',
-        revalidateApiBasePath: '/api/revalidate',
-        expectedH1Content: 'Product prerendered',
-      },
-      {
-        label:
-          'not prerendered page with dynamic path with fallback: blocking and awaited res.revalidate()',
-        prerendered: false,
-        pagePath: '/products/not-prerendered',
-        revalidateApiBasePath: '/api/revalidate',
-        expectedH1Content: 'Product not-prerendered',
-      },
-      {
-        label:
-          'not prerendered page with dynamic path with fallback: blocking and not awaited res.revalidate()',
-        prerendered: false,
-        pagePath: '/products/not-prerendered-and-not-awaited-revalidation',
-        revalidateApiBasePath: '/api/revalidate-no-await',
-        expectedH1Content: 'Product not-prerendered-and-not-awaited-revalidation',
-      },
-      {
-        label:
-          'prerendered page with dynamic path with fallback: blocking and awaited res.revalidate() - non-ASCII variant',
-        prerendered: true,
-        pagePath: '/products/事前レンダリング,test',
-        revalidateApiBasePath: '/api/revalidate',
-        expectedH1Content: 'Product 事前レンダリング,test',
-      },
-      {
-        label:
-          'not prerendered page with dynamic path with fallback: blocking and awaited res.revalidate() - non-ASCII variant',
-        prerendered: false,
-        pagePath: '/products/事前レンダリングされていない,test',
-        revalidateApiBasePath: '/api/revalidate',
-        expectedH1Content: 'Product 事前レンダリングされていない,test',
-      },
-      {
-        label:
-          'prerendered page with dynamic path with fallback: true and awaited res.revalidate()',
-        prerendered: true,
-        pagePath: '/fallback-true/prerendered',
-        revalidateApiBasePath: '/api/revalidate',
-        expectedH1Content: 'Product prerendered',
-      },
-      {
-        label:
-          'not prerendered page with dynamic path with fallback: true and awaited res.revalidate()',
-        prerendered: false,
-        useFallback: true,
-        pagePath: '/fallback-true/not-prerendered',
-        revalidateApiBasePath: '/api/revalidate',
-        expectedH1Content: 'Product not-prerendered',
-      },
-    ]) {
-      test.describe(label, () => {
-        test(`default locale`, async ({ page, pollUntilHeadersMatch, pageRouterBasePathI18n }) => {
+        {
+          label:
+            'not prerendered page with dynamic path with fallback: blocking and awaited res.revalidate()',
+          prerendered: false,
+          pagePath: '/products/not-prerendered',
+          revalidateApiBasePath: '/api/revalidate',
+          expectedH1Content: 'Product not-prerendered',
+        },
+        {
+          label:
+            'not prerendered page with dynamic path with fallback: blocking and not awaited res.revalidate()',
+          prerendered: false,
+          pagePath: '/products/not-prerendered-and-not-awaited-revalidation',
+          revalidateApiBasePath: '/api/revalidate-no-await',
+          expectedH1Content: 'Product not-prerendered-and-not-awaited-revalidation',
+        },
+        {
+          label:
+            'prerendered page with dynamic path with fallback: blocking and awaited res.revalidate() - non-ASCII variant',
+          prerendered: true,
+          pagePath: '/products/事前レンダリング,test',
+          revalidateApiBasePath: '/api/revalidate',
+          expectedH1Content: 'Product 事前レンダリング,test',
+        },
+        {
+          label:
+            'not prerendered page with dynamic path with fallback: blocking and awaited res.revalidate() - non-ASCII variant',
+          prerendered: false,
+          pagePath: '/products/事前レンダリングされていない,test',
+          revalidateApiBasePath: '/api/revalidate',
+          expectedH1Content: 'Product 事前レンダリングされていない,test',
+        },
+        {
+          label:
+            'prerendered page with dynamic path with fallback: true and awaited res.revalidate()',
+          prerendered: true,
+          pagePath: '/fallback-true/prerendered',
+          revalidateApiBasePath: '/api/revalidate',
+          expectedH1Content: 'Product prerendered',
+        },
+        {
+          label:
+            'not prerendered page with dynamic path with fallback: true and awaited res.revalidate()',
+          prerendered: false,
+          useFallback: true,
+          pagePath: '/fallback-true/not-prerendered',
+          revalidateApiBasePath: '/api/revalidate',
+          expectedH1Content: 'Product not-prerendered',
+        },
+      ]) {
+        test(label, async ({ page, pollUntilHeadersMatch, pageRouter }) => {
           // in case there is retry or some other test did hit that path before
           // we want to make sure that cdn cache is not warmed up
           const purgeCdnCache = await page.goto(
-            new URL(
-              `/base/path/api/purge-cdn?path=/en${encodeURI(pagePath)}`,
-              pageRouterBasePathI18n.url,
-            ).href,
+            new URL(`/api/purge-cdn?path=${encodeURI(pagePath)}`, pageRouter.url).href,
           )
           expect(purgeCdnCache?.status()).toBe(200)
 
           // wait a bit until cdn cache purge propagates
           await page.waitForTimeout(500)
 
-          const response1ImplicitLocale = await pollUntilHeadersMatch(
-            new URL(`base/path${pagePath}`, pageRouterBasePathI18n.url).href,
-            {
-              headersToMatch: {
-                // either first time hitting this route or we invalidated
-                // just CDN node in earlier step
-                // we will invoke function and see Next cache hit status
-                // in the response because it was prerendered at build time
-                // or regenerated in previous attempt to run this test
-                'cache-status': [
-                  /"Netlify Edge"; fwd=(miss|stale)/m,
-                  prerendered ? /"Next.js"; hit/m : /"Next.js"; (hit|fwd=miss)/m,
-                ],
-              },
-              headersNotMatchedMessage:
-                'First request to tested page (implicit locale html) should be a miss or stale on the Edge and hit in Next.js',
+          const response1 = await pollUntilHeadersMatch(new URL(pagePath, pageRouter.url).href, {
+            headersToMatch: {
+              // either first time hitting this route or we invalidated
+              // just CDN node in earlier step
+              // we will invoke function and see Next cache hit status
+              // in the response because it was prerendered at build time
+              // or regenerated in previous attempt to run this test
+              'cache-status': [
+                /"Netlify Edge"; fwd=(miss|stale)/m,
+                prerendered ? /"Next.js"; hit/m : /"Next.js"; (hit|fwd=miss)/m,
+              ],
             },
-          )
-          const headers1ImplicitLocale = response1ImplicitLocale?.headers() || {}
-          expect(response1ImplicitLocale?.status()).toBe(200)
-          expect(headers1ImplicitLocale['x-nextjs-cache']).toBeUndefined()
-
-          const fallbackWasServedImplicitLocale =
-            useFallback && headers1ImplicitLocale['cache-status'].includes('"Next.js"; fwd=miss')
+            headersNotMatchedMessage:
+              'First request to tested page (html) should be a miss or stale on the Edge and hit in Next.js',
+          })
+          const headers1 = response1?.headers() || {}
+          expect(response1?.status()).toBe(200)
+          expect(headers1['x-nextjs-cache']).toBeUndefined()
 
-          if (!fallbackWasServedImplicitLocale) {
-            expect(headers1ImplicitLocale['debug-netlify-cache-tag']).toBe(
-              `_n_t_/en${encodeURI(pagePath).toLowerCase()}`,
+          const fallbackWasServed =
+            useFallback && headers1['cache-status'].includes('"Next.js"; fwd=miss')
+          if (!fallbackWasServed) {
+            expect(headers1['debug-netlify-cache-tag']).toBe(
+              `_n_t_${encodeURI(pagePath).toLowerCase()}`,
             )
           }
-          expect(headers1ImplicitLocale['debug-netlify-cdn-cache-control']).toBe(
-            fallbackWasServedImplicitLocale
+          expect(headers1['debug-netlify-cdn-cache-control']).toBe(
+            fallbackWasServed
               ? // fallback should not be cached
                 nextVersionSatisfies('>=15.4.0-canary.95')
                 ? `private, no-cache, no-store, max-age=0, must-revalidate, durable`
@@ -747,64 +178,18 @@ test.describe('Page Router with basePath and i18n', () => {
                 : 's-maxage=31536000, stale-while-revalidate=31536000, durable',
           )
 
-          if (fallbackWasServedImplicitLocale) {
-            const loading = await page.textContent('[data-testid="loading"]')
-            expect(loading, 'Fallback should be shown').toBe('Loading...')
-          }
-
-          const date1ImplicitLocale = await page.textContent('[data-testid="date-now"]')
-          const h1ImplicitLocale = await page.textContent('h1')
-          expect(h1ImplicitLocale).toBe(expectedH1Content)
-
-          const response1ExplicitLocale = await pollUntilHeadersMatch(
-            new URL(`base/path/en${pagePath}`, pageRouterBasePathI18n.url).href,
-            {
-              headersToMatch: {
-                // either first time hitting this route or we invalidated
-                // just CDN node in earlier step
-                // we will invoke function and see Next cache hit status \
-                // in the response because it was set by previous request that didn't have locale in pathname
-                'cache-status': [/"Netlify Edge"; fwd=(miss|stale)/m, /"Next.js"; hit/m],
-              },
-              headersNotMatchedMessage:
-                'First request to tested page (explicit locale html) should be a miss or stale on the Edge and hit in Next.js',
-            },
-          )
-          const headers1ExplicitLocale = response1ExplicitLocale?.headers() || {}
-          expect(response1ExplicitLocale?.status()).toBe(200)
-          expect(headers1ExplicitLocale['x-nextjs-cache']).toBeUndefined()
-
-          const fallbackWasServedExplicitLocale =
-            useFallback && headers1ExplicitLocale['cache-status'].includes('"Next.js"; fwd=miss')
-          expect(headers1ExplicitLocale['debug-netlify-cache-tag']).toBe(
-            fallbackWasServedExplicitLocale
-              ? undefined
-              : `_n_t_/en${encodeURI(pagePath).toLowerCase()}`,
-          )
-          expect(headers1ExplicitLocale['debug-netlify-cdn-cache-control']).toBe(
-            fallbackWasServedExplicitLocale
-              ? undefined
-              : nextVersionSatisfies('>=15.0.0-canary.187')
-                ? 's-maxage=31536000, durable'
-                : 's-maxage=31536000, stale-while-revalidate=31536000, durable',
-          )
-
-          if (fallbackWasServedExplicitLocale) {
+          if (fallbackWasServed) {
             const loading = await page.textContent('[data-testid="loading"]')
             expect(loading, 'Fallback should be shown').toBe('Loading...')
           }
 
-          const date1ExplicitLocale = await page.textContent('[data-testid="date-now"]')
-          const h1ExplicitLocale = await page.textContent('h1')
-          expect(h1ExplicitLocale).toBe(expectedH1Content)
-
-          // implicit and explicit locale paths should be the same (same cached response)
-          expect(date1ImplicitLocale).toBe(date1ExplicitLocale)
+          const date1 = await page.textContent('[data-testid="date-now"]')
+          const h1 = await page.textContent('h1')
+          expect(h1).toBe(expectedH1Content)
 
           // check json route
           const response1Json = await pollUntilHeadersMatch(
-            new URL(`base/path/_next/data/build-id/en${pagePath}.json`, pageRouterBasePathI18n.url)
-              .href,
+            new URL(`_next/data/build-id${pagePath}.json`, pageRouter.url).href,
             {
               headersToMatch: {
                 // either first time hitting this route or we invalidated
@@ -822,7 +207,7 @@ test.describe('Page Router with basePath and i18n', () => {
           expect(response1Json?.status()).toBe(200)
           expect(headers1Json['x-nextjs-cache']).toBeUndefined()
           expect(headers1Json['debug-netlify-cache-tag']).toBe(
-            `_n_t_/en${encodeURI(pagePath).toLowerCase()}`,
+            `_n_t_${encodeURI(pagePath).toLowerCase()}`,
           )
           expect(headers1Json['debug-netlify-cdn-cache-control']).toBe(
             nextVersionSatisfies('>=15.0.0-canary.187')
@@ -830,76 +215,40 @@ test.describe('Page Router with basePath and i18n', () => {
               : 's-maxage=31536000, stale-while-revalidate=31536000, durable',
           )
           const data1 = (await response1Json?.json()) || {}
-          expect(data1?.pageProps?.time).toBe(date1ImplicitLocale)
-
-          const response2ImplicitLocale = await pollUntilHeadersMatch(
-            new URL(`base/path${pagePath}`, pageRouterBasePathI18n.url).href,
-            {
-              headersToMatch: {
-                // we are hitting the same page again and we most likely will see
-                // CDN hit (in this case Next reported cache status is omitted
-                // as it didn't actually take place in handling this request)
-                // or we will see CDN miss because different CDN node handled request
-                'cache-status': /"Netlify Edge"; (hit|fwd=miss|fwd=stale)/m,
-              },
-              headersNotMatchedMessage:
-                'Second request to tested page (implicit locale html) should most likely be a hit on the Edge (optionally miss or stale if different CDN node)',
-            },
-          )
-          const headers2ImplicitLocale = response2ImplicitLocale?.headers() || {}
-          expect(response2ImplicitLocale?.status()).toBe(200)
-          expect(headers2ImplicitLocale['x-nextjs-cache']).toBeUndefined()
-          if (!headers2ImplicitLocale['cache-status'].includes('"Netlify Edge"; hit')) {
-            // if we missed CDN cache, we will see Next cache hit status
-            // as we reuse cached response
-            expect(headers2ImplicitLocale['cache-status']).toMatch(/"Next.js"; hit/m)
-          }
-          expect(headers2ImplicitLocale['debug-netlify-cdn-cache-control']).toBe(
-            nextVersionSatisfies('>=15.0.0-canary.187')
-              ? 's-maxage=31536000, durable'
-              : 's-maxage=31536000, stale-while-revalidate=31536000, durable',
-          )
-
-          // the page is cached
-          const date2ImplicitLocale = await page.textContent('[data-testid="date-now"]')
-          expect(date2ImplicitLocale).toBe(date1ImplicitLocale)
+          expect(data1?.pageProps?.time).toBe(date1)
 
-          const response2ExplicitLocale = await pollUntilHeadersMatch(
-            new URL(`base/path${pagePath}`, pageRouterBasePathI18n.url).href,
-            {
-              headersToMatch: {
-                // we are hitting the same page again and we most likely will see
-                // CDN hit (in this case Next reported cache status is omitted
-                // as it didn't actually take place in handling this request)
-                // or we will see CDN miss because different CDN node handled request
-                'cache-status': /"Netlify Edge"; (hit|fwd=miss|fwd=stale)/m,
-              },
-              headersNotMatchedMessage:
-                'Second request to tested page (implicit locale html) should most likely be a hit on the Edge (optionally miss or stale if different CDN node)',
+          const response2 = await pollUntilHeadersMatch(new URL(pagePath, pageRouter.url).href, {
+            headersToMatch: {
+              // we are hitting the same page again and we most likely will see
+              // CDN hit (in this case Next reported cache status is omitted
+              // as it didn't actually take place in handling this request)
+              // or we will see CDN miss because different CDN node handled request
+              'cache-status': /"Netlify Edge"; (hit|fwd=miss|fwd=stale)/m,
             },
-          )
-          const headers2ExplicitLocale = response2ExplicitLocale?.headers() || {}
-          expect(response2ExplicitLocale?.status()).toBe(200)
-          expect(headers2ExplicitLocale['x-nextjs-cache']).toBeUndefined()
-          if (!headers2ExplicitLocale['cache-status'].includes('"Netlify Edge"; hit')) {
+            headersNotMatchedMessage:
+              'Second request to tested page (html) should most likely be a hit on the Edge (optionally miss or stale if different CDN node)',
+          })
+          const headers2 = response2?.headers() || {}
+          expect(response2?.status()).toBe(200)
+          expect(headers2['x-nextjs-cache']).toBeUndefined()
+          if (!headers2['cache-status'].includes('"Netlify Edge"; hit')) {
             // if we missed CDN cache, we will see Next cache hit status
             // as we reuse cached response
-            expect(headers2ExplicitLocale['cache-status']).toMatch(/"Next.js"; hit/m)
+            expect(headers2['cache-status']).toMatch(/"Next.js"; hit/m)
           }
-          expect(headers2ExplicitLocale['debug-netlify-cdn-cache-control']).toBe(
+          expect(headers2['debug-netlify-cdn-cache-control']).toBe(
             nextVersionSatisfies('>=15.0.0-canary.187')
               ? 's-maxage=31536000, durable'
               : 's-maxage=31536000, stale-while-revalidate=31536000, durable',
           )
 
           // the page is cached
-          const date2ExplicitLocale = await page.textContent('[data-testid="date-now"]')
-          expect(date2ExplicitLocale).toBe(date1ExplicitLocale)
+          const date2 = await page.textContent('[data-testid="date-now"]')
+          expect(date2).toBe(date1)
 
           // check json route
           const response2Json = await pollUntilHeadersMatch(
-            new URL(`base/path/_next/data/build-id/en${pagePath}.json`, pageRouterBasePathI18n.url)
-              .href,
+            new URL(`/_next/data/build-id${pagePath}.json`, pageRouter.url).href,
             {
               headersToMatch: {
                 // we are hitting the same page again and we most likely will see
@@ -914,6 +263,7 @@ test.describe('Page Router with basePath and i18n', () => {
           )
           const headers2Json = response2Json?.headers() || {}
           expect(response2Json?.status()).toBe(200)
+          expect(headers2Json['x-nextjs-cache']).toBeUndefined()
           if (!headers2Json['cache-status'].includes('"Netlify Edge"; hit')) {
             // if we missed CDN cache, we will see Next cache hit status
             // as we reuse cached response
@@ -926,74 +276,40 @@ test.describe('Page Router with basePath and i18n', () => {
           )
 
           const data2 = (await response2Json?.json()) || {}
-          expect(data2?.pageProps?.time).toBe(date1ImplicitLocale)
-
-          // revalidate implicit locale path
-          const revalidateImplicit = await page.goto(
-            new URL(
-              `/base/path${revalidateApiBasePath}?path=${pagePath}`,
-              pageRouterBasePathI18n.url,
-            ).href,
+          expect(data2?.pageProps?.time).toBe(date1)
+
+          const revalidate = await page.goto(
+            new URL(`${revalidateApiBasePath}?path=${pagePath}`, pageRouter.url).href,
           )
-          expect(revalidateImplicit?.status()).toBe(200)
+          expect(revalidate?.status()).toBe(200)
 
           // wait a bit until the page got regenerated
           await page.waitForTimeout(1000)
 
           // now after the revalidation it should have a different date
-          const response3ImplicitLocale = await pollUntilHeadersMatch(
-            new URL(`base/path${pagePath}`, pageRouterBasePathI18n.url).href,
-            {
-              headersToMatch: {
-                // revalidate refreshes Next cache, but not CDN cache
-                // so our request after revalidation means that Next cache is already
-                // warmed up with fresh response, but CDN cache just knows that previously
-                // cached response is stale, so we are hitting our function that serve
-                // already cached response
-                'cache-status': [/"Next.js"; hit/m, /"Netlify Edge"; fwd=(miss|stale)/m],
-              },
-              headersNotMatchedMessage:
-                'Third request to tested page (implicit locale html) should be a miss or stale on the Edge and hit in Next.js after on-demand revalidation',
-            },
-          )
-          const headers3ImplicitLocale = response3ImplicitLocale?.headers() || {}
-          expect(response3ImplicitLocale?.status()).toBe(200)
-          expect(headers3ImplicitLocale?.['x-nextjs-cache']).toBeUndefined()
-
-          // the page has now an updated date
-          const date3ImplicitLocale = await page.textContent('[data-testid="date-now"]')
-          expect(date3ImplicitLocale).not.toBe(date2ImplicitLocale)
-
-          const response3ExplicitLocale = await pollUntilHeadersMatch(
-            new URL(`base/path/en${pagePath}`, pageRouterBasePathI18n.url).href,
-            {
-              headersToMatch: {
-                // revalidate refreshes Next cache, but not CDN cache
-                // so our request after revalidation means that Next cache is already
-                // warmed up with fresh response, but CDN cache just knows that previously
-                // cached response is stale, so we are hitting our function that serve
-                // already cached response
-                'cache-status': [/"Next.js"; hit/m, /"Netlify Edge"; fwd=(miss|stale)/m],
-              },
-              headersNotMatchedMessage:
-                'Third request to tested page (explicit locale html) should be a miss or stale on the Edge and hit in Next.js after on-demand revalidation',
+          const response3 = await pollUntilHeadersMatch(new URL(pagePath, pageRouter.url).href, {
+            headersToMatch: {
+              // revalidate refreshes Next cache, but not CDN cache
+              // so our request after revalidation means that Next cache is already
+              // warmed up with fresh response, but CDN cache just knows that previously
+              // cached response is stale, so we are hitting our function that serve
+              // already cached response
+              'cache-status': [/"Next.js"; hit/m, /"Netlify Edge"; fwd=(miss|stale)/m],
             },
-          )
-          const headers3ExplicitLocale = response3ExplicitLocale?.headers() || {}
-          expect(response3ExplicitLocale?.status()).toBe(200)
-          expect(headers3ExplicitLocale?.['x-nextjs-cache']).toBeUndefined()
+            headersNotMatchedMessage:
+              'Third request to tested page (html) should be a miss or stale on the Edge and hit in Next.js after on-demand revalidation',
+          })
+          const headers3 = response3?.headers() || {}
+          expect(response3?.status()).toBe(200)
+          expect(headers3?.['x-nextjs-cache']).toBeUndefined()
 
           // the page has now an updated date
-          const date3ExplicitLocale = await page.textContent('[data-testid="date-now"]')
-          expect(date3ExplicitLocale).not.toBe(date2ExplicitLocale)
-
-          // implicit and explicit locale paths should be the same (same cached response)
-          expect(date3ImplicitLocale).toBe(date3ExplicitLocale)
+          const date3 = await page.textContent('[data-testid="date-now"]')
+          expect(date3).not.toBe(date2)
 
           // check json route
           const response3Json = await pollUntilHeadersMatch(
-            new URL(`base/path/_next/data/build-id/en${pagePath}.json`, pageRouterBasePathI18n.url)
-              .href,
+            new URL(`/_next/data/build-id${pagePath}.json`, pageRouter.url).href,
             {
               headersToMatch: {
                 // revalidate refreshes Next cache, but not CDN cache
@@ -1010,11 +326,6 @@ test.describe('Page Router with basePath and i18n', () => {
           const headers3Json = response3Json?.headers() || {}
           expect(response3Json?.status()).toBe(200)
           expect(headers3Json['x-nextjs-cache']).toBeUndefined()
-          if (!headers3Json['cache-status'].includes('"Netlify Edge"; hit')) {
-            // if we missed CDN cache, we will see Next cache hit status
-            // as we reuse cached response
-            expect(headers3Json['cache-status']).toMatch(/"Next.js"; hit/m)
-          }
           expect(headers3Json['debug-netlify-cdn-cache-control']).toBe(
             nextVersionSatisfies('>=15.0.0-canary.187')
               ? 's-maxage=31536000, durable'
@@ -1022,373 +333,1094 @@ test.describe('Page Router with basePath and i18n', () => {
           )
 
           const data3 = (await response3Json?.json()) || {}
-          expect(data3?.pageProps?.time).toBe(date3ImplicitLocale)
-
-          // revalidate implicit locale path
-          const revalidateExplicit = await page.goto(
-            new URL(
-              `/base/path${revalidateApiBasePath}?path=/en${pagePath}`,
-              pageRouterBasePathI18n.url,
-            ).href,
-          )
-          expect(revalidateExplicit?.status()).toBe(200)
+          expect(data3?.pageProps?.time).toBe(date3)
+        })
+      }
+    })
 
-          // wait a bit until the page got regenerated
-          await page.waitForTimeout(1000)
+    test('Time based revalidate works correctly', async ({
+      page,
+      pollUntilHeadersMatch,
+      pageRouter,
+    }) => {
+      // in case there is retry or some other test did hit that path before
+      // we want to make sure that cdn cache is not warmed up
+      const purgeCdnCache = await page.goto(
+        new URL('/api/purge-cdn?path=/static/revalidate-slow-data', pageRouter.url).href,
+      )
+      expect(purgeCdnCache?.status()).toBe(200)
 
-          // now after the revalidation it should have a different date
-          const response4ImplicitLocale = await pollUntilHeadersMatch(
-            new URL(`base/path${pagePath}`, pageRouterBasePathI18n.url).href,
-            {
-              headersToMatch: {
-                // revalidate refreshes Next cache, but not CDN cache
-                // so our request after revalidation means that Next cache is already
-                // warmed up with fresh response, but CDN cache just knows that previously
-                // cached response is stale, so we are hitting our function that serve
-                // already cached response
-                'cache-status': [/"Next.js"; hit/m, /"Netlify Edge"; fwd=(miss|stale)/m],
-              },
-              headersNotMatchedMessage:
-                'Fourth request to tested page (implicit locale html) should be a miss or stale on the Edge and hit in Next.js after on-demand revalidation',
-            },
-          )
-          const headers4ImplicitLocale = response4ImplicitLocale?.headers() || {}
-          expect(response4ImplicitLocale?.status()).toBe(200)
-          expect(headers4ImplicitLocale?.['x-nextjs-cache']).toBeUndefined()
+      // wait a bit until cdn cache purge propagates and make sure page gets stale (revalidate 10)
+      await page.waitForTimeout(10_000)
 
-          // the page has now an updated date
-          const date4ImplicitLocale = await page.textContent('[data-testid="date-now"]')
-          expect(date4ImplicitLocale).not.toBe(date3ImplicitLocale)
+      const beforeFetch = new Date().toISOString()
 
-          const response4ExplicitLocale = await pollUntilHeadersMatch(
-            new URL(`base/path/en${pagePath}`, pageRouterBasePathI18n.url).href,
-            {
-              headersToMatch: {
-                // revalidate refreshes Next cache, but not CDN cache
-                // so our request after revalidation means that Next cache is already
-                // warmed up with fresh response, but CDN cache just knows that previously
-                // cached response is stale, so we are hitting our function that serve
-                // already cached response
-                'cache-status': [/"Next.js"; hit/m, /"Netlify Edge"; fwd=(miss|stale)/m],
-              },
-              headersNotMatchedMessage:
-                'Fourth request to tested page (explicit locale html) should be a miss or stale on the Edge and hit in Next.js after on-demand revalidation',
-            },
-          )
-          const headers4ExplicitLocale = response4ExplicitLocale?.headers() || {}
-          expect(response4ExplicitLocale?.status()).toBe(200)
-          expect(headers4ExplicitLocale?.['x-nextjs-cache']).toBeUndefined()
+      const response1 = await pollUntilHeadersMatch(
+        new URL('static/revalidate-slow-data', pageRouter.url).href,
+        {
+          headersToMatch: {
+            // either first time hitting this route or we invalidated
+            // just CDN node in earlier step
+            // we will invoke function and see Next cache hit status \
+            // in the response because it was prerendered at build time
+            // or regenerated in previous attempt to run this test
+            'cache-status': [/"Netlify Edge"; fwd=(miss|stale)/m, /"Next.js"; hit/m],
+          },
+          headersNotMatchedMessage:
+            'First request to tested page (html) should be a miss or stale on the Edge and stale in Next.js',
+        },
+      )
+      expect(response1?.status()).toBe(200)
+      const date1 = (await page.textContent('[data-testid="date-now"]')) ?? ''
 
-          // the page has now an updated date
-          const date4ExplicitLocale = await page.textContent('[data-testid="date-now"]')
-          expect(date4ExplicitLocale).not.toBe(date3ExplicitLocale)
+      // ensure response was produced before invocation (served from cache)
+      expect(date1.localeCompare(beforeFetch)).toBeLessThan(0)
 
-          // implicit and explicit locale paths should be the same (same cached response)
-          expect(date4ImplicitLocale).toBe(date4ExplicitLocale)
+      // wait a bit to ensure background work has a chance to finish
+      // (page is fresh for 10 seconds and it should take at least 5 seconds to regenerate, so we should wait at least more than 15 seconds)
+      await page.waitForTimeout(20_000)
 
-          // check json route
-          const response4Json = await pollUntilHeadersMatch(
-            new URL(`base/path/_next/data/build-id/en${pagePath}.json`, pageRouterBasePathI18n.url)
-              .href,
-            {
-              headersToMatch: {
-                // revalidate refreshes Next cache, but not CDN cache
-                // so our request after revalidation means that Next cache is already
-                // warmed up with fresh response, but CDN cache just knows that previously
-                // cached response is stale, so we are hitting our function that serve
-                // already cached response
-                'cache-status': [/"Next.js"; hit/m, /"Netlify Edge"; fwd=(miss|stale)/m],
-              },
-              headersNotMatchedMessage:
-                'Fourth request to tested page (data) should be a miss or stale on the Edge and hit in Next.js after on-demand revalidation',
-            },
-          )
-          const headers4Json = response4Json?.headers() || {}
-          expect(response4Json?.status()).toBe(200)
-          expect(headers4Json['x-nextjs-cache']).toBeUndefined()
-          expect(headers4Json['debug-netlify-cdn-cache-control']).toBe(
-            nextVersionSatisfies('>=15.0.0-canary.187')
-              ? 's-maxage=31536000, durable'
-              : 's-maxage=31536000, stale-while-revalidate=31536000, durable',
-          )
+      const response2 = await pollUntilHeadersMatch(
+        new URL('static/revalidate-slow-data', pageRouter.url).href,
+        {
+          headersToMatch: {
+            // either first time hitting this route or we invalidated
+            // just CDN node in earlier step
+            // we will invoke function and see Next cache hit status \
+            // in the response because it was prerendered at build time
+            // or regenerated in previous attempt to run this test
+            'cache-status': [/"Netlify Edge"; fwd=(miss|stale)/m, /"Next.js"; hit;/m],
+          },
+          headersNotMatchedMessage:
+            'Second request to tested page (html) should be a miss or stale on the Edge and hit or stale in Next.js',
+        },
+      )
+      expect(response2?.status()).toBe(200)
+      const date2 = (await page.textContent('[data-testid="date-now"]')) ?? ''
 
-          const data4 = (await response4Json?.json()) || {}
-          expect(data4?.pageProps?.time).toBe(date4ImplicitLocale)
-        })
+      // ensure response was produced after initial invocation
+      expect(beforeFetch.localeCompare(date2)).toBeLessThan(0)
+    })
 
-        test('non-default locale', async ({
-          page,
-          pollUntilHeadersMatch,
-          pageRouterBasePathI18n,
-        }) => {
-          // in case there is retry or some other test did hit that path before
-          // we want to make sure that cdn cache is not warmed up
-          const purgeCdnCache = await page.goto(
-            new URL(`/base/path/api/purge-cdn?path=/de${pagePath}`, pageRouterBasePathI18n.url)
-              .href,
-          )
-          expect(purgeCdnCache?.status()).toBe(200)
+    test('Background SWR invocations can store fresh responses in CDN cache', async ({
+      page,
+      pageRouter,
+    }) => {
+      const slug = Date.now()
+      const pathname = `/revalidate-60/${slug}`
 
-          // wait a bit until cdn cache purge propagates
-          await page.waitForTimeout(500)
+      const beforeFirstFetch = new Date().toISOString()
 
-          const response1 = await pollUntilHeadersMatch(
-            new URL(`/base/path/de${pagePath}`, pageRouterBasePathI18n.url).href,
-            {
-              headersToMatch: {
-                // either first time hitting this route or we invalidated
-                // just CDN node in earlier step
-                // we will invoke function and see Next cache hit status
-                // in the response because it was prerendered at build time
-                // or regenerated in previous attempt to run this test
-                'cache-status': [
-                  /"Netlify Edge"; fwd=(miss|stale)/m,
-                  prerendered ? /"Next.js"; hit/m : /"Next.js"; (hit|fwd=miss)/m,
-                ],
+      const response1 = await page.goto(new URL(pathname, pageRouter.url).href)
+      expect(response1?.status()).toBe(200)
+      expect(response1?.headers()['cache-status']).toMatch(
+        /"Netlify (Edge|Durable)"; fwd=(uri-miss(; stored)?|miss)/m,
+      )
+      expect(response1?.headers()['debug-netlify-cdn-cache-control']).toMatch(
+        /s-maxage=60, stale-while-revalidate=[0-9]+, durable/,
+      )
+
+      // ensure response was NOT produced before invocation
+      const date1 = (await page.textContent('[data-testid="date-now"]')) ?? ''
+      expect(date1.localeCompare(beforeFirstFetch)).toBeGreaterThan(0)
+
+      // allow page to get stale
+      await page.waitForTimeout(61_000)
+
+      const response2 = await page.goto(new URL(pathname, pageRouter.url).href)
+      expect(response2?.status()).toBe(200)
+      expect(response2?.headers()['cache-status']).toMatch(
+        /("Netlify Edge"; hit; fwd=stale|"Netlify Durable"; hit; ttl=-[0-9]+)/m,
+      )
+      expect(response2?.headers()['debug-netlify-cdn-cache-control']).toMatch(
+        /s-maxage=60, stale-while-revalidate=[0-9]+, durable/,
+      )
+
+      const date2 = (await page.textContent('[data-testid="date-now"]')) ?? ''
+      expect(date2).toBe(date1)
+
+      // wait a bit to ensure background work has a chance to finish
+      // (it should take at least 5 seconds to regenerate, so we should wait at least that much to get fresh response)
+      await page.waitForTimeout(10_000)
+
+      // subsequent request should be served with fresh response from cdn cache, as previous request
+      // should result in background SWR invocation that serves fresh response that was stored in CDN cache
+      const response3 = await page.goto(new URL(pathname, pageRouter.url).href)
+      expect(response3?.status()).toBe(200)
+      expect(response3?.headers()['cache-status']).toMatch(
+        // hit, without being followed by ';fwd=stale' for edge or negative TTL for durable, optionally with fwd=stale
+        /("Netlify Edge"; hit(?!; fwd=stale)|"Netlify Durable"; hit(?!; ttl=-[0-9]+))/m,
+      )
+      expect(response3?.headers()['debug-netlify-cdn-cache-control']).toMatch(
+        /s-maxage=60, stale-while-revalidate=[0-9]+, durable/,
+      )
+
+      const date3 = (await page.textContent('[data-testid="date-now"]')) ?? ''
+      expect(date3.localeCompare(date2)).toBeGreaterThan(0)
+    })
+
+    test('should serve 404 page when requesting non existing page (no matching route)', async ({
+      page,
+      pageRouter,
+    }) => {
+      // 404 page is built and uploaded to blobs at build time
+      // when Next.js serves 404 it will try to fetch it from the blob store
+      // if request handler function is unable to get from blob store it will
+      // fail request handling and serve 500 error.
+      // This implicitly tests that request handler function is able to read blobs
+      // that are uploaded as part of site deploy.
+
+      const response = await page.goto(new URL('non-existing', pageRouter.url).href)
+      const headers = response?.headers() || {}
+      expect(response?.status()).toBe(404)
+
+      expect(await page.textContent('p')).toBe('Custom 404 page')
+
+      // https://github.com/vercel/next.js/pull/69802 made changes to returned cache-control header,
+      // after that (14.2.10 and canary.147) 404 pages would have `private` directive, before that
+      // it would not
+      const shouldHavePrivateDirective = nextVersionSatisfies('^14.2.10 || >=15.0.0-canary.147')
+      expect(headers['debug-netlify-cdn-cache-control']).toBe(
+        (shouldHavePrivateDirective ? 'private, ' : '') +
+          'no-cache, no-store, max-age=0, must-revalidate, durable',
+      )
+      expect(headers['cache-control']).toBe(
+        (shouldHavePrivateDirective ? 'private,' : '') +
+          'no-cache,no-store,max-age=0,must-revalidate',
+      )
+    })
+
+    test('should serve 404 page when requesting non existing page (marked with notFound: true in getStaticProps)', async ({
+      page,
+      pageRouter,
+    }) => {
+      const response = await page.goto(new URL('static/not-found', pageRouter.url).href)
+      const headers = response?.headers() || {}
+      expect(response?.status()).toBe(404)
+
+      expect(await page.textContent('p')).toBe('Custom 404 page')
+
+      expect(headers['debug-netlify-cdn-cache-control']).toBe(
+        nextVersionSatisfies('>=15.0.0-canary.187')
+          ? 's-maxage=31536000, durable'
+          : 's-maxage=31536000, stale-while-revalidate=31536000, durable',
+      )
+      expect(headers['cache-control']).toBe('public,max-age=0,must-revalidate')
+    })
+
+    test('requesting a page with a very long name works', async ({ page, pageRouter }) => {
+      const response = await page.goto(
+        new URL(
+          '/products/an-incredibly-long-product-name-thats-impressively-repetetively-needlessly-overdimensioned-and-should-be-shortened-to-less-than-255-characters-for-the-sake-of-seo-and-ux-and-first-and-foremost-for-gods-sake-but-nobody-wont-ever-read-this-anyway',
+          pageRouter.url,
+        ).href,
+      )
+      expect(response?.status()).toBe(200)
+    })
+
+    // adapted from https://github.com/vercel/next.js/blob/89fcf68c6acd62caf91a8cf0bfd3fdc566e75d9d/test/e2e/app-dir/app-static/app-static.test.ts#L108
+
+    test('unstable-cache should work', async ({ pageRouter }) => {
+      const pathname = `${pageRouter.url}/api/unstable-cache-node`
+      let res = await fetch(`${pageRouter.url}/api/unstable-cache-node`)
+      expect(res.status).toBe(200)
+      let prevData = await res.json()
+
+      expect(prevData.data.random).toBeTruthy()
+
+      await check(async () => {
+        res = await fetch(pathname)
+        expect(res.status).toBe(200)
+        const curData = await res.json()
+
+        try {
+          expect(curData.data.random).toBeTruthy()
+          expect(curData.data.random).toBe(prevData.data.random)
+        } finally {
+          prevData = curData
+        }
+        return 'success'
+      }, 'success')
+    })
+
+    test('Fully static pages should be cached permanently', async ({ page, pageRouter }) => {
+      const response = await page.goto(new URL('static/fully-static', pageRouter.url).href)
+      const headers = response?.headers() || {}
+
+      expect(headers['debug-netlify-cdn-cache-control']).toBe('max-age=31536000, durable')
+      expect(headers['cache-control']).toBe('public,max-age=0,must-revalidate')
+    })
+
+    test('environment variables from .env files should be available for functions', async ({
+      pageRouter,
+    }) => {
+      const response = await fetch(`${pageRouter.url}/api/env`)
+      const data = await response.json()
+      expect(data).toEqual({
+        '.env': 'defined in .env',
+        '.env.local': 'defined in .env.local',
+        '.env.production': 'defined in .env.production',
+        '.env.production.local': 'defined in .env.production.local',
+      })
+    })
+
+    test('ISR pages that are the same after regeneration execute background getStaticProps uninterrupted', async ({
+      page,
+      pageRouter,
+    }) => {
+      const slug = Date.now()
+
+      await page.goto(new URL(`always-the-same-body/${slug}`, pageRouter.url).href)
+
+      await new Promise((resolve) => setTimeout(resolve, 15_000))
+
+      await page.goto(new URL(`always-the-same-body/${slug}`, pageRouter.url).href)
+
+      await new Promise((resolve) => setTimeout(resolve, 15_000))
+
+      await page.goto(new URL(`always-the-same-body/${slug}`, pageRouter.url).href)
+
+      await new Promise((resolve) => setTimeout(resolve, 15_000))
+
+      // keep lambda executing to allow for background getStaticProps to finish in case background work execution was suspended
+      await fetch(new URL(`api/sleep-5`, pageRouter.url).href)
+
+      const response = await fetch(new URL(`read-static-props-blobs/${slug}`, pageRouter.url).href)
+      expect(response.ok, 'response for stored data status should not fail').toBe(true)
+
+      const data = await response.json()
+
+      expect(typeof data.start, 'timestamp of getStaticProps start should be a number').toEqual(
+        'number',
+      )
+      expect(typeof data.end, 'timestamp of getStaticProps end should be a number').toEqual(
+        'number',
+      )
+
+      // duration should be around 5s overall, due to 5s timeout, but this is not exact so let's be generous and allow 10 seconds
+      // which is still less than 15 seconds between requests
+      expect(
+        data.end - data.start,
+        'getStaticProps duration should not be longer than 10 seconds',
+      ).toBeLessThan(10_000)
+    })
+
+    test('API route calling res.revalidate() on page returning notFound: true is not cacheable', async ({
+      page,
+      pageRouter,
+    }) => {
+      // note: known conditions for problematic case is
+      // 1. API route needs to call res.revalidate()
+      // 2. revalidated page's getStaticProps must return notFound: true
+      const response = await page.goto(
+        new URL('/api/revalidate?path=/static/not-found', pageRouter.url).href,
+      )
+
+      expect(response?.status()).toEqual(200)
+      expect(response?.headers()['debug-netlify-cdn-cache-control'] ?? '').not.toMatch(
+        /(s-maxage|max-age)/,
+      )
+    })
+  },
+)
+
+test.describe(
+  'Page Router with basePath and i18n',
+  {
+    tag: generateTestTags({ pagesRouter: true, basePath: true, i18n: true }),
+  },
+  () => {
+    test.describe('Static revalidate works correctly', () => {
+      for (const {
+        label,
+        useFallback,
+        prerendered,
+        pagePath,
+        revalidateApiBasePath,
+        expectedH1Content,
+      } of [
+        {
+          label: 'prerendered page with static path and awaited res.revalidate()',
+          prerendered: true,
+          pagePath: '/static/revalidate-manual',
+          revalidateApiBasePath: '/api/revalidate',
+          expectedH1Content: 'Show #71',
+        },
+        {
+          label:
+            'prerendered page with dynamic path with fallback: blocking and awaited res.revalidate()',
+          prerendered: true,
+          pagePath: '/products/prerendered',
+          revalidateApiBasePath: '/api/revalidate',
+          expectedH1Content: 'Product prerendered',
+        },
+        {
+          label:
+            'not prerendered page with dynamic path with fallback: blocking and awaited res.revalidate()',
+          prerendered: false,
+          pagePath: '/products/not-prerendered',
+          revalidateApiBasePath: '/api/revalidate',
+          expectedH1Content: 'Product not-prerendered',
+        },
+        {
+          label:
+            'not prerendered page with dynamic path with fallback: blocking and not awaited res.revalidate()',
+          prerendered: false,
+          pagePath: '/products/not-prerendered-and-not-awaited-revalidation',
+          revalidateApiBasePath: '/api/revalidate-no-await',
+          expectedH1Content: 'Product not-prerendered-and-not-awaited-revalidation',
+        },
+        {
+          label:
+            'prerendered page with dynamic path with fallback: blocking and awaited res.revalidate() - non-ASCII variant',
+          prerendered: true,
+          pagePath: '/products/事前レンダリング,test',
+          revalidateApiBasePath: '/api/revalidate',
+          expectedH1Content: 'Product 事前レンダリング,test',
+        },
+        {
+          label:
+            'not prerendered page with dynamic path with fallback: blocking and awaited res.revalidate() - non-ASCII variant',
+          prerendered: false,
+          pagePath: '/products/事前レンダリングされていない,test',
+          revalidateApiBasePath: '/api/revalidate',
+          expectedH1Content: 'Product 事前レンダリングされていない,test',
+        },
+        {
+          label:
+            'prerendered page with dynamic path with fallback: true and awaited res.revalidate()',
+          prerendered: true,
+          pagePath: '/fallback-true/prerendered',
+          revalidateApiBasePath: '/api/revalidate',
+          expectedH1Content: 'Product prerendered',
+        },
+        {
+          label:
+            'not prerendered page with dynamic path with fallback: true and awaited res.revalidate()',
+          prerendered: false,
+          useFallback: true,
+          pagePath: '/fallback-true/not-prerendered',
+          revalidateApiBasePath: '/api/revalidate',
+          expectedH1Content: 'Product not-prerendered',
+        },
+      ]) {
+        test.describe(label, () => {
+          test(`default locale`, async ({
+            page,
+            pollUntilHeadersMatch,
+            pageRouterBasePathI18n,
+          }) => {
+            // in case there is retry or some other test did hit that path before
+            // we want to make sure that cdn cache is not warmed up
+            const purgeCdnCache = await page.goto(
+              new URL(
+                `/base/path/api/purge-cdn?path=/en${encodeURI(pagePath)}`,
+                pageRouterBasePathI18n.url,
+              ).href,
+            )
+            expect(purgeCdnCache?.status()).toBe(200)
+
+            // wait a bit until cdn cache purge propagates
+            await page.waitForTimeout(500)
+
+            const response1ImplicitLocale = await pollUntilHeadersMatch(
+              new URL(`base/path${pagePath}`, pageRouterBasePathI18n.url).href,
+              {
+                headersToMatch: {
+                  // either first time hitting this route or we invalidated
+                  // just CDN node in earlier step
+                  // we will invoke function and see Next cache hit status
+                  // in the response because it was prerendered at build time
+                  // or regenerated in previous attempt to run this test
+                  'cache-status': [
+                    /"Netlify Edge"; fwd=(miss|stale)/m,
+                    prerendered ? /"Next.js"; hit/m : /"Next.js"; (hit|fwd=miss)/m,
+                  ],
+                },
+                headersNotMatchedMessage:
+                  'First request to tested page (implicit locale html) should be a miss or stale on the Edge and hit in Next.js',
               },
-              headersNotMatchedMessage:
-                'First request to tested page (html) should be a miss or stale on the Edge and hit in Next.js',
-            },
-          )
-          const headers1 = response1?.headers() || {}
-          expect(response1?.status()).toBe(200)
-          expect(headers1['x-nextjs-cache']).toBeUndefined()
+            )
+            const headers1ImplicitLocale = response1ImplicitLocale?.headers() || {}
+            expect(response1ImplicitLocale?.status()).toBe(200)
+            expect(headers1ImplicitLocale['x-nextjs-cache']).toBeUndefined()
+
+            const fallbackWasServedImplicitLocale =
+              useFallback && headers1ImplicitLocale['cache-status'].includes('"Next.js"; fwd=miss')
+
+            if (!fallbackWasServedImplicitLocale) {
+              expect(headers1ImplicitLocale['debug-netlify-cache-tag']).toBe(
+                `_n_t_/en${encodeURI(pagePath).toLowerCase()}`,
+              )
+            }
+            expect(headers1ImplicitLocale['debug-netlify-cdn-cache-control']).toBe(
+              fallbackWasServedImplicitLocale
+                ? // fallback should not be cached
+                  nextVersionSatisfies('>=15.4.0-canary.95')
+                  ? `private, no-cache, no-store, max-age=0, must-revalidate, durable`
+                  : undefined
+                : nextVersionSatisfies('>=15.0.0-canary.187')
+                  ? 's-maxage=31536000, durable'
+                  : 's-maxage=31536000, stale-while-revalidate=31536000, durable',
+            )
 
-          const fallbackWasServed =
-            useFallback && headers1['cache-status'].includes('"Next.js"; fwd=miss')
-          if (!fallbackWasServed) {
-            expect(headers1['debug-netlify-cache-tag']).toBe(
-              `_n_t_/de${encodeURI(pagePath).toLowerCase()}`,
+            if (fallbackWasServedImplicitLocale) {
+              const loading = await page.textContent('[data-testid="loading"]')
+              expect(loading, 'Fallback should be shown').toBe('Loading...')
+            }
+
+            const date1ImplicitLocale = await page.textContent('[data-testid="date-now"]')
+            const h1ImplicitLocale = await page.textContent('h1')
+            expect(h1ImplicitLocale).toBe(expectedH1Content)
+
+            const response1ExplicitLocale = await pollUntilHeadersMatch(
+              new URL(`base/path/en${pagePath}`, pageRouterBasePathI18n.url).href,
+              {
+                headersToMatch: {
+                  // either first time hitting this route or we invalidated
+                  // just CDN node in earlier step
+                  // we will invoke function and see Next cache hit status \
+                  // in the response because it was set by previous request that didn't have locale in pathname
+                  'cache-status': [/"Netlify Edge"; fwd=(miss|stale)/m, /"Next.js"; hit/m],
+                },
+                headersNotMatchedMessage:
+                  'First request to tested page (explicit locale html) should be a miss or stale on the Edge and hit in Next.js',
+              },
             )
-          }
-          expect(headers1['debug-netlify-cdn-cache-control']).toBe(
-            fallbackWasServed
-              ? // fallback should not be cached
-                nextVersionSatisfies('>=15.4.0-canary.95')
-                ? `private, no-cache, no-store, max-age=0, must-revalidate, durable`
-                : undefined
-              : nextVersionSatisfies('>=15.0.0-canary.187')
+            const headers1ExplicitLocale = response1ExplicitLocale?.headers() || {}
+            expect(response1ExplicitLocale?.status()).toBe(200)
+            expect(headers1ExplicitLocale['x-nextjs-cache']).toBeUndefined()
+
+            const fallbackWasServedExplicitLocale =
+              useFallback && headers1ExplicitLocale['cache-status'].includes('"Next.js"; fwd=miss')
+            expect(headers1ExplicitLocale['debug-netlify-cache-tag']).toBe(
+              fallbackWasServedExplicitLocale
+                ? undefined
+                : `_n_t_/en${encodeURI(pagePath).toLowerCase()}`,
+            )
+            expect(headers1ExplicitLocale['debug-netlify-cdn-cache-control']).toBe(
+              fallbackWasServedExplicitLocale
+                ? undefined
+                : nextVersionSatisfies('>=15.0.0-canary.187')
+                  ? 's-maxage=31536000, durable'
+                  : 's-maxage=31536000, stale-while-revalidate=31536000, durable',
+            )
+
+            if (fallbackWasServedExplicitLocale) {
+              const loading = await page.textContent('[data-testid="loading"]')
+              expect(loading, 'Fallback should be shown').toBe('Loading...')
+            }
+
+            const date1ExplicitLocale = await page.textContent('[data-testid="date-now"]')
+            const h1ExplicitLocale = await page.textContent('h1')
+            expect(h1ExplicitLocale).toBe(expectedH1Content)
+
+            // implicit and explicit locale paths should be the same (same cached response)
+            expect(date1ImplicitLocale).toBe(date1ExplicitLocale)
+
+            // check json route
+            const response1Json = await pollUntilHeadersMatch(
+              new URL(
+                `base/path/_next/data/build-id/en${pagePath}.json`,
+                pageRouterBasePathI18n.url,
+              ).href,
+              {
+                headersToMatch: {
+                  // either first time hitting this route or we invalidated
+                  // just CDN node in earlier step
+                  // we will invoke function and see Next cache hit status \
+                  // in the response because it was prerendered at build time
+                  // or regenerated in previous attempt to run this test
+                  'cache-status': [/"Netlify Edge"; fwd=(miss|stale)/m, /"Next.js"; hit/m],
+                },
+                headersNotMatchedMessage:
+                  'First request to tested page (data) should be a miss or stale on the Edge and hit in Next.js',
+              },
+            )
+            const headers1Json = response1Json?.headers() || {}
+            expect(response1Json?.status()).toBe(200)
+            expect(headers1Json['x-nextjs-cache']).toBeUndefined()
+            expect(headers1Json['debug-netlify-cache-tag']).toBe(
+              `_n_t_/en${encodeURI(pagePath).toLowerCase()}`,
+            )
+            expect(headers1Json['debug-netlify-cdn-cache-control']).toBe(
+              nextVersionSatisfies('>=15.0.0-canary.187')
                 ? 's-maxage=31536000, durable'
                 : 's-maxage=31536000, stale-while-revalidate=31536000, durable',
-          )
-
-          if (fallbackWasServed) {
-            const loading = await page.textContent('[data-testid="loading"]')
-            expect(loading, 'Fallback should be shown').toBe('Loading...')
-          }
-
-          const date1 = await page.textContent('[data-testid="date-now"]')
-          const h1 = await page.textContent('h1')
-          expect(h1).toBe(expectedH1Content)
+            )
+            const data1 = (await response1Json?.json()) || {}
+            expect(data1?.pageProps?.time).toBe(date1ImplicitLocale)
+
+            const response2ImplicitLocale = await pollUntilHeadersMatch(
+              new URL(`base/path${pagePath}`, pageRouterBasePathI18n.url).href,
+              {
+                headersToMatch: {
+                  // we are hitting the same page again and we most likely will see
+                  // CDN hit (in this case Next reported cache status is omitted
+                  // as it didn't actually take place in handling this request)
+                  // or we will see CDN miss because different CDN node handled request
+                  'cache-status': /"Netlify Edge"; (hit|fwd=miss|fwd=stale)/m,
+                },
+                headersNotMatchedMessage:
+                  'Second request to tested page (implicit locale html) should most likely be a hit on the Edge (optionally miss or stale if different CDN node)',
+              },
+            )
+            const headers2ImplicitLocale = response2ImplicitLocale?.headers() || {}
+            expect(response2ImplicitLocale?.status()).toBe(200)
+            expect(headers2ImplicitLocale['x-nextjs-cache']).toBeUndefined()
+            if (!headers2ImplicitLocale['cache-status'].includes('"Netlify Edge"; hit')) {
+              // if we missed CDN cache, we will see Next cache hit status
+              // as we reuse cached response
+              expect(headers2ImplicitLocale['cache-status']).toMatch(/"Next.js"; hit/m)
+            }
+            expect(headers2ImplicitLocale['debug-netlify-cdn-cache-control']).toBe(
+              nextVersionSatisfies('>=15.0.0-canary.187')
+                ? 's-maxage=31536000, durable'
+                : 's-maxage=31536000, stale-while-revalidate=31536000, durable',
+            )
 
-          // check json route
-          const response1Json = await pollUntilHeadersMatch(
-            new URL(`base/path/_next/data/build-id/de${pagePath}.json`, pageRouterBasePathI18n.url)
-              .href,
-            {
-              headersToMatch: {
-                // either first time hitting this route or we invalidated
-                // just CDN node in earlier step
-                // we will invoke function and see Next cache hit status \
-                // in the response because it was prerendered at build time
-                // or regenerated in previous attempt to run this test
-                'cache-status': [/"Netlify Edge"; fwd=(miss|stale)/m, /"Next.js"; hit/m],
+            // the page is cached
+            const date2ImplicitLocale = await page.textContent('[data-testid="date-now"]')
+            expect(date2ImplicitLocale).toBe(date1ImplicitLocale)
+
+            const response2ExplicitLocale = await pollUntilHeadersMatch(
+              new URL(`base/path${pagePath}`, pageRouterBasePathI18n.url).href,
+              {
+                headersToMatch: {
+                  // we are hitting the same page again and we most likely will see
+                  // CDN hit (in this case Next reported cache status is omitted
+                  // as it didn't actually take place in handling this request)
+                  // or we will see CDN miss because different CDN node handled request
+                  'cache-status': /"Netlify Edge"; (hit|fwd=miss|fwd=stale)/m,
+                },
+                headersNotMatchedMessage:
+                  'Second request to tested page (implicit locale html) should most likely be a hit on the Edge (optionally miss or stale if different CDN node)',
               },
-              headersNotMatchedMessage:
-                'First request to tested page (data) should be a miss or stale on the Edge and hit in Next.js',
-            },
-          )
-          const headers1Json = response1Json?.headers() || {}
-          expect(response1Json?.status()).toBe(200)
-          expect(headers1Json['x-nextjs-cache']).toBeUndefined()
-          expect(headers1Json['debug-netlify-cache-tag']).toBe(
-            `_n_t_/de${encodeURI(pagePath).toLowerCase()}`,
-          )
-          expect(headers1Json['debug-netlify-cdn-cache-control']).toBe(
-            nextVersionSatisfies('>=15.0.0-canary.187')
-              ? 's-maxage=31536000, durable'
-              : 's-maxage=31536000, stale-while-revalidate=31536000, durable',
-          )
-          const data1 = (await response1Json?.json()) || {}
-          expect(data1?.pageProps?.time).toBe(date1)
+            )
+            const headers2ExplicitLocale = response2ExplicitLocale?.headers() || {}
+            expect(response2ExplicitLocale?.status()).toBe(200)
+            expect(headers2ExplicitLocale['x-nextjs-cache']).toBeUndefined()
+            if (!headers2ExplicitLocale['cache-status'].includes('"Netlify Edge"; hit')) {
+              // if we missed CDN cache, we will see Next cache hit status
+              // as we reuse cached response
+              expect(headers2ExplicitLocale['cache-status']).toMatch(/"Next.js"; hit/m)
+            }
+            expect(headers2ExplicitLocale['debug-netlify-cdn-cache-control']).toBe(
+              nextVersionSatisfies('>=15.0.0-canary.187')
+                ? 's-maxage=31536000, durable'
+                : 's-maxage=31536000, stale-while-revalidate=31536000, durable',
+            )
 
-          const response2 = await pollUntilHeadersMatch(
-            new URL(`base/path/de${pagePath}`, pageRouterBasePathI18n.url).href,
-            {
-              headersToMatch: {
-                // we are hitting the same page again and we most likely will see
-                // CDN hit (in this case Next reported cache status is omitted
-                // as it didn't actually take place in handling this request)
-                // or we will see CDN miss because different CDN node handled request
-                'cache-status': /"Netlify Edge"; (hit|fwd=miss|fwd=stale)/m,
+            // the page is cached
+            const date2ExplicitLocale = await page.textContent('[data-testid="date-now"]')
+            expect(date2ExplicitLocale).toBe(date1ExplicitLocale)
+
+            // check json route
+            const response2Json = await pollUntilHeadersMatch(
+              new URL(
+                `base/path/_next/data/build-id/en${pagePath}.json`,
+                pageRouterBasePathI18n.url,
+              ).href,
+              {
+                headersToMatch: {
+                  // we are hitting the same page again and we most likely will see
+                  // CDN hit (in this case Next reported cache status is omitted
+                  // as it didn't actually take place in handling this request)
+                  // or we will see CDN miss because different CDN node handled request
+                  'cache-status': /"Netlify Edge"; (hit|fwd=miss|fwd=stale)/m,
+                },
+                headersNotMatchedMessage:
+                  'Second request to tested page (data) should most likely be a hit on the Edge (optionally miss or stale if different CDN node)',
               },
-              headersNotMatchedMessage:
-                'Second request to tested page (html) should most likely be a hit on the Edge (optionally miss or stale if different CDN node)',
-            },
-          )
-          const headers2 = response2?.headers() || {}
-          expect(response2?.status()).toBe(200)
-          expect(headers2['x-nextjs-cache']).toBeUndefined()
-          if (!headers2['cache-status'].includes('"Netlify Edge"; hit')) {
-            // if we missed CDN cache, we will see Next cache hit status
-            // as we reuse cached response
-            expect(headers2['cache-status']).toMatch(/"Next.js"; hit/m)
-          }
-          expect(headers2['debug-netlify-cdn-cache-control']).toBe(
-            nextVersionSatisfies('>=15.0.0-canary.187')
-              ? 's-maxage=31536000, durable'
-              : 's-maxage=31536000, stale-while-revalidate=31536000, durable',
-          )
+            )
+            const headers2Json = response2Json?.headers() || {}
+            expect(response2Json?.status()).toBe(200)
+            if (!headers2Json['cache-status'].includes('"Netlify Edge"; hit')) {
+              // if we missed CDN cache, we will see Next cache hit status
+              // as we reuse cached response
+              expect(headers2Json['cache-status']).toMatch(/"Next.js"; hit/m)
+            }
+            expect(headers2Json['debug-netlify-cdn-cache-control']).toBe(
+              nextVersionSatisfies('>=15.0.0-canary.187')
+                ? 's-maxage=31536000, durable'
+                : 's-maxage=31536000, stale-while-revalidate=31536000, durable',
+            )
 
-          // the page is cached
-          const date2 = await page.textContent('[data-testid="date-now"]')
-          expect(date2).toBe(date1)
+            const data2 = (await response2Json?.json()) || {}
+            expect(data2?.pageProps?.time).toBe(date1ImplicitLocale)
 
-          // check json route
-          const response2Json = await pollUntilHeadersMatch(
-            new URL(`base/path/_next/data/build-id/de${pagePath}.json`, pageRouterBasePathI18n.url)
-              .href,
-            {
-              headersToMatch: {
-                // we are hitting the same page again and we most likely will see
-                // CDN hit (in this case Next reported cache status is omitted
-                // as it didn't actually take place in handling this request)
-                // or we will see CDN miss because different CDN node handled request
-                'cache-status': /"Netlify Edge"; (hit|fwd=miss|fwd=stale)/m,
+            // revalidate implicit locale path
+            const revalidateImplicit = await page.goto(
+              new URL(
+                `/base/path${revalidateApiBasePath}?path=${pagePath}`,
+                pageRouterBasePathI18n.url,
+              ).href,
+            )
+            expect(revalidateImplicit?.status()).toBe(200)
+
+            // wait a bit until the page got regenerated
+            await page.waitForTimeout(1000)
+
+            // now after the revalidation it should have a different date
+            const response3ImplicitLocale = await pollUntilHeadersMatch(
+              new URL(`base/path${pagePath}`, pageRouterBasePathI18n.url).href,
+              {
+                headersToMatch: {
+                  // revalidate refreshes Next cache, but not CDN cache
+                  // so our request after revalidation means that Next cache is already
+                  // warmed up with fresh response, but CDN cache just knows that previously
+                  // cached response is stale, so we are hitting our function that serve
+                  // already cached response
+                  'cache-status': [/"Next.js"; hit/m, /"Netlify Edge"; fwd=(miss|stale)/m],
+                },
+                headersNotMatchedMessage:
+                  'Third request to tested page (implicit locale html) should be a miss or stale on the Edge and hit in Next.js after on-demand revalidation',
               },
-              headersNotMatchedMessage:
-                'Second request to tested page (data) should most likely be a hit on the Edge (optionally miss or stale if different CDN node)',
-            },
-          )
-          const headers2Json = response2Json?.headers() || {}
-          expect(response2Json?.status()).toBe(200)
-          expect(headers2Json['x-nextjs-cache']).toBeUndefined()
-          if (!headers2Json['cache-status'].includes('"Netlify Edge"; hit')) {
-            // if we missed CDN cache, we will see Next cache hit status
-            // as we reuse cached response
-            expect(headers2Json['cache-status']).toMatch(/"Next.js"; hit/m)
-          }
-          expect(headers2Json['debug-netlify-cdn-cache-control']).toBe(
-            nextVersionSatisfies('>=15.0.0-canary.187')
-              ? 's-maxage=31536000, durable'
-              : 's-maxage=31536000, stale-while-revalidate=31536000, durable',
-          )
+            )
+            const headers3ImplicitLocale = response3ImplicitLocale?.headers() || {}
+            expect(response3ImplicitLocale?.status()).toBe(200)
+            expect(headers3ImplicitLocale?.['x-nextjs-cache']).toBeUndefined()
+
+            // the page has now an updated date
+            const date3ImplicitLocale = await page.textContent('[data-testid="date-now"]')
+            expect(date3ImplicitLocale).not.toBe(date2ImplicitLocale)
+
+            const response3ExplicitLocale = await pollUntilHeadersMatch(
+              new URL(`base/path/en${pagePath}`, pageRouterBasePathI18n.url).href,
+              {
+                headersToMatch: {
+                  // revalidate refreshes Next cache, but not CDN cache
+                  // so our request after revalidation means that Next cache is already
+                  // warmed up with fresh response, but CDN cache just knows that previously
+                  // cached response is stale, so we are hitting our function that serve
+                  // already cached response
+                  'cache-status': [/"Next.js"; hit/m, /"Netlify Edge"; fwd=(miss|stale)/m],
+                },
+                headersNotMatchedMessage:
+                  'Third request to tested page (explicit locale html) should be a miss or stale on the Edge and hit in Next.js after on-demand revalidation',
+              },
+            )
+            const headers3ExplicitLocale = response3ExplicitLocale?.headers() || {}
+            expect(response3ExplicitLocale?.status()).toBe(200)
+            expect(headers3ExplicitLocale?.['x-nextjs-cache']).toBeUndefined()
+
+            // the page has now an updated date
+            const date3ExplicitLocale = await page.textContent('[data-testid="date-now"]')
+            expect(date3ExplicitLocale).not.toBe(date2ExplicitLocale)
+
+            // implicit and explicit locale paths should be the same (same cached response)
+            expect(date3ImplicitLocale).toBe(date3ExplicitLocale)
+
+            // check json route
+            const response3Json = await pollUntilHeadersMatch(
+              new URL(
+                `base/path/_next/data/build-id/en${pagePath}.json`,
+                pageRouterBasePathI18n.url,
+              ).href,
+              {
+                headersToMatch: {
+                  // revalidate refreshes Next cache, but not CDN cache
+                  // so our request after revalidation means that Next cache is already
+                  // warmed up with fresh response, but CDN cache just knows that previously
+                  // cached response is stale, so we are hitting our function that serve
+                  // already cached response
+                  'cache-status': [/"Next.js"; hit/m, /"Netlify Edge"; fwd=(miss|stale)/m],
+                },
+                headersNotMatchedMessage:
+                  'Third request to tested page (data) should be a miss or stale on the Edge and hit in Next.js after on-demand revalidation',
+              },
+            )
+            const headers3Json = response3Json?.headers() || {}
+            expect(response3Json?.status()).toBe(200)
+            expect(headers3Json['x-nextjs-cache']).toBeUndefined()
+            if (!headers3Json['cache-status'].includes('"Netlify Edge"; hit')) {
+              // if we missed CDN cache, we will see Next cache hit status
+              // as we reuse cached response
+              expect(headers3Json['cache-status']).toMatch(/"Next.js"; hit/m)
+            }
+            expect(headers3Json['debug-netlify-cdn-cache-control']).toBe(
+              nextVersionSatisfies('>=15.0.0-canary.187')
+                ? 's-maxage=31536000, durable'
+                : 's-maxage=31536000, stale-while-revalidate=31536000, durable',
+            )
 
-          const data2 = (await response2Json?.json()) || {}
-          expect(data2?.pageProps?.time).toBe(date1)
+            const data3 = (await response3Json?.json()) || {}
+            expect(data3?.pageProps?.time).toBe(date3ImplicitLocale)
 
-          const revalidate = await page.goto(
-            new URL(
-              `/base/path${revalidateApiBasePath}?path=/de${pagePath}`,
-              pageRouterBasePathI18n.url,
-            ).href,
-          )
-          expect(revalidate?.status()).toBe(200)
+            // revalidate implicit locale path
+            const revalidateExplicit = await page.goto(
+              new URL(
+                `/base/path${revalidateApiBasePath}?path=/en${pagePath}`,
+                pageRouterBasePathI18n.url,
+              ).href,
+            )
+            expect(revalidateExplicit?.status()).toBe(200)
+
+            // wait a bit until the page got regenerated
+            await page.waitForTimeout(1000)
+
+            // now after the revalidation it should have a different date
+            const response4ImplicitLocale = await pollUntilHeadersMatch(
+              new URL(`base/path${pagePath}`, pageRouterBasePathI18n.url).href,
+              {
+                headersToMatch: {
+                  // revalidate refreshes Next cache, but not CDN cache
+                  // so our request after revalidation means that Next cache is already
+                  // warmed up with fresh response, but CDN cache just knows that previously
+                  // cached response is stale, so we are hitting our function that serve
+                  // already cached response
+                  'cache-status': [/"Next.js"; hit/m, /"Netlify Edge"; fwd=(miss|stale)/m],
+                },
+                headersNotMatchedMessage:
+                  'Fourth request to tested page (implicit locale html) should be a miss or stale on the Edge and hit in Next.js after on-demand revalidation',
+              },
+            )
+            const headers4ImplicitLocale = response4ImplicitLocale?.headers() || {}
+            expect(response4ImplicitLocale?.status()).toBe(200)
+            expect(headers4ImplicitLocale?.['x-nextjs-cache']).toBeUndefined()
+
+            // the page has now an updated date
+            const date4ImplicitLocale = await page.textContent('[data-testid="date-now"]')
+            expect(date4ImplicitLocale).not.toBe(date3ImplicitLocale)
+
+            const response4ExplicitLocale = await pollUntilHeadersMatch(
+              new URL(`base/path/en${pagePath}`, pageRouterBasePathI18n.url).href,
+              {
+                headersToMatch: {
+                  // revalidate refreshes Next cache, but not CDN cache
+                  // so our request after revalidation means that Next cache is already
+                  // warmed up with fresh response, but CDN cache just knows that previously
+                  // cached response is stale, so we are hitting our function that serve
+                  // already cached response
+                  'cache-status': [/"Next.js"; hit/m, /"Netlify Edge"; fwd=(miss|stale)/m],
+                },
+                headersNotMatchedMessage:
+                  'Fourth request to tested page (explicit locale html) should be a miss or stale on the Edge and hit in Next.js after on-demand revalidation',
+              },
+            )
+            const headers4ExplicitLocale = response4ExplicitLocale?.headers() || {}
+            expect(response4ExplicitLocale?.status()).toBe(200)
+            expect(headers4ExplicitLocale?.['x-nextjs-cache']).toBeUndefined()
+
+            // the page has now an updated date
+            const date4ExplicitLocale = await page.textContent('[data-testid="date-now"]')
+            expect(date4ExplicitLocale).not.toBe(date3ExplicitLocale)
+
+            // implicit and explicit locale paths should be the same (same cached response)
+            expect(date4ImplicitLocale).toBe(date4ExplicitLocale)
+
+            // check json route
+            const response4Json = await pollUntilHeadersMatch(
+              new URL(
+                `base/path/_next/data/build-id/en${pagePath}.json`,
+                pageRouterBasePathI18n.url,
+              ).href,
+              {
+                headersToMatch: {
+                  // revalidate refreshes Next cache, but not CDN cache
+                  // so our request after revalidation means that Next cache is already
+                  // warmed up with fresh response, but CDN cache just knows that previously
+                  // cached response is stale, so we are hitting our function that serve
+                  // already cached response
+                  'cache-status': [/"Next.js"; hit/m, /"Netlify Edge"; fwd=(miss|stale)/m],
+                },
+                headersNotMatchedMessage:
+                  'Fourth request to tested page (data) should be a miss or stale on the Edge and hit in Next.js after on-demand revalidation',
+              },
+            )
+            const headers4Json = response4Json?.headers() || {}
+            expect(response4Json?.status()).toBe(200)
+            expect(headers4Json['x-nextjs-cache']).toBeUndefined()
+            expect(headers4Json['debug-netlify-cdn-cache-control']).toBe(
+              nextVersionSatisfies('>=15.0.0-canary.187')
+                ? 's-maxage=31536000, durable'
+                : 's-maxage=31536000, stale-while-revalidate=31536000, durable',
+            )
 
-          // wait a bit until the page got regenerated
-          await page.waitForTimeout(1000)
+            const data4 = (await response4Json?.json()) || {}
+            expect(data4?.pageProps?.time).toBe(date4ImplicitLocale)
+          })
+
+          test('non-default locale', async ({
+            page,
+            pollUntilHeadersMatch,
+            pageRouterBasePathI18n,
+          }) => {
+            // in case there is retry or some other test did hit that path before
+            // we want to make sure that cdn cache is not warmed up
+            const purgeCdnCache = await page.goto(
+              new URL(`/base/path/api/purge-cdn?path=/de${pagePath}`, pageRouterBasePathI18n.url)
+                .href,
+            )
+            expect(purgeCdnCache?.status()).toBe(200)
+
+            // wait a bit until cdn cache purge propagates
+            await page.waitForTimeout(500)
+
+            const response1 = await pollUntilHeadersMatch(
+              new URL(`/base/path/de${pagePath}`, pageRouterBasePathI18n.url).href,
+              {
+                headersToMatch: {
+                  // either first time hitting this route or we invalidated
+                  // just CDN node in earlier step
+                  // we will invoke function and see Next cache hit status
+                  // in the response because it was prerendered at build time
+                  // or regenerated in previous attempt to run this test
+                  'cache-status': [
+                    /"Netlify Edge"; fwd=(miss|stale)/m,
+                    prerendered ? /"Next.js"; hit/m : /"Next.js"; (hit|fwd=miss)/m,
+                  ],
+                },
+                headersNotMatchedMessage:
+                  'First request to tested page (html) should be a miss or stale on the Edge and hit in Next.js',
+              },
+            )
+            const headers1 = response1?.headers() || {}
+            expect(response1?.status()).toBe(200)
+            expect(headers1['x-nextjs-cache']).toBeUndefined()
+
+            const fallbackWasServed =
+              useFallback && headers1['cache-status'].includes('"Next.js"; fwd=miss')
+            if (!fallbackWasServed) {
+              expect(headers1['debug-netlify-cache-tag']).toBe(
+                `_n_t_/de${encodeURI(pagePath).toLowerCase()}`,
+              )
+            }
+            expect(headers1['debug-netlify-cdn-cache-control']).toBe(
+              fallbackWasServed
+                ? // fallback should not be cached
+                  nextVersionSatisfies('>=15.4.0-canary.95')
+                  ? `private, no-cache, no-store, max-age=0, must-revalidate, durable`
+                  : undefined
+                : nextVersionSatisfies('>=15.0.0-canary.187')
+                  ? 's-maxage=31536000, durable'
+                  : 's-maxage=31536000, stale-while-revalidate=31536000, durable',
+            )
 
-          // now after the revalidation it should have a different date
-          const response3 = await pollUntilHeadersMatch(
-            new URL(`base/path/de${pagePath}`, pageRouterBasePathI18n.url).href,
-            {
-              headersToMatch: {
-                // revalidate refreshes Next cache, but not CDN cache
-                // so our request after revalidation means that Next cache is already
-                // warmed up with fresh response, but CDN cache just knows that previously
-                // cached response is stale, so we are hitting our function that serve
-                // already cached response
-                'cache-status': [/"Next.js"; hit/m, /"Netlify Edge"; fwd=(miss|stale)/m],
+            if (fallbackWasServed) {
+              const loading = await page.textContent('[data-testid="loading"]')
+              expect(loading, 'Fallback should be shown').toBe('Loading...')
+            }
+
+            const date1 = await page.textContent('[data-testid="date-now"]')
+            const h1 = await page.textContent('h1')
+            expect(h1).toBe(expectedH1Content)
+
+            // check json route
+            const response1Json = await pollUntilHeadersMatch(
+              new URL(
+                `base/path/_next/data/build-id/de${pagePath}.json`,
+                pageRouterBasePathI18n.url,
+              ).href,
+              {
+                headersToMatch: {
+                  // either first time hitting this route or we invalidated
+                  // just CDN node in earlier step
+                  // we will invoke function and see Next cache hit status \
+                  // in the response because it was prerendered at build time
+                  // or regenerated in previous attempt to run this test
+                  'cache-status': [/"Netlify Edge"; fwd=(miss|stale)/m, /"Next.js"; hit/m],
+                },
+                headersNotMatchedMessage:
+                  'First request to tested page (data) should be a miss or stale on the Edge and hit in Next.js',
               },
-              headersNotMatchedMessage:
-                'Third request to tested page (html) should be a miss or stale on the Edge and hit in Next.js after on-demand revalidation',
-            },
-          )
-          const headers3 = response3?.headers() || {}
-          expect(response3?.status()).toBe(200)
-          expect(headers3?.['x-nextjs-cache']).toBeUndefined()
+            )
+            const headers1Json = response1Json?.headers() || {}
+            expect(response1Json?.status()).toBe(200)
+            expect(headers1Json['x-nextjs-cache']).toBeUndefined()
+            expect(headers1Json['debug-netlify-cache-tag']).toBe(
+              `_n_t_/de${encodeURI(pagePath).toLowerCase()}`,
+            )
+            expect(headers1Json['debug-netlify-cdn-cache-control']).toBe(
+              nextVersionSatisfies('>=15.0.0-canary.187')
+                ? 's-maxage=31536000, durable'
+                : 's-maxage=31536000, stale-while-revalidate=31536000, durable',
+            )
+            const data1 = (await response1Json?.json()) || {}
+            expect(data1?.pageProps?.time).toBe(date1)
+
+            const response2 = await pollUntilHeadersMatch(
+              new URL(`base/path/de${pagePath}`, pageRouterBasePathI18n.url).href,
+              {
+                headersToMatch: {
+                  // we are hitting the same page again and we most likely will see
+                  // CDN hit (in this case Next reported cache status is omitted
+                  // as it didn't actually take place in handling this request)
+                  // or we will see CDN miss because different CDN node handled request
+                  'cache-status': /"Netlify Edge"; (hit|fwd=miss|fwd=stale)/m,
+                },
+                headersNotMatchedMessage:
+                  'Second request to tested page (html) should most likely be a hit on the Edge (optionally miss or stale if different CDN node)',
+              },
+            )
+            const headers2 = response2?.headers() || {}
+            expect(response2?.status()).toBe(200)
+            expect(headers2['x-nextjs-cache']).toBeUndefined()
+            if (!headers2['cache-status'].includes('"Netlify Edge"; hit')) {
+              // if we missed CDN cache, we will see Next cache hit status
+              // as we reuse cached response
+              expect(headers2['cache-status']).toMatch(/"Next.js"; hit/m)
+            }
+            expect(headers2['debug-netlify-cdn-cache-control']).toBe(
+              nextVersionSatisfies('>=15.0.0-canary.187')
+                ? 's-maxage=31536000, durable'
+                : 's-maxage=31536000, stale-while-revalidate=31536000, durable',
+            )
 
-          // the page has now an updated date
-          const date3 = await page.textContent('[data-testid="date-now"]')
-          expect(date3).not.toBe(date2)
+            // the page is cached
+            const date2 = await page.textContent('[data-testid="date-now"]')
+            expect(date2).toBe(date1)
+
+            // check json route
+            const response2Json = await pollUntilHeadersMatch(
+              new URL(
+                `base/path/_next/data/build-id/de${pagePath}.json`,
+                pageRouterBasePathI18n.url,
+              ).href,
+              {
+                headersToMatch: {
+                  // we are hitting the same page again and we most likely will see
+                  // CDN hit (in this case Next reported cache status is omitted
+                  // as it didn't actually take place in handling this request)
+                  // or we will see CDN miss because different CDN node handled request
+                  'cache-status': /"Netlify Edge"; (hit|fwd=miss|fwd=stale)/m,
+                },
+                headersNotMatchedMessage:
+                  'Second request to tested page (data) should most likely be a hit on the Edge (optionally miss or stale if different CDN node)',
+              },
+            )
+            const headers2Json = response2Json?.headers() || {}
+            expect(response2Json?.status()).toBe(200)
+            expect(headers2Json['x-nextjs-cache']).toBeUndefined()
+            if (!headers2Json['cache-status'].includes('"Netlify Edge"; hit')) {
+              // if we missed CDN cache, we will see Next cache hit status
+              // as we reuse cached response
+              expect(headers2Json['cache-status']).toMatch(/"Next.js"; hit/m)
+            }
+            expect(headers2Json['debug-netlify-cdn-cache-control']).toBe(
+              nextVersionSatisfies('>=15.0.0-canary.187')
+                ? 's-maxage=31536000, durable'
+                : 's-maxage=31536000, stale-while-revalidate=31536000, durable',
+            )
 
-          // check json route
-          const response3Json = await pollUntilHeadersMatch(
-            new URL(`base/path/_next/data/build-id/de${pagePath}.json`, pageRouterBasePathI18n.url)
-              .href,
-            {
-              headersToMatch: {
-                // revalidate refreshes Next cache, but not CDN cache
-                // so our request after revalidation means that Next cache is already
-                // warmed up with fresh response, but CDN cache just knows that previously
-                // cached response is stale, so we are hitting our function that serve
-                // already cached response
-                'cache-status': [/"Next.js"; hit/m, /"Netlify Edge"; fwd=(miss|stale)/m],
+            const data2 = (await response2Json?.json()) || {}
+            expect(data2?.pageProps?.time).toBe(date1)
+
+            const revalidate = await page.goto(
+              new URL(
+                `/base/path${revalidateApiBasePath}?path=/de${pagePath}`,
+                pageRouterBasePathI18n.url,
+              ).href,
+            )
+            expect(revalidate?.status()).toBe(200)
+
+            // wait a bit until the page got regenerated
+            await page.waitForTimeout(1000)
+
+            // now after the revalidation it should have a different date
+            const response3 = await pollUntilHeadersMatch(
+              new URL(`base/path/de${pagePath}`, pageRouterBasePathI18n.url).href,
+              {
+                headersToMatch: {
+                  // revalidate refreshes Next cache, but not CDN cache
+                  // so our request after revalidation means that Next cache is already
+                  // warmed up with fresh response, but CDN cache just knows that previously
+                  // cached response is stale, so we are hitting our function that serve
+                  // already cached response
+                  'cache-status': [/"Next.js"; hit/m, /"Netlify Edge"; fwd=(miss|stale)/m],
+                },
+                headersNotMatchedMessage:
+                  'Third request to tested page (html) should be a miss or stale on the Edge and hit in Next.js after on-demand revalidation',
               },
-              headersNotMatchedMessage:
-                'Third request to tested page (data) should be a miss or stale on the Edge and hit in Next.js after on-demand revalidation',
-            },
-          )
-          const headers3Json = response3Json?.headers() || {}
-          expect(response3Json?.status()).toBe(200)
-          expect(headers3Json['x-nextjs-cache']).toBeUndefined()
-          if (!headers3Json['cache-status'].includes('"Netlify Edge"; hit')) {
-            // if we missed CDN cache, we will see Next cache hit status
-            // as we reuse cached response
-            expect(headers3Json['cache-status']).toMatch(/"Next.js"; hit/m)
-          }
-          expect(headers3Json['debug-netlify-cdn-cache-control']).toBe(
-            nextVersionSatisfies('>=15.0.0-canary.187')
-              ? 's-maxage=31536000, durable'
-              : 's-maxage=31536000, stale-while-revalidate=31536000, durable',
-          )
+            )
+            const headers3 = response3?.headers() || {}
+            expect(response3?.status()).toBe(200)
+            expect(headers3?.['x-nextjs-cache']).toBeUndefined()
+
+            // the page has now an updated date
+            const date3 = await page.textContent('[data-testid="date-now"]')
+            expect(date3).not.toBe(date2)
+
+            // check json route
+            const response3Json = await pollUntilHeadersMatch(
+              new URL(
+                `base/path/_next/data/build-id/de${pagePath}.json`,
+                pageRouterBasePathI18n.url,
+              ).href,
+              {
+                headersToMatch: {
+                  // revalidate refreshes Next cache, but not CDN cache
+                  // so our request after revalidation means that Next cache is already
+                  // warmed up with fresh response, but CDN cache just knows that previously
+                  // cached response is stale, so we are hitting our function that serve
+                  // already cached response
+                  'cache-status': [/"Next.js"; hit/m, /"Netlify Edge"; fwd=(miss|stale)/m],
+                },
+                headersNotMatchedMessage:
+                  'Third request to tested page (data) should be a miss or stale on the Edge and hit in Next.js after on-demand revalidation',
+              },
+            )
+            const headers3Json = response3Json?.headers() || {}
+            expect(response3Json?.status()).toBe(200)
+            expect(headers3Json['x-nextjs-cache']).toBeUndefined()
+            if (!headers3Json['cache-status'].includes('"Netlify Edge"; hit')) {
+              // if we missed CDN cache, we will see Next cache hit status
+              // as we reuse cached response
+              expect(headers3Json['cache-status']).toMatch(/"Next.js"; hit/m)
+            }
+            expect(headers3Json['debug-netlify-cdn-cache-control']).toBe(
+              nextVersionSatisfies('>=15.0.0-canary.187')
+                ? 's-maxage=31536000, durable'
+                : 's-maxage=31536000, stale-while-revalidate=31536000, durable',
+            )
 
-          const data3 = (await response3Json?.json()) || {}
-          expect(data3?.pageProps?.time).toBe(date3)
+            const data3 = (await response3Json?.json()) || {}
+            expect(data3?.pageProps?.time).toBe(date3)
+          })
         })
-      })
-    }
-  })
-
-  test('requesting a non existing page route that needs to be fetched from the blob store like 404.html', async ({
-    page,
-    pageRouterBasePathI18n,
-  }) => {
-    const response = await page.goto(
-      new URL('base/path/non-existing', pageRouterBasePathI18n.url).href,
-    )
-    const headers = response?.headers() || {}
-    expect(response?.status()).toBe(404)
-
-    expect(await page.textContent('p')).toBe('Custom 404 page for locale: en')
-
-    expect(headers['debug-netlify-cdn-cache-control']).toMatch(
-      /no-cache, no-store, max-age=0, must-revalidate, durable/m,
-    )
-    expect(headers['cache-control']).toMatch(/no-cache,no-store,max-age=0,must-revalidate/m)
-  })
-
-  test('requesting a non existing page route that needs to be fetched from the blob store like 404.html (notFound: true)', async ({
-    page,
-    pageRouterBasePathI18n,
-  }) => {
-    const response = await page.goto(
-      new URL('base/path/static/not-found', pageRouterBasePathI18n.url).href,
-    )
-    const headers = response?.headers() || {}
-    expect(response?.status()).toBe(404)
-
-    expect(await page.textContent('p')).toBe('Custom 404 page for locale: en')
-
-    // Prior to v14.2.4 notFound pages are not cacheable
-    // https://github.com/vercel/next.js/pull/66674
-    if (nextVersionSatisfies('>= 14.2.4')) {
-      expect(headers['debug-netlify-cdn-cache-control']).toBe(
-        nextVersionSatisfies('>=15.0.0-canary.187')
-          ? 's-maxage=31536000, durable'
-          : 's-maxage=31536000, stale-while-revalidate=31536000, durable',
+      }
+    })
+
+    test('requesting a non existing page route that needs to be fetched from the blob store like 404.html', async ({
+      page,
+      pageRouterBasePathI18n,
+    }) => {
+      const response = await page.goto(
+        new URL('base/path/non-existing', pageRouterBasePathI18n.url).href,
       )
-      expect(headers['cache-control']).toBe('public,max-age=0,must-revalidate')
-    }
-  })
-})
+      const headers = response?.headers() || {}
+      expect(response?.status()).toBe(404)
+
+      expect(await page.textContent('p')).toBe('Custom 404 page for locale: en')
+
+      expect(headers['debug-netlify-cdn-cache-control']).toMatch(
+        /no-cache, no-store, max-age=0, must-revalidate, durable/m,
+      )
+      expect(headers['cache-control']).toMatch(/no-cache,no-store,max-age=0,must-revalidate/m)
+    })
+
+    test('requesting a non existing page route that needs to be fetched from the blob store like 404.html (notFound: true)', async ({
+      page,
+      pageRouterBasePathI18n,
+    }) => {
+      const response = await page.goto(
+        new URL('base/path/static/not-found', pageRouterBasePathI18n.url).href,
+      )
+      const headers = response?.headers() || {}
+      expect(response?.status()).toBe(404)
+
+      expect(await page.textContent('p')).toBe('Custom 404 page for locale: en')
+
+      // Prior to v14.2.4 notFound pages are not cacheable
+      // https://github.com/vercel/next.js/pull/66674
+      if (nextVersionSatisfies('>= 14.2.4')) {
+        expect(headers['debug-netlify-cdn-cache-control']).toBe(
+          nextVersionSatisfies('>=15.0.0-canary.187')
+            ? 's-maxage=31536000, durable'
+            : 's-maxage=31536000, stale-while-revalidate=31536000, durable',
+        )
+        expect(headers['cache-control']).toBe('public,max-age=0,must-revalidate')
+      }
+    })
+  },
+)
diff --git a/tests/e2e/simple-app.test.ts b/tests/e2e/simple-app.test.ts
index 1d0ee82a17..3ca00a13d0 100644
--- a/tests/e2e/simple-app.test.ts
+++ b/tests/e2e/simple-app.test.ts
@@ -1,353 +1,372 @@
 import { expect, type Locator, type Response } from '@playwright/test'
 import { nextVersionSatisfies } from '../utils/next-version-helpers.mjs'
-import { test } from '../utils/playwright-helpers.js'
+import { generateTestTags, test } from '../utils/playwright-helpers.js'
 
 const expectImageWasLoaded = async (locator: Locator) => {
   expect(await locator.evaluate((img: HTMLImageElement) => img.naturalHeight)).toBeGreaterThan(0)
 }
 
-test('Renders the Home page correctly', async ({ page, simple }) => {
-  const response = await page.goto(simple.url)
-  const headers = response?.headers() || {}
+test.describe(
+  'Simple App',
+  {
+    tag: generateTestTags({ appRouter: true }),
+  },
+  () => {
+    test('Renders the Home page correctly', async ({ page, simple }) => {
+      const response = await page.goto(simple.url)
+      const headers = response?.headers() || {}
 
-  await expect(page).toHaveTitle('Simple Next App')
+      await expect(page).toHaveTitle('Simple Next App')
 
-  expect(headers['cache-status'].replaceAll(', ', '\n')).toMatch(/^"Next.js"; hit$/m)
-  expect(headers['cache-status'].replaceAll(', ', '\n')).toMatch(/^"Netlify Edge"; fwd=miss$/m)
-  // "Netlify Durable" assertion is skipped because we are asserting index page and there are possible that something else is making similar request to it
-  // and as a result we can see many possible statuses for it: `fwd=miss`, `fwd=miss; stored`, `hit; ttl=` so there is no point in asserting on that
-  // "Netlify Edge" status suffers from similar issue, but is less likely to manifest (only if those requests would be handled by same CDN node) and retries
-  // usually allow to pass the test
+      expect(headers['cache-status'].replaceAll(', ', '\n')).toMatch(/^"Next.js"; hit$/m)
+      expect(headers['cache-status'].replaceAll(', ', '\n')).toMatch(/^"Netlify Edge"; fwd=miss$/m)
+      // "Netlify Durable" assertion is skipped because we are asserting index page and there are possible that something else is making similar request to it
+      // and as a result we can see many possible statuses for it: `fwd=miss`, `fwd=miss; stored`, `hit; ttl=` so there is no point in asserting on that
+      // "Netlify Edge" status suffers from similar issue, but is less likely to manifest (only if those requests would be handled by same CDN node) and retries
+      // usually allow to pass the test
 
-  const h1 = page.locator('h1')
-  await expect(h1).toHaveText('Home')
+      const h1 = page.locator('h1')
+      await expect(h1).toHaveText('Home')
 
-  await expectImageWasLoaded(page.locator('img'))
+      await expectImageWasLoaded(page.locator('img'))
 
-  await page.goto(`${simple.url}/api/static`)
+      await page.goto(`${simple.url}/api/static`)
 
-  const body = (await page.$('body').then((el) => el?.textContent())) || '{}'
-  expect(body).toBe('{"words":"hello world"}')
-})
-
-test('Renders the Home page correctly with distDir', async ({ page, distDir }) => {
-  await page.goto(distDir.url)
+      const body = (await page.$('body').then((el) => el?.textContent())) || '{}'
+      expect(body).toBe('{"words":"hello world"}')
+    })
 
-  await expect(page).toHaveTitle('Simple Next App')
+    test(
+      'Renders the Home page correctly with distDir',
+      {
+        tag: generateTestTags({ customDistDir: true }),
+      },
+      async ({ page, distDir }) => {
+        await page.goto(distDir.url)
 
-  const h1 = page.locator('h1')
-  await expect(h1).toHaveText('Home')
+        await expect(page).toHaveTitle('Simple Next App')
 
-  await expectImageWasLoaded(page.locator('img'))
-})
+        const h1 = page.locator('h1')
+        await expect(h1).toHaveText('Home')
 
-test('Serves a static image correctly', async ({ page, simple }) => {
-  const response = await page.goto(`${simple.url}/next.svg`)
+        await expectImageWasLoaded(page.locator('img'))
+      },
+    )
 
-  expect(response?.status()).toBe(200)
-  expect(response?.headers()['content-type']).toBe('image/svg+xml')
-})
+    test('Serves a static image correctly', async ({ page, simple }) => {
+      const response = await page.goto(`${simple.url}/next.svg`)
 
-test('Redirects correctly', async ({ page, simple }) => {
-  await page.goto(`${simple.url}/redirect/response`)
-  await expect(page).toHaveURL(`https://www.netlify.com/`)
+      expect(response?.status()).toBe(200)
+      expect(response?.headers()['content-type']).toBe('image/svg+xml')
+    })
 
-  await page.goto(`${simple.url}/redirect`)
-  await expect(page).toHaveURL(`https://www.netlify.com/`)
-})
+    test('Redirects correctly', async ({ page, simple }) => {
+      await page.goto(`${simple.url}/redirect/response`)
+      await expect(page).toHaveURL(`https://www.netlify.com/`)
 
-const waitFor = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
+      await page.goto(`${simple.url}/redirect`)
+      await expect(page).toHaveURL(`https://www.netlify.com/`)
+    })
 
-// adaptation of https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-static/app-static.test.ts#L1716-L1755
-test.skip('streams stale responses', async ({ simple }) => {
-  // Introduced in https://github.com/vercel/next.js/pull/55978
-  test.skip(!nextVersionSatisfies('>=13.5.4'), 'This test is only for Next.js 13.5.4+')
-  // Prime the cache.
-  const path = `${simple.url}/stale-cache-serving/app-page`
-  const res = await fetch(path)
-  expect(res.status).toBe(200)
+    const waitFor = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
 
-  // Consume the cache, the revalidations are completed on the end of the
-  // stream so we need to wait for that to complete.
-  await res.text()
+    // adaptation of https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-static/app-static.test.ts#L1716-L1755
+    test.skip('streams stale responses', async ({ simple }) => {
+      // Introduced in https://github.com/vercel/next.js/pull/55978
+      test.skip(!nextVersionSatisfies('>=13.5.4'), 'This test is only for Next.js 13.5.4+')
+      // Prime the cache.
+      const path = `${simple.url}/stale-cache-serving/app-page`
+      const res = await fetch(path)
+      expect(res.status).toBe(200)
 
-  // different from next.js test:
-  // we need to wait another 10secs for the blob to propagate back
-  // can be removed once we have a local cache for blobs
-  await waitFor(10000)
+      // Consume the cache, the revalidations are completed on the end of the
+      // stream so we need to wait for that to complete.
+      await res.text()
 
-  for (let i = 0; i < 6; i++) {
-    await waitFor(1000)
+      // different from next.js test:
+      // we need to wait another 10secs for the blob to propagate back
+      // can be removed once we have a local cache for blobs
+      await waitFor(10000)
 
-    const timings = {
-      start: Date.now(),
-      startedStreaming: 0,
-    }
+      for (let i = 0; i < 6; i++) {
+        await waitFor(1000)
 
-    const res = await fetch(path)
+        const timings = {
+          start: Date.now(),
+          startedStreaming: 0,
+        }
 
-    await new Promise((resolve) => {
-      res.body?.pipeTo(
-        new WritableStream({
-          write() {
-            if (!timings.startedStreaming) {
-              timings.startedStreaming = Date.now()
-            }
-          },
-          close() {
-            resolve()
-          },
-        }),
-      )
+        const res = await fetch(path)
+
+        await new Promise((resolve) => {
+          res.body?.pipeTo(
+            new WritableStream({
+              write() {
+                if (!timings.startedStreaming) {
+                  timings.startedStreaming = Date.now()
+                }
+              },
+              close() {
+                resolve()
+              },
+            }),
+          )
+        })
+
+        expect(
+          timings.startedStreaming - timings.start,
+          `streams in less than 3s, run #${i}/6`,
+        ).toBeLessThan(3000)
+      }
     })
 
-    expect(
-      timings.startedStreaming - timings.start,
-      `streams in less than 3s, run #${i}/6`,
-    ).toBeLessThan(3000)
-  }
-})
-
-test.describe('next/image is using Netlify Image CDN', () => {
-  test('Local images', async ({ page, simple }) => {
-    const nextImageResponsePromise = page.waitForResponse('**/.netlify/images**')
+    test.describe('next/image is using Netlify Image CDN', () => {
+      test('Local images', async ({ page, simple }) => {
+        const nextImageResponsePromise = page.waitForResponse('**/.netlify/images**')
 
-    await page.goto(`${simple.url}/image/local`)
+        await page.goto(`${simple.url}/image/local`)
 
-    const nextImageResponse = await nextImageResponsePromise
-    expect(nextImageResponse.request().url()).toContain('.netlify/images?url=%2Fsquirrel.jpg')
+        const nextImageResponse = await nextImageResponsePromise
+        expect(nextImageResponse.request().url()).toContain('.netlify/images?url=%2Fsquirrel.jpg')
 
-    expect(nextImageResponse.status()).toBe(200)
-    // ensure next/image is using Image CDN
-    // source image is jpg, but when requesting it through Image CDN avif or webp will be returned
-    expect(['image/avif', 'image/webp']).toContain(
-      await nextImageResponse.headerValue('content-type'),
-    )
+        expect(nextImageResponse.status()).toBe(200)
+        // ensure next/image is using Image CDN
+        // source image is jpg, but when requesting it through Image CDN avif or webp will be returned
+        expect(['image/avif', 'image/webp']).toContain(
+          await nextImageResponse.headerValue('content-type'),
+        )
 
-    await expectImageWasLoaded(page.locator('img'))
-  })
+        await expectImageWasLoaded(page.locator('img'))
+      })
 
-  test('Remote images: remote patterns #1 (protocol, hostname, pathname set)', async ({
-    page,
-    simple,
-  }) => {
-    const nextImageResponsePromise = page.waitForResponse('**/.netlify/images**')
+      test('Remote images: remote patterns #1 (protocol, hostname, pathname set)', async ({
+        page,
+        simple,
+      }) => {
+        const nextImageResponsePromise = page.waitForResponse('**/.netlify/images**')
 
-    await page.goto(`${simple.url}/image/remote-pattern-1`)
+        await page.goto(`${simple.url}/image/remote-pattern-1`)
 
-    const nextImageResponse = await nextImageResponsePromise
+        const nextImageResponse = await nextImageResponsePromise
 
-    expect(nextImageResponse.url()).toContain(
-      `.netlify/images?url=${encodeURIComponent(
-        'https://images.unsplash.com/photo-1574870111867-089730e5a72b',
-      )}`,
-    )
+        expect(nextImageResponse.url()).toContain(
+          `.netlify/images?url=${encodeURIComponent(
+            'https://images.unsplash.com/photo-1574870111867-089730e5a72b',
+          )}`,
+        )
 
-    expect(nextImageResponse.status()).toBe(200)
-    expect(['image/avif', 'image/webp']).toContain(
-      await nextImageResponse.headerValue('content-type'),
-    )
+        expect(nextImageResponse.status()).toBe(200)
+        expect(['image/avif', 'image/webp']).toContain(
+          await nextImageResponse.headerValue('content-type'),
+        )
 
-    await expectImageWasLoaded(page.locator('img'))
-  })
+        await expectImageWasLoaded(page.locator('img'))
+      })
 
-  test('Remote images: remote patterns #2 (just hostname starting with wildcard)', async ({
-    page,
-    simple,
-  }) => {
-    const nextImageResponsePromise = page.waitForResponse('**/.netlify/images**')
+      test('Remote images: remote patterns #2 (just hostname starting with wildcard)', async ({
+        page,
+        simple,
+      }) => {
+        const nextImageResponsePromise = page.waitForResponse('**/.netlify/images**')
 
-    await page.goto(`${simple.url}/image/remote-pattern-2`)
+        await page.goto(`${simple.url}/image/remote-pattern-2`)
 
-    const nextImageResponse = await nextImageResponsePromise
+        const nextImageResponse = await nextImageResponsePromise
 
-    expect(nextImageResponse.url()).toContain(
-      `.netlify/images?url=${encodeURIComponent(
-        'https://cdn.pixabay.com/photo/2017/02/20/18/03/cat-2083492_1280.jpg',
-      )}`,
-    )
+        expect(nextImageResponse.url()).toContain(
+          `.netlify/images?url=${encodeURIComponent(
+            'https://cdn.pixabay.com/photo/2017/02/20/18/03/cat-2083492_1280.jpg',
+          )}`,
+        )
 
-    expect(nextImageResponse.status()).toBe(200)
-    expect(['image/avif', 'image/webp']).toContain(
-      await nextImageResponse.headerValue('content-type'),
-    )
+        expect(nextImageResponse.status()).toBe(200)
+        expect(['image/avif', 'image/webp']).toContain(
+          await nextImageResponse.headerValue('content-type'),
+        )
 
-    await expectImageWasLoaded(page.locator('img'))
-  })
+        await expectImageWasLoaded(page.locator('img'))
+      })
 
-  test('Remote images: domains', async ({ page, simple }) => {
-    const nextImageResponsePromise = page.waitForResponse('**/.netlify/images**')
+      test('Remote images: domains', async ({ page, simple }) => {
+        const nextImageResponsePromise = page.waitForResponse('**/.netlify/images**')
 
-    await page.goto(`${simple.url}/image/remote-domain`)
+        await page.goto(`${simple.url}/image/remote-domain`)
 
-    const nextImageResponse = await nextImageResponsePromise
+        const nextImageResponse = await nextImageResponsePromise
 
-    expect(nextImageResponse.url()).toContain(
-      `.netlify/images?url=${encodeURIComponent(
-        'https://images.pexels.com/photos/406014/pexels-photo-406014.jpeg',
-      )}`,
-    )
+        expect(nextImageResponse.url()).toContain(
+          `.netlify/images?url=${encodeURIComponent(
+            'https://images.pexels.com/photos/406014/pexels-photo-406014.jpeg',
+          )}`,
+        )
 
-    expect(nextImageResponse?.status()).toBe(200)
-    expect(['image/avif', 'image/webp']).toContain(
-      await nextImageResponse.headerValue('content-type'),
-    )
+        expect(nextImageResponse?.status()).toBe(200)
+        expect(['image/avif', 'image/webp']).toContain(
+          await nextImageResponse.headerValue('content-type'),
+        )
 
-    await expectImageWasLoaded(page.locator('img'))
-  })
+        await expectImageWasLoaded(page.locator('img'))
+      })
 
-  test('Handling of browser-cached Runtime v4 redirect', async ({ page, simple }) => {
-    // Runtime v4 redirects for next/image are 301 and would be cached by browser
-    // So this test checks behavior when migrating from v4 to v5 for site visitors
-    // and ensure that images are still served through Image CDN
-    const nextImageResponsePromise = page.waitForResponse('**/_ipx/**')
+      test('Handling of browser-cached Runtime v4 redirect', async ({ page, simple }) => {
+        // Runtime v4 redirects for next/image are 301 and would be cached by browser
+        // So this test checks behavior when migrating from v4 to v5 for site visitors
+        // and ensure that images are still served through Image CDN
+        const nextImageResponsePromise = page.waitForResponse('**/_ipx/**')
 
-    await page.goto(`${simple.url}/image/migration-from-v4-runtime`)
+        await page.goto(`${simple.url}/image/migration-from-v4-runtime`)
 
-    const nextImageResponse = await nextImageResponsePromise
-    // ensure fixture is replicating runtime v4 redirect
-    expect(nextImageResponse.request().url()).toContain(
-      '_ipx/w_384,q_75/%2Fsquirrel.jpg?url=%2Fsquirrel.jpg&w=384&q=75',
-    )
+        const nextImageResponse = await nextImageResponsePromise
+        // ensure fixture is replicating runtime v4 redirect
+        expect(nextImageResponse.request().url()).toContain(
+          '_ipx/w_384,q_75/%2Fsquirrel.jpg?url=%2Fsquirrel.jpg&w=384&q=75',
+        )
 
-    expect(nextImageResponse.status()).toEqual(200)
-    expect(['image/avif', 'image/webp']).toContain(
-      await nextImageResponse.headerValue('content-type'),
-    )
+        expect(nextImageResponse.status()).toEqual(200)
+        expect(['image/avif', 'image/webp']).toContain(
+          await nextImageResponse.headerValue('content-type'),
+        )
 
-    await expectImageWasLoaded(page.locator('img'))
-  })
-})
-
-test('requesting a non existing page route that needs to be fetched from the blob store like 404.html', async ({
-  page,
-  simple,
-}) => {
-  const response = await page.goto(new URL('non-existing', simple.url).href)
-  const headers = response?.headers() || {}
-  expect(response?.status()).toBe(404)
-
-  expect(await page.textContent('h1')).toBe('404 Not Found')
-
-  // https://github.com/vercel/next.js/pull/66674 made changes to returned cache-control header,
-  // before that 404 page would have `private` directive, after that (14.2.4 and canary.24) it
-  // would not ... and then https://github.com/vercel/next.js/pull/69802 changed it back again
-  // (14.2.10 and canary.147)
-  const shouldHavePrivateDirective = nextVersionSatisfies(
-    '<14.2.4 || >=14.2.10 <15.0.0-canary.24 || ^15.0.0-canary.147',
-  )
-
-  expect(headers['debug-netlify-cdn-cache-control']).toBe(
-    (shouldHavePrivateDirective ? 'private, ' : '') +
-      'no-cache, no-store, max-age=0, must-revalidate, durable',
-  )
-  expect(headers['cache-control']).toBe(
-    (shouldHavePrivateDirective ? 'private,' : '') + 'no-cache,no-store,max-age=0,must-revalidate',
-  )
-})
-
-test('requesting a non existing page route that needs to be fetched from the blob store like 404.html (notFound())', async ({
-  page,
-  simple,
-}) => {
-  const response = await page.goto(new URL('route-resolves-to-not-found', simple.url).href)
-  const headers = response?.headers() || {}
-  expect(response?.status()).toBe(404)
-
-  expect(await page.textContent('h1')).toBe('404 Not Found')
-
-  expect(headers['debug-netlify-cdn-cache-control']).toBe(
-    nextVersionSatisfies('>=15.0.0-canary.187')
-      ? 's-maxage=31536000, durable'
-      : 's-maxage=31536000, stale-while-revalidate=31536000, durable',
-  )
-  expect(headers['cache-control']).toBe('public,max-age=0,must-revalidate')
-})
-
-test('Compressed rewrites are readable', async ({ simple }) => {
-  const resp = await fetch(`${simple.url}/rewrite-no-basepath`)
-  expect(resp.headers.get('content-length')).toBeNull()
-  expect(resp.headers.get('transfer-encoding')).toEqual('chunked')
-  expect(resp.headers.get('content-encoding')).toEqual('br')
-  expect(await resp.text()).toContain('Example Domain')
-})
-
-test('can require CJS module that is not bundled', async ({ simple }) => {
-  const resp = await fetch(`${simple.url}/api/cjs-file-with-js-extension`)
-
-  expect(resp.status).toBe(200)
-
-  const parsedBody = await resp.json()
-
-  expect(parsedBody.notBundledCJSModule.isBundled).toEqual(false)
-  expect(parsedBody.bundledCJSModule.isBundled).toEqual(true)
-})
-
-test.describe('RSC cache poisoning', () => {
-  test('Next.config.js rewrite', async ({ page, simple }) => {
-    const prefetchResponsePromise = new Promise((resolve) => {
-      page.on('response', (response) => {
-        if (response.url().includes('/config-rewrite/source')) {
-          resolve(response)
-        }
+        await expectImageWasLoaded(page.locator('img'))
       })
     })
-    await page.goto(`${simple.url}/config-rewrite`)
 
-    // ensure prefetch
-    await page.hover('text=NextConfig.rewrite')
+    test('requesting a non existing page route that needs to be fetched from the blob store like 404.html', async ({
+      page,
+      simple,
+    }) => {
+      const response = await page.goto(new URL('non-existing', simple.url).href)
+      const headers = response?.headers() || {}
+      expect(response?.status()).toBe(404)
+
+      expect(await page.textContent('h1')).toBe('404 Not Found')
+
+      // https://github.com/vercel/next.js/pull/66674 made changes to returned cache-control header,
+      // before that 404 page would have `private` directive, after that (14.2.4 and canary.24) it
+      // would not ... and then https://github.com/vercel/next.js/pull/69802 changed it back again
+      // (14.2.10 and canary.147)
+      const shouldHavePrivateDirective = nextVersionSatisfies(
+        '<14.2.4 || >=14.2.10 <15.0.0-canary.24 || ^15.0.0-canary.147',
+      )
 
-    // wait for prefetch request to finish
-    const prefetchResponse = await prefetchResponsePromise
+      expect(headers['debug-netlify-cdn-cache-control']).toBe(
+        (shouldHavePrivateDirective ? 'private, ' : '') +
+          'no-cache, no-store, max-age=0, must-revalidate, durable',
+      )
+      expect(headers['cache-control']).toBe(
+        (shouldHavePrivateDirective ? 'private,' : '') +
+          'no-cache,no-store,max-age=0,must-revalidate',
+      )
+    })
 
-    // ensure prefetch respond with RSC data
-    expect(prefetchResponse.headers()['content-type']).toMatch(/text\/x-component/)
-    expect(prefetchResponse.headers()['debug-netlify-cdn-cache-control']).toMatch(
-      /s-maxage=31536000/,
-    )
+    test('requesting a non existing page route that needs to be fetched from the blob store like 404.html (notFound())', async ({
+      page,
+      simple,
+    }) => {
+      const response = await page.goto(new URL('route-resolves-to-not-found', simple.url).href)
+      const headers = response?.headers() || {}
+      expect(response?.status()).toBe(404)
 
-    const htmlResponse = await page.goto(`${simple.url}/config-rewrite/source`)
+      expect(await page.textContent('h1')).toBe('404 Not Found')
 
-    // ensure we get HTML response
-    expect(htmlResponse?.headers()['content-type']).toMatch(/text\/html/)
-    expect(htmlResponse?.headers()['debug-netlify-cdn-cache-control']).toMatch(/s-maxage=31536000/)
-  })
+      expect(headers['debug-netlify-cdn-cache-control']).toBe(
+        nextVersionSatisfies('>=15.0.0-canary.187')
+          ? 's-maxage=31536000, durable'
+          : 's-maxage=31536000, stale-while-revalidate=31536000, durable',
+      )
+      expect(headers['cache-control']).toBe('public,max-age=0,must-revalidate')
+    })
 
-  test('Next.config.js redirect', async ({ page, simple }) => {
-    const prefetchResponsePromise = new Promise((resolve) => {
-      page.on('response', (response) => {
-        if (response.url().includes('/config-redirect/dest')) {
-          resolve(response)
-        }
-      })
+    test('Compressed rewrites are readable', async ({ simple }) => {
+      const resp = await fetch(`${simple.url}/rewrite-no-basepath`)
+      expect(resp.headers.get('content-length')).toBeNull()
+      expect(resp.headers.get('transfer-encoding')).toEqual('chunked')
+      expect(resp.headers.get('content-encoding')).toEqual('br')
+      expect(await resp.text()).toContain('Example Domain')
     })
-    await page.goto(`${simple.url}/config-redirect`)
 
-    // ensure prefetch
-    await page.hover('text=NextConfig.redirect')
+    test('can require CJS module that is not bundled', async ({ simple }) => {
+      const resp = await fetch(`${simple.url}/api/cjs-file-with-js-extension`)
 
-    // wait for prefetch request to finish
-    const prefetchResponse = await prefetchResponsePromise
+      expect(resp.status).toBe(200)
 
-    // ensure prefetch respond with RSC data
-    expect(prefetchResponse.headers()['content-type']).toMatch(/text\/x-component/)
-    expect(prefetchResponse.headers()['debug-netlify-cdn-cache-control']).toMatch(
-      /s-maxage=31536000/,
-    )
+      const parsedBody = await resp.json()
 
-    const htmlResponse = await page.goto(`${simple.url}/config-rewrite/source`)
+      expect(parsedBody.notBundledCJSModule.isBundled).toEqual(false)
+      expect(parsedBody.bundledCJSModule.isBundled).toEqual(true)
+    })
+
+    test.describe('RSC cache poisoning', () => {
+      test('Next.config.js rewrite', async ({ page, simple }) => {
+        const prefetchResponsePromise = new Promise((resolve) => {
+          page.on('response', (response) => {
+            if (response.url().includes('/config-rewrite/source')) {
+              resolve(response)
+            }
+          })
+        })
+        await page.goto(`${simple.url}/config-rewrite`)
 
-    // ensure we get HTML response
-    expect(htmlResponse?.headers()['content-type']).toMatch(/text\/html/)
-    expect(htmlResponse?.headers()['debug-netlify-cdn-cache-control']).toMatch(/s-maxage=31536000/)
-  })
-})
+        // ensure prefetch
+        await page.hover('text=NextConfig.rewrite')
 
-test('Handles route with a path segment starting with dot correctly', async ({ simple }) => {
-  const response = await fetch(`${simple.url}/.well-known/farcaster`)
+        // wait for prefetch request to finish
+        const prefetchResponse = await prefetchResponsePromise
 
-  expect(response.status).toBe(200)
+        // ensure prefetch respond with RSC data
+        expect(prefetchResponse.headers()['content-type']).toMatch(/text\/x-component/)
+        expect(prefetchResponse.headers()['debug-netlify-cdn-cache-control']).toMatch(
+          /s-maxage=31536000/,
+        )
+
+        const htmlResponse = await page.goto(`${simple.url}/config-rewrite/source`)
+
+        // ensure we get HTML response
+        expect(htmlResponse?.headers()['content-type']).toMatch(/text\/html/)
+        expect(htmlResponse?.headers()['debug-netlify-cdn-cache-control']).toMatch(
+          /s-maxage=31536000/,
+        )
+      })
 
-  const data = await response.json()
-  expect(data).toEqual({ msg: 'Hi!' })
-})
+      test('Next.config.js redirect', async ({ page, simple }) => {
+        const prefetchResponsePromise = new Promise((resolve) => {
+          page.on('response', (response) => {
+            if (response.url().includes('/config-redirect/dest')) {
+              resolve(response)
+            }
+          })
+        })
+        await page.goto(`${simple.url}/config-redirect`)
+
+        // ensure prefetch
+        await page.hover('text=NextConfig.redirect')
+
+        // wait for prefetch request to finish
+        const prefetchResponse = await prefetchResponsePromise
+
+        // ensure prefetch respond with RSC data
+        expect(prefetchResponse.headers()['content-type']).toMatch(/text\/x-component/)
+        expect(prefetchResponse.headers()['debug-netlify-cdn-cache-control']).toMatch(
+          /s-maxage=31536000/,
+        )
+
+        const htmlResponse = await page.goto(`${simple.url}/config-rewrite/source`)
+
+        // ensure we get HTML response
+        expect(htmlResponse?.headers()['content-type']).toMatch(/text\/html/)
+        expect(htmlResponse?.headers()['debug-netlify-cdn-cache-control']).toMatch(
+          /s-maxage=31536000/,
+        )
+      })
+    })
+
+    test('Handles route with a path segment starting with dot correctly', async ({ simple }) => {
+      const response = await fetch(`${simple.url}/.well-known/farcaster`)
+
+      expect(response.status).toBe(200)
+
+      const data = await response.json()
+      expect(data).toEqual({ msg: 'Hi!' })
+    })
+  },
+)
diff --git a/tests/utils/create-e2e-fixture.ts b/tests/utils/create-e2e-fixture.ts
index fe2546da2e..62bfe68d9d 100644
--- a/tests/utils/create-e2e-fixture.ts
+++ b/tests/utils/create-e2e-fixture.ts
@@ -323,7 +323,7 @@ export const fixtureFactories = {
       buildCommand: 'next build --turbopack',
     }),
   outputExport: () => createE2EFixture('output-export'),
-  ouputExportPublishOut: () =>
+  outputExportPublishOut: () =>
     createE2EFixture('output-export', {
       publishDirectory: 'out',
     }),
diff --git a/tests/utils/playwright-helpers.ts b/tests/utils/playwright-helpers.ts
index 8a6bd1f912..732c30e6e3 100644
--- a/tests/utils/playwright-helpers.ts
+++ b/tests/utils/playwright-helpers.ts
@@ -108,3 +108,48 @@ export const test = base.extend<
     { auto: true },
   ],
 })
+
+/**
+ * Generate tags based on the provided options. This is useful to notice patterns when group of tests fail
+ * @param options The options to generate tags from.
+ * @returns An array of generated tags.
+ */
+export const generateTestTags = (options: {
+  pagesRouter?: boolean
+  appRouter?: boolean
+  i18n?: boolean
+  basePath?: boolean
+  middleware?: false | 'edge' | 'node'
+  customDistDir?: boolean
+  export?: boolean
+  monorepo?: boolean
+}) => {
+  const tags: string[] = []
+
+  if (options.pagesRouter) {
+    tags.push('@pages-router')
+  }
+  if (options.appRouter) {
+    tags.push('@app-router')
+  }
+  if (options.i18n) {
+    tags.push('@i18n')
+  }
+  if (options.basePath) {
+    tags.push('@base-path')
+  }
+  if (options.middleware) {
+    tags.push(`@middleware-${options.middleware}`)
+  }
+  if (options.customDistDir) {
+    tags.push('@custom-dist-dir')
+  }
+  if (options.export) {
+    tags.push('@export')
+  }
+  if (options.monorepo) {
+    tags.push('@monorepo')
+  }
+
+  return tags
+}

From bffff192248b9300eda3d0d33b5be38dc2999d76 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Wed, 24 Sep 2025 13:15:10 +0200
Subject: [PATCH 23/39] add missing name and generator to middleware EF

---
 src/adapter/constants.ts  | 10 ++++++++++
 src/adapter/middleware.ts |  9 ++++-----
 2 files changed, 14 insertions(+), 5 deletions(-)
 create mode 100644 src/adapter/constants.ts

diff --git a/src/adapter/constants.ts b/src/adapter/constants.ts
new file mode 100644
index 0000000000..cf25b1e856
--- /dev/null
+++ b/src/adapter/constants.ts
@@ -0,0 +1,10 @@
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const MODULE_DIR = fileURLToPath(new URL('.', import.meta.url))
+export const PLUGIN_DIR = join(MODULE_DIR, '../..')
+
+const packageJSON = JSON.parse(readFileSync(join(PLUGIN_DIR, 'package.json'), 'utf-8'))
+
+export const GENERATOR = `${packageJSON.name}@${packageJSON.version}`
diff --git a/src/adapter/middleware.ts b/src/adapter/middleware.ts
index 7c035cb567..cfed7496a2 100644
--- a/src/adapter/middleware.ts
+++ b/src/adapter/middleware.ts
@@ -1,10 +1,10 @@
 import { cp, mkdir, readFile, writeFile } from 'node:fs/promises'
 import { dirname, join, parse } from 'node:path'
-import { fileURLToPath } from 'node:url'
 
 import { glob } from 'fast-glob'
 import { pathToRegexp } from 'path-to-regexp'
 
+import { GENERATOR, PLUGIN_DIR } from './constants.js'
 import type { FrameworksAPIConfig, NextConfigComplete, OnBuildCompleteContext } from './types.js'
 
 const NETLIFY_FRAMEWORKS_API_EDGE_FUNCTIONS = '.netlify/v1/edge-functions'
@@ -15,9 +15,6 @@ const MIDDLEWARE_FUNCTION_DIR = join(
   MIDDLEWARE_FUNCTION_NAME,
 )
 
-const MODULE_DIR = fileURLToPath(new URL('.', import.meta.url))
-const PLUGIN_DIR = join(MODULE_DIR, '../..')
-
 export async function onBuildComplete(
   ctx: OnBuildCompleteContext,
   frameworksAPIConfigArg: FrameworksAPIConfig,
@@ -138,8 +135,10 @@ const writeHandlerFile = async (
     export default (req, context) => handleMiddleware(req, context, handler);
 
     export const config = ${JSON.stringify({
-      pattern: augmentMatchers(middleware, nextConfig).map((matcher) => matcher.regexp),
       cache: undefined,
+      generator: GENERATOR,
+      name: 'Next.js Middleware Handler',
+      pattern: augmentMatchers(middleware, nextConfig).map((matcher) => matcher.regexp),
     })}
     `,
   )

From 3bf80aebe51805e340ac3f5e4c3e063d22551c7b Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Wed, 24 Sep 2025 16:37:20 +0200
Subject: [PATCH 24/39] handle node middleware

---
 adapters-notes.md         |  1 +
 edge-runtime/shim/node.js |  2 +-
 src/adapter/middleware.ts | 89 ++++++++++++++++++++++++++++++++-------
 3 files changed, 76 insertions(+), 16 deletions(-)

diff --git a/adapters-notes.md b/adapters-notes.md
index 3d959d889b..6fddd43831 100644
--- a/adapters-notes.md
+++ b/adapters-notes.md
@@ -51,6 +51,7 @@
     handler and convert those files into blobs to upload later
 - [partially done - for edge runtime] use middleware output to generate middleware edge function
 - [done] don't glob for static files and use `outputs.staticFiles` instead
+- check `output: 'export'` case
 - note any remaining manual manifest files reading in build plugin once everything that could be
   adjusted was handled
 
diff --git a/edge-runtime/shim/node.js b/edge-runtime/shim/node.js
index 9f3e94fe7e..9ed62f6bcd 100644
--- a/edge-runtime/shim/node.js
+++ b/edge-runtime/shim/node.js
@@ -6,7 +6,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'
 import { createRequire } from 'node:module' // used in dynamically generated part
 import process from 'node:process'
 
-import { registerCJSModules } from '../edge-runtime/lib/cjs.ts' // used in dynamically generated part
+import { registerCJSModules } from './edge-runtime/lib/cjs.ts' // used in dynamically generated part
 
 globalThis.process = process
 
diff --git a/src/adapter/middleware.ts b/src/adapter/middleware.ts
index cfed7496a2..0f332985d0 100644
--- a/src/adapter/middleware.ts
+++ b/src/adapter/middleware.ts
@@ -1,5 +1,5 @@
-import { cp, mkdir, readFile, writeFile } from 'node:fs/promises'
-import { dirname, join, parse } from 'node:path'
+import { cp, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'
+import { dirname, join, parse, relative } from 'node:path/posix'
 
 import { glob } from 'fast-glob'
 import { pathToRegexp } from 'path-to-regexp'
@@ -26,12 +26,13 @@ export async function onBuildComplete(
     return frameworksAPIConfig
   }
 
-  if (middleware.runtime !== 'edge') {
-    // TODO: nodejs middleware
-    return frameworksAPIConfig
+  if (middleware.runtime === 'edge') {
+    await copyHandlerDependenciesForEdgeMiddleware(middleware)
+  } else if (middleware.runtime === 'nodejs') {
+    // return frameworksAPIConfig
+    await copyHandlerDependenciesForNodeMiddleware(middleware, ctx.repoRoot)
   }
 
-  await copyHandlerDependenciesForEdgeMiddleware(middleware)
   await writeHandlerFile(middleware, ctx.config)
 
   return frameworksAPIConfig
@@ -40,8 +41,6 @@ export async function onBuildComplete(
 const copyHandlerDependenciesForEdgeMiddleware = async (
   middleware: Required['middleware'],
 ) => {
-  // const srcDir = join(ctx.standaloneDir, ctx.nextDistDir)
-
   const edgeRuntimeDir = join(PLUGIN_DIR, 'edge-runtime')
   const shimPath = join(edgeRuntimeDir, 'shim/edge.js')
   const shim = await readFile(shimPath, 'utf8')
@@ -58,15 +57,15 @@ const copyHandlerDependenciesForEdgeMiddleware = async (
   //   }
   // }
 
-  for (const [relative, absolute] of Object.entries(middleware.assets)) {
-    if (absolute.endsWith('.wasm')) {
-      const data = await readFile(absolute)
+  for (const [relativePath, absolutePath] of Object.entries(middleware.assets)) {
+    if (absolutePath.endsWith('.wasm')) {
+      const data = await readFile(absolutePath)
 
-      const { name } = parse(relative)
+      const { name } = parse(relativePath)
       parts.push(`const ${name} = Uint8Array.from(${JSON.stringify([...data])})`)
-    } else if (absolute.endsWith('.js')) {
-      const entrypoint = await readFile(absolute, 'utf8')
-      parts.push(`;// Concatenated file: ${relative} \n`, entrypoint)
+    } else if (absolutePath.endsWith('.js')) {
+      const entrypoint = await readFile(absolutePath, 'utf8')
+      parts.push(`;// Concatenated file: ${relativePath} \n`, entrypoint)
     }
   }
   parts.push(
@@ -80,6 +79,66 @@ const copyHandlerDependenciesForEdgeMiddleware = async (
   await writeFile(outputFile, parts.join('\n'))
 }
 
+const copyHandlerDependenciesForNodeMiddleware = async (
+  middleware: Required['middleware'],
+  repoRoot: string,
+) => {
+  const edgeRuntimeDir = join(PLUGIN_DIR, 'edge-runtime')
+  const shimPath = join(edgeRuntimeDir, 'shim/node.js')
+  const shim = await readFile(shimPath, 'utf8')
+
+  const parts = [shim]
+
+  const files: string[] = Object.values(middleware.assets)
+  if (!files.includes(middleware.filePath)) {
+    files.push(middleware.filePath)
+  }
+
+  // C++ addons are not supported
+  const unsupportedDotNodeModules = files.filter((file) => file.endsWith('.node'))
+  if (unsupportedDotNodeModules.length !== 0) {
+    throw new Error(
+      `Usage of unsupported C++ Addon(s) found in Node.js Middleware:\n${unsupportedDotNodeModules.map((file) => `- ${file}`).join('\n')}\n\nCheck https://docs.netlify.com/build/frameworks/framework-setup-guides/nextjs/overview/#limitations for more information.`,
+    )
+  }
+
+  parts.push(`const virtualModules = new Map();`)
+
+  const handleFileOrDirectory = async (fileOrDir: string) => {
+    const stats = await stat(fileOrDir)
+    if (stats.isDirectory()) {
+      const filesInDir = await readdir(fileOrDir)
+      for (const fileInDir of filesInDir) {
+        await handleFileOrDirectory(join(fileOrDir, fileInDir))
+      }
+    } else {
+      const content = await readFile(fileOrDir, 'utf8')
+
+      parts.push(
+        `virtualModules.set(${JSON.stringify(relative(repoRoot, fileOrDir))}, ${JSON.stringify(content)});`,
+      )
+    }
+  }
+
+  for (const file of files) {
+    await handleFileOrDirectory(file)
+  }
+  parts.push(`registerCJSModules(import.meta.url, virtualModules);
+
+    const require = createRequire(import.meta.url);
+    const handlerMod = require("./${relative(repoRoot, middleware.filePath)}");
+    const handler = handlerMod.default || handlerMod;
+
+    export default handler
+    `)
+
+  const outputFile = join(MIDDLEWARE_FUNCTION_DIR, `concatenated-file.js`)
+
+  await mkdir(dirname(outputFile), { recursive: true })
+
+  await writeFile(outputFile, parts.join('\n'))
+}
+
 const writeHandlerFile = async (
   middleware: Required['middleware'],
   nextConfig: NextConfigComplete,

From 1a7e93d3d7bcf84016741648e2cc8e36d2e4f893 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Wed, 24 Sep 2025 16:39:07 +0200
Subject: [PATCH 25/39] remove no longer used build plugin middleware handling

---
 src/build/functions/edge.ts | 370 +-----------------------------------
 1 file changed, 2 insertions(+), 368 deletions(-)

diff --git a/src/build/functions/edge.ts b/src/build/functions/edge.ts
index 354887d92e..81ada58235 100644
--- a/src/build/functions/edge.ts
+++ b/src/build/functions/edge.ts
@@ -1,373 +1,7 @@
-import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
-import { dirname, join, relative } from 'node:path/posix'
+import { rm } from 'node:fs/promises'
 
-import type { Manifest, ManifestFunction } from '@netlify/edge-functions'
-import { glob } from 'fast-glob'
-import type { FunctionsConfigManifest } from 'next-with-cache-handler-v2/dist/build/index.js'
-import type { EdgeFunctionDefinition as EdgeMiddlewareDefinition } from 'next-with-cache-handler-v2/dist/build/webpack/plugins/middleware-plugin.js'
-import { pathToRegexp } from 'path-to-regexp'
-
-import { EDGE_HANDLER_NAME, PluginContext } from '../plugin-context.js'
-
-type NodeMiddlewareDefinitionWithOptionalMatchers = FunctionsConfigManifest['functions'][0]
-type WithRequired = T & { [P in K]-?: T[P] }
-type NodeMiddlewareDefinition = WithRequired<
-  NodeMiddlewareDefinitionWithOptionalMatchers,
-  'matchers'
->
-
-function nodeMiddlewareDefinitionHasMatcher(
-  definition: NodeMiddlewareDefinitionWithOptionalMatchers,
-): definition is NodeMiddlewareDefinition {
-  return Array.isArray(definition.matchers)
-}
-
-type EdgeOrNodeMiddlewareDefinition = {
-  runtime: 'nodejs' | 'edge'
-  // hoisting shared properties from underlying definitions for common handling
-  name: string
-  matchers: EdgeMiddlewareDefinition['matchers']
-} & (
-  | {
-      runtime: 'nodejs'
-      functionDefinition: NodeMiddlewareDefinition
-    }
-  | {
-      runtime: 'edge'
-      functionDefinition: EdgeMiddlewareDefinition
-    }
-)
-
-const writeEdgeManifest = async (ctx: PluginContext, manifest: Manifest) => {
-  await mkdir(ctx.edgeFunctionsDir, { recursive: true })
-  await writeFile(join(ctx.edgeFunctionsDir, 'manifest.json'), JSON.stringify(manifest, null, 2))
-}
-
-const copyRuntime = async (ctx: PluginContext, handlerDirectory: string): Promise => {
-  const files = await glob('edge-runtime/**/*', {
-    cwd: ctx.pluginDir,
-    ignore: ['**/*.test.ts'],
-    dot: true,
-  })
-  await Promise.all(
-    files.map((path) =>
-      cp(join(ctx.pluginDir, path), join(handlerDirectory, path), { recursive: true }),
-    ),
-  )
-}
-
-/**
- * When i18n is enabled the matchers assume that paths _always_ include the
- * locale. We manually add an extra matcher for the original path without
- * the locale to ensure that the edge function can handle it.
- * We don't need to do this for data routes because they always have the locale.
- */
-const augmentMatchers = (
-  matchers: EdgeMiddlewareDefinition['matchers'],
-  ctx: PluginContext,
-): EdgeMiddlewareDefinition['matchers'] => {
-  const i18NConfig = ctx.buildConfig.i18n
-  if (!i18NConfig) {
-    return matchers
-  }
-  return matchers.flatMap((matcher) => {
-    if (matcher.originalSource && matcher.locale !== false) {
-      return [
-        matcher.regexp
-          ? {
-              ...matcher,
-              // https://github.com/vercel/next.js/blob/5e236c9909a768dc93856fdfad53d4f4adc2db99/packages/next/src/build/analysis/get-page-static-info.ts#L332-L336
-              // Next is producing pretty broad matcher for i18n locale. Presumably rest of their infrastructure protects this broad matcher
-              // from matching on non-locale paths. For us this becomes request entry point, so we need to narrow it down to just defined locales
-              // otherwise users might get unexpected matches on paths like `/api*`
-              regexp: matcher.regexp.replace(/\[\^\/\.]+/g, `(${i18NConfig.locales.join('|')})`),
-            }
-          : matcher,
-        {
-          ...matcher,
-          regexp: pathToRegexp(matcher.originalSource).source,
-        },
-      ]
-    }
-    return matcher
-  })
-}
-
-const writeHandlerFile = async (
-  ctx: PluginContext,
-  { matchers, name }: EdgeOrNodeMiddlewareDefinition,
-) => {
-  const nextConfig = ctx.buildConfig
-  const handlerName = getHandlerName({ name })
-  const handlerDirectory = join(ctx.edgeFunctionsDir, handlerName)
-  const handlerRuntimeDirectory = join(handlerDirectory, 'edge-runtime')
-
-  // Copying the runtime files. These are the compatibility layer between
-  // Netlify Edge Functions and the Next.js edge runtime.
-  await copyRuntime(ctx, handlerDirectory)
-
-  // Writing a file with the matchers that should trigger this function. We'll
-  // read this file from the function at runtime.
-  await writeFile(join(handlerRuntimeDirectory, 'matchers.json'), JSON.stringify(matchers))
-
-  // The config is needed by the edge function to match and normalize URLs. To
-  // avoid shipping and parsing a large file at runtime, let's strip it down to
-  // just the properties that the edge function actually needs.
-  const minimalNextConfig = {
-    basePath: nextConfig.basePath,
-    i18n: nextConfig.i18n,
-    trailingSlash: nextConfig.trailingSlash,
-    skipMiddlewareUrlNormalize: nextConfig.skipMiddlewareUrlNormalize,
-  }
-
-  await writeFile(
-    join(handlerRuntimeDirectory, 'next.config.json'),
-    JSON.stringify(minimalNextConfig),
-  )
-
-  const htmlRewriterWasm = await readFile(
-    join(
-      ctx.pluginDir,
-      'edge-runtime/vendor/deno.land/x/htmlrewriter@v1.0.0/pkg/htmlrewriter_bg.wasm',
-    ),
-  )
-
-  // Writing the function entry file. It wraps the middleware code with the
-  // compatibility layer mentioned above.
-  await writeFile(
-    join(handlerDirectory, `${handlerName}.js`),
-    `
-    import { init as htmlRewriterInit } from './edge-runtime/vendor/deno.land/x/htmlrewriter@v1.0.0/src/index.ts'
-    import { handleMiddleware } from './edge-runtime/middleware.ts';
-    import handler from './server/${name}.js';
-
-    await htmlRewriterInit({ module_or_path: Uint8Array.from(${JSON.stringify([
-      ...htmlRewriterWasm,
-    ])}) });
-
-    export default (req, context) => handleMiddleware(req, context, handler);
-    `,
-  )
-}
-
-const copyHandlerDependenciesForEdgeMiddleware = async (
-  ctx: PluginContext,
-  { name, env, files, wasm }: EdgeMiddlewareDefinition,
-) => {
-  const srcDir = join(ctx.standaloneDir, ctx.nextDistDir)
-  const destDir = join(ctx.edgeFunctionsDir, getHandlerName({ name }))
-
-  const edgeRuntimeDir = join(ctx.pluginDir, 'edge-runtime')
-  const shimPath = join(edgeRuntimeDir, 'shim/edge.js')
-  const shim = await readFile(shimPath, 'utf8')
-
-  const parts = [shim]
-
-  const outputFile = join(destDir, `server/${name}.js`)
-
-  if (env) {
-    // Prepare environment variables for draft-mode (i.e. __NEXT_PREVIEW_MODE_ID, __NEXT_PREVIEW_MODE_SIGNING_KEY, __NEXT_PREVIEW_MODE_ENCRYPTION_KEY)
-    for (const [key, value] of Object.entries(env)) {
-      parts.push(`process.env.${key} = '${value}';`)
-    }
-  }
-
-  if (wasm?.length) {
-    for (const wasmChunk of wasm ?? []) {
-      const data = await readFile(join(srcDir, wasmChunk.filePath))
-      parts.push(`const ${wasmChunk.name} = Uint8Array.from(${JSON.stringify([...data])})`)
-    }
-  }
-
-  for (const file of files) {
-    const entrypoint = await readFile(join(srcDir, file), 'utf8')
-    parts.push(`;// Concatenated file: ${file} \n`, entrypoint)
-  }
-  parts.push(
-    `const middlewareEntryKey = Object.keys(_ENTRIES).find(entryKey => entryKey.startsWith("middleware_${name}"));`,
-    // turbopack entries are promises so we await here to get actual entry
-    // non-turbopack entries are already resolved, so await does not change anything
-    `export default await _ENTRIES[middlewareEntryKey].default;`,
-  )
-  await mkdir(dirname(outputFile), { recursive: true })
-
-  await writeFile(outputFile, parts.join('\n'))
-}
-
-const NODE_MIDDLEWARE_NAME = 'node-middleware'
-const copyHandlerDependenciesForNodeMiddleware = async (ctx: PluginContext) => {
-  const name = NODE_MIDDLEWARE_NAME
-
-  const srcDir = join(ctx.standaloneDir, ctx.nextDistDir)
-  const destDir = join(ctx.edgeFunctionsDir, getHandlerName({ name }))
-
-  const edgeRuntimeDir = join(ctx.pluginDir, 'edge-runtime')
-  const shimPath = join(edgeRuntimeDir, 'shim/node.js')
-  const shim = await readFile(shimPath, 'utf8')
-
-  const parts = [shim]
-
-  const entry = 'server/middleware.js'
-  const nft = `${entry}.nft.json`
-  const nftFilesPath = join(process.cwd(), ctx.nextDistDir, nft)
-  const nftManifest = JSON.parse(await readFile(nftFilesPath, 'utf8'))
-
-  const files: string[] = nftManifest.files.map((file: string) => join('server', file))
-  files.push(entry)
-
-  // files are relative to location of middleware entrypoint
-  // we need to capture all of them
-  // they might be going to parent directories, so first we check how many directories we need to go up
-  const { maxParentDirectoriesPath, unsupportedDotNodeModules } = files.reduce(
-    (acc, file) => {
-      let dirsUp = 0
-      let parentDirectoriesPath = ''
-      for (const part of file.split('/')) {
-        if (part === '..') {
-          dirsUp += 1
-          parentDirectoriesPath += '../'
-        } else {
-          break
-        }
-      }
-
-      if (file.endsWith('.node')) {
-        // C++ addons are not supported
-        acc.unsupportedDotNodeModules.push(join(srcDir, file))
-      }
-
-      if (dirsUp > acc.maxDirsUp) {
-        return {
-          ...acc,
-          maxDirsUp: dirsUp,
-          maxParentDirectoriesPath: parentDirectoriesPath,
-        }
-      }
-
-      return acc
-    },
-    { maxDirsUp: 0, maxParentDirectoriesPath: '', unsupportedDotNodeModules: [] as string[] },
-  )
-
-  if (unsupportedDotNodeModules.length !== 0) {
-    throw new Error(
-      `Usage of unsupported C++ Addon(s) found in Node.js Middleware:\n${unsupportedDotNodeModules.map((file) => `- ${file}`).join('\n')}\n\nCheck https://docs.netlify.com/build/frameworks/framework-setup-guides/nextjs/overview/#limitations for more information.`,
-    )
-  }
-
-  const commonPrefix = relative(join(srcDir, maxParentDirectoriesPath), srcDir)
-
-  parts.push(`const virtualModules = new Map();`)
-
-  const handleFileOrDirectory = async (fileOrDir: string) => {
-    const srcPath = join(srcDir, fileOrDir)
-
-    const stats = await stat(srcPath)
-    if (stats.isDirectory()) {
-      const filesInDir = await readdir(srcPath)
-      for (const fileInDir of filesInDir) {
-        await handleFileOrDirectory(join(fileOrDir, fileInDir))
-      }
-    } else {
-      const content = await readFile(srcPath, 'utf8')
-
-      parts.push(
-        `virtualModules.set(${JSON.stringify(join(commonPrefix, fileOrDir))}, ${JSON.stringify(content)});`,
-      )
-    }
-  }
-
-  for (const file of files) {
-    await handleFileOrDirectory(file)
-  }
-  parts.push(`registerCJSModules(import.meta.url, virtualModules);
-
-    const require = createRequire(import.meta.url);
-    const handlerMod = require("./${join(commonPrefix, entry)}");
-    const handler = handlerMod.default || handlerMod;
-
-    export default handler
-    `)
-
-  const outputFile = join(destDir, `server/${name}.js`)
-
-  await mkdir(dirname(outputFile), { recursive: true })
-
-  await writeFile(outputFile, parts.join('\n'))
-}
-
-const createEdgeHandler = async (
-  ctx: PluginContext,
-  definition: EdgeOrNodeMiddlewareDefinition,
-): Promise => {
-  await (definition.runtime === 'edge'
-    ? copyHandlerDependenciesForEdgeMiddleware(ctx, definition.functionDefinition)
-    : copyHandlerDependenciesForNodeMiddleware(ctx))
-  await writeHandlerFile(ctx, definition)
-}
-
-const getHandlerName = ({ name }: Pick): string =>
-  `${EDGE_HANDLER_NAME}-${name.replace(/\W/g, '-')}`
-
-const buildHandlerDefinition = (
-  ctx: PluginContext,
-  def: EdgeOrNodeMiddlewareDefinition,
-): Array => {
-  const functionHandlerName = getHandlerName({ name: def.name })
-  const functionName = 'Next.js Middleware Handler'
-  const cache = def.name.endsWith('middleware') ? undefined : ('manual' as const)
-  const generator = `${ctx.pluginName}@${ctx.pluginVersion}`
-
-  return augmentMatchers(def.matchers, ctx).map((matcher) => ({
-    function: functionHandlerName,
-    name: functionName,
-    pattern: matcher.regexp,
-    cache,
-    generator,
-  }))
-}
+import { PluginContext } from '../plugin-context.js'
 
 export const clearStaleEdgeHandlers = async (ctx: PluginContext) => {
   await rm(ctx.edgeFunctionsDir, { recursive: true, force: true })
 }
-
-export const createEdgeHandlers = async (ctx: PluginContext) => {
-  // Edge middleware
-  const nextManifest = await ctx.getMiddlewareManifest()
-  const middlewareDefinitions: EdgeOrNodeMiddlewareDefinition[] = [
-    ...Object.values(nextManifest.middleware),
-  ].map((edgeDefinition) => {
-    return {
-      runtime: 'edge',
-      functionDefinition: edgeDefinition,
-      name: edgeDefinition.name,
-      matchers: edgeDefinition.matchers,
-    }
-  })
-
-  // Node middleware
-  const functionsConfigManifest = await ctx.getFunctionsConfigManifest()
-  if (
-    functionsConfigManifest?.functions?.['/_middleware'] &&
-    nodeMiddlewareDefinitionHasMatcher(functionsConfigManifest?.functions?.['/_middleware'])
-  ) {
-    middlewareDefinitions.push({
-      runtime: 'nodejs',
-      functionDefinition: functionsConfigManifest?.functions?.['/_middleware'],
-      name: NODE_MIDDLEWARE_NAME,
-      matchers: functionsConfigManifest?.functions?.['/_middleware']?.matchers,
-    })
-  }
-
-  await Promise.all(middlewareDefinitions.map((def) => createEdgeHandler(ctx, def)))
-
-  const netlifyDefinitions = middlewareDefinitions.flatMap((def) =>
-    buildHandlerDefinition(ctx, def),
-  )
-
-  const netlifyManifest: Manifest = {
-    version: 1,
-    functions: netlifyDefinitions,
-  }
-  await writeEdgeManifest(ctx, netlifyManifest)
-}

From 36dc9dbb37394ac269a03ea9c9e23c0990f43532 Mon Sep 17 00:00:00 2001
From: Mateusz Bocian 
Date: Wed, 24 Sep 2025 10:42:18 -0400
Subject: [PATCH 26/39] rename step

---
 src/adapter/adapter.ts                      | 5 +++--
 src/adapter/{static.ts => static-assets.ts} | 0
 2 files changed, 3 insertions(+), 2 deletions(-)
 rename src/adapter/{static.ts => static-assets.ts} (100%)

diff --git a/src/adapter/adapter.ts b/src/adapter/adapter.ts
index aed852a03e..709cb5db29 100644
--- a/src/adapter/adapter.ts
+++ b/src/adapter/adapter.ts
@@ -9,7 +9,8 @@ import {
   onBuildComplete as onBuildCompleteForImageCDN,
 } from './image-cdn.js'
 import { onBuildComplete as onBuildCompleteForMiddleware } from './middleware.js'
-import { onBuildComplete as onBuildCompleteForStaticFiles } from './static.js'
+import { onBuildComplete as onBuildCompleteForStaticAssets } from './static-assets.js'
+import { onBuildComplete as onBuildCompleteForStaticContent } from './static-content.js'
 import { FrameworksAPIConfig } from './types.js'
 
 const NETLIFY_FRAMEWORKS_API_CONFIG_PATH = '.netlify/v1/config.json'
@@ -40,7 +41,7 @@ const adapter: NextAdapter = {
       nextAdapterContext,
       frameworksAPIConfig,
     )
-    frameworksAPIConfig = await onBuildCompleteForStaticFiles(
+    frameworksAPIConfig = await onBuildCompleteForStaticAssets(
       nextAdapterContext,
       frameworksAPIConfig,
     )
diff --git a/src/adapter/static.ts b/src/adapter/static-assets.ts
similarity index 100%
rename from src/adapter/static.ts
rename to src/adapter/static-assets.ts

From a42a9b8edd2d71d283614299693c7a1d3d74b57c Mon Sep 17 00:00:00 2001
From: Mateusz Bocian 
Date: Wed, 24 Sep 2025 10:45:21 -0400
Subject: [PATCH 27/39] move static content step to adapter pattern

---
 src/adapter/adapter.ts           |   4 +
 src/adapter/static-content.ts    |  44 ++++
 src/build/content/static.test.ts | 378 -------------------------------
 src/build/content/static.ts      |  49 +---
 src/index.ts                     |   8 +-
 5 files changed, 51 insertions(+), 432 deletions(-)
 create mode 100644 src/adapter/static-content.ts
 delete mode 100644 src/build/content/static.test.ts

diff --git a/src/adapter/adapter.ts b/src/adapter/adapter.ts
index 709cb5db29..eeb878d6c5 100644
--- a/src/adapter/adapter.ts
+++ b/src/adapter/adapter.ts
@@ -45,6 +45,10 @@ const adapter: NextAdapter = {
       nextAdapterContext,
       frameworksAPIConfig,
     )
+    frameworksAPIConfig = await onBuildCompleteForStaticContent(
+      nextAdapterContext,
+      frameworksAPIConfig,
+    )
     frameworksAPIConfig = onBuildCompleteForHeaders(nextAdapterContext, frameworksAPIConfig)
 
     if (frameworksAPIConfig) {
diff --git a/src/adapter/static-content.ts b/src/adapter/static-content.ts
new file mode 100644
index 0000000000..1b0bb5f870
--- /dev/null
+++ b/src/adapter/static-content.ts
@@ -0,0 +1,44 @@
+import { mkdir, readFile, writeFile } from 'node:fs/promises'
+import { join } from 'node:path/posix'
+
+import type { HtmlBlob } from '../shared/blob-types.cjs'
+import { encodeBlobKey } from '../shared/blobkey.js'
+
+import type { FrameworksAPIConfig, OnBuildCompleteContext } from './types.js'
+
+export async function onBuildComplete(
+  ctx: OnBuildCompleteContext,
+  frameworksAPIConfigArg: FrameworksAPIConfig,
+) {
+  const frameworksAPIConfig: FrameworksAPIConfig = frameworksAPIConfigArg ?? {}
+
+  const BLOBS_DIRECTORY = join(ctx.projectDir, '.netlify/deploy/v1/blobs/deploy')
+
+  try {
+    await mkdir(BLOBS_DIRECTORY, { recursive: true })
+
+    for (const appPage of ctx.outputs.appPages) {
+      const html = await readFile(appPage.filePath, 'utf-8')
+
+      await writeFile(
+        join(BLOBS_DIRECTORY, await encodeBlobKey(appPage.pathname)),
+        JSON.stringify({ html, isFullyStaticPage: false } satisfies HtmlBlob),
+        'utf-8',
+      )
+    }
+
+    for (const appRoute of ctx.outputs.appRoutes) {
+      const html = await readFile(appRoute.filePath, 'utf-8')
+
+      await writeFile(
+        join(BLOBS_DIRECTORY, await encodeBlobKey(appRoute.pathname)),
+        JSON.stringify({ html, isFullyStaticPage: false } satisfies HtmlBlob),
+        'utf-8',
+      )
+    }
+  } catch (error) {
+    throw new Error(`Failed assembling static pages for upload`, { cause: error })
+  }
+
+  return frameworksAPIConfig
+}
diff --git a/src/build/content/static.test.ts b/src/build/content/static.test.ts
deleted file mode 100644
index a483e9b30d..0000000000
--- a/src/build/content/static.test.ts
+++ /dev/null
@@ -1,378 +0,0 @@
-import { readFile } from 'node:fs/promises'
-import { join } from 'node:path'
-import { inspect } from 'node:util'
-
-import type { NetlifyPluginOptions } from '@netlify/build'
-import glob from 'fast-glob'
-import type { PrerenderManifest } from 'next/dist/build/index.js'
-import { beforeEach, describe, expect, Mock, test, vi } from 'vitest'
-
-import { decodeBlobKey, encodeBlobKey } from '../../../tests/index.js'
-import { type FixtureTestContext } from '../../../tests/utils/contexts.js'
-import { createFsFixture } from '../../../tests/utils/fixture.js'
-import { HtmlBlob } from '../../shared/blob-types.cjs'
-import { PluginContext, RequiredServerFilesManifest } from '../plugin-context.js'
-
-import { copyStaticContent } from './static.js'
-
-type Context = FixtureTestContext & {
-  pluginContext: PluginContext
-  publishDir: string
-  relativeAppDir: string
-}
-const createFsFixtureWithBasePath = (
-  fixture: Record,
-  ctx: Omit,
-  {
-    basePath = '',
-    // eslint-disable-next-line unicorn/no-useless-undefined
-    i18n = undefined,
-    dynamicRoutes = {},
-    pagesManifest = {},
-  }: {
-    basePath?: string
-    i18n?: Pick, 'locales'>
-    dynamicRoutes?: {
-      [route: string]: Pick
-    }
-    pagesManifest?: Record
-  } = {},
-) => {
-  return createFsFixture(
-    {
-      ...fixture,
-      [join(ctx.publishDir, 'routes-manifest.json')]: JSON.stringify({ basePath }),
-      [join(ctx.publishDir, 'required-server-files.json')]: JSON.stringify({
-        relativeAppDir: ctx.relativeAppDir,
-        appDir: ctx.relativeAppDir,
-        config: {
-          distDir: ctx.publishDir,
-          i18n,
-        },
-      } as Pick),
-      [join(ctx.publishDir, 'prerender-manifest.json')]: JSON.stringify({ dynamicRoutes }),
-      [join(ctx.publishDir, 'server', 'pages-manifest.json')]: JSON.stringify(pagesManifest),
-    },
-    ctx,
-  )
-}
-
-let failBuildMock: Mock
-
-describe('Regular Repository layout', () => {
-  beforeEach((ctx) => {
-    failBuildMock = vi.fn((msg, err) => {
-      expect.fail(`failBuild should not be called, was called with ${inspect({ msg, err })}`)
-    })
-    ctx.publishDir = '.next'
-    ctx.relativeAppDir = ''
-    ctx.pluginContext = new PluginContext({
-      constants: {
-        PUBLISH_DIR: ctx.publishDir,
-      },
-      utils: {
-        build: {
-          failBuild: failBuildMock,
-        } as unknown,
-      },
-    } as NetlifyPluginOptions)
-  })
-
-  describe('should copy the static pages to the publish directory if there are no corresponding JSON files and mark wether html file is a fully static pages router page', () => {
-    test('no i18n', async ({ pluginContext, ...ctx }) => {
-      await createFsFixtureWithBasePath(
-        {
-          '.next/server/pages/test.html': '',
-          '.next/server/pages/test2.html': '',
-          '.next/server/pages/test3.html': '',
-          '.next/server/pages/test3.json': '',
-          '.next/server/pages/blog/[slug].html': '',
-        },
-        ctx,
-        {
-          dynamicRoutes: {
-            '/blog/[slug]': {
-              fallback: '/blog/[slug].html',
-            },
-          },
-          pagesManifest: {
-            '/blog/[slug]': 'pages/blog/[slug].js',
-            '/test': 'pages/test.html',
-            '/test2': 'pages/test2.html',
-            '/test3': 'pages/test3.js',
-          },
-        },
-      )
-
-      await copyStaticContent(pluginContext)
-      const files = await glob('**/*', { cwd: pluginContext.blobDir, dot: true })
-
-      const expectedHtmlBlobs = ['blog/[slug].html', 'test.html', 'test2.html']
-      const expectedFullyStaticPages = new Set(['test.html', 'test2.html'])
-
-      expect(files.map((path) => decodeBlobKey(path)).sort()).toEqual(expectedHtmlBlobs)
-
-      for (const page of expectedHtmlBlobs) {
-        const expectedIsFullyStaticPage = expectedFullyStaticPages.has(page)
-
-        const blob = JSON.parse(
-          await readFile(join(pluginContext.blobDir, await encodeBlobKey(page)), 'utf-8'),
-        ) as HtmlBlob
-
-        expect(
-          blob,
-          `${page} should ${expectedIsFullyStaticPage ? '' : 'not '}be a fully static Page`,
-        ).toEqual({
-          html: '',
-          isFullyStaticPage: expectedIsFullyStaticPage,
-        })
-      }
-    })
-
-    test('with i18n', async ({ pluginContext, ...ctx }) => {
-      await createFsFixtureWithBasePath(
-        {
-          '.next/server/pages/de/test.html': '',
-          '.next/server/pages/de/test2.html': '',
-          '.next/server/pages/de/test3.html': '',
-          '.next/server/pages/de/test3.json': '',
-          '.next/server/pages/de/blog/[slug].html': '',
-          '.next/server/pages/en/test.html': '',
-          '.next/server/pages/en/test2.html': '',
-          '.next/server/pages/en/test3.html': '',
-          '.next/server/pages/en/test3.json': '',
-          '.next/server/pages/en/blog/[slug].html': '',
-        },
-        ctx,
-        {
-          dynamicRoutes: {
-            '/blog/[slug]': {
-              fallback: '/blog/[slug].html',
-            },
-          },
-          i18n: {
-            locales: ['en', 'de'],
-          },
-          pagesManifest: {
-            '/blog/[slug]': 'pages/blog/[slug].js',
-            '/en/test': 'pages/en/test.html',
-            '/de/test': 'pages/de/test.html',
-            '/en/test2': 'pages/en/test2.html',
-            '/de/test2': 'pages/de/test2.html',
-            '/test3': 'pages/test3.js',
-          },
-        },
-      )
-
-      await copyStaticContent(pluginContext)
-      const files = await glob('**/*', { cwd: pluginContext.blobDir, dot: true })
-
-      const expectedHtmlBlobs = [
-        'de/blog/[slug].html',
-        'de/test.html',
-        'de/test2.html',
-        'en/blog/[slug].html',
-        'en/test.html',
-        'en/test2.html',
-      ]
-      const expectedFullyStaticPages = new Set([
-        'en/test.html',
-        'de/test.html',
-        'en/test2.html',
-        'de/test2.html',
-      ])
-
-      expect(files.map((path) => decodeBlobKey(path)).sort()).toEqual(expectedHtmlBlobs)
-
-      for (const page of expectedHtmlBlobs) {
-        const expectedIsFullyStaticPage = expectedFullyStaticPages.has(page)
-
-        const blob = JSON.parse(
-          await readFile(join(pluginContext.blobDir, await encodeBlobKey(page)), 'utf-8'),
-        ) as HtmlBlob
-
-        expect(
-          blob,
-          `${page} should ${expectedIsFullyStaticPage ? '' : 'not '}be a fully static Page`,
-        ).toEqual({
-          html: '',
-          isFullyStaticPage: expectedIsFullyStaticPage,
-        })
-      }
-    })
-  })
-
-  test('should not copy the static pages to the publish directory if there are corresponding JSON files', async ({
-    pluginContext,
-    ...ctx
-  }) => {
-    await createFsFixtureWithBasePath(
-      {
-        '.next/server/pages/test.html': '',
-        '.next/server/pages/test.json': '',
-        '.next/server/pages/test2.html': '',
-        '.next/server/pages/test2.json': '',
-      },
-      ctx,
-    )
-
-    await copyStaticContent(pluginContext)
-    expect(await glob('**/*', { cwd: pluginContext.blobDir, dot: true })).toHaveLength(0)
-  })
-})
-
-describe('Mono Repository', () => {
-  beforeEach((ctx) => {
-    ctx.publishDir = 'apps/app-1/.next'
-    ctx.relativeAppDir = 'apps/app-1'
-    ctx.pluginContext = new PluginContext({
-      constants: {
-        PUBLISH_DIR: ctx.publishDir,
-        PACKAGE_PATH: 'apps/app-1',
-      },
-      utils: { build: { failBuild: vi.fn() } as unknown },
-    } as NetlifyPluginOptions)
-  })
-
-  describe('should copy the static pages to the publish directory if there are no corresponding JSON files and mark wether html file is a fully static pages router page', () => {
-    test('no i18n', async ({ pluginContext, ...ctx }) => {
-      await createFsFixtureWithBasePath(
-        {
-          'apps/app-1/.next/server/pages/test.html': '',
-          'apps/app-1/.next/server/pages/test2.html': '',
-          'apps/app-1/.next/server/pages/test3.html': '',
-          'apps/app-1/.next/server/pages/test3.json': '',
-          'apps/app-1/.next/server/pages/blog/[slug].html': '',
-        },
-        ctx,
-        {
-          dynamicRoutes: {
-            '/blog/[slug]': {
-              fallback: '/blog/[slug].html',
-            },
-          },
-          pagesManifest: {
-            '/blog/[slug]': 'pages/blog/[slug].js',
-            '/test': 'pages/test.html',
-            '/test2': 'pages/test2.html',
-            '/test3': 'pages/test3.js',
-          },
-        },
-      )
-
-      await copyStaticContent(pluginContext)
-      const files = await glob('**/*', { cwd: pluginContext.blobDir, dot: true })
-
-      const expectedHtmlBlobs = ['blog/[slug].html', 'test.html', 'test2.html']
-      const expectedFullyStaticPages = new Set(['test.html', 'test2.html'])
-
-      expect(files.map((path) => decodeBlobKey(path)).sort()).toEqual(expectedHtmlBlobs)
-
-      for (const page of expectedHtmlBlobs) {
-        const expectedIsFullyStaticPage = expectedFullyStaticPages.has(page)
-
-        const blob = JSON.parse(
-          await readFile(join(pluginContext.blobDir, await encodeBlobKey(page)), 'utf-8'),
-        ) as HtmlBlob
-
-        expect(
-          blob,
-          `${page} should ${expectedIsFullyStaticPage ? '' : 'not '}be a fully static Page`,
-        ).toEqual({
-          html: '',
-          isFullyStaticPage: expectedIsFullyStaticPage,
-        })
-      }
-    })
-
-    test('with i18n', async ({ pluginContext, ...ctx }) => {
-      await createFsFixtureWithBasePath(
-        {
-          'apps/app-1/.next/server/pages/de/test.html': '',
-          'apps/app-1/.next/server/pages/de/test2.html': '',
-          'apps/app-1/.next/server/pages/de/test3.html': '',
-          'apps/app-1/.next/server/pages/de/test3.json': '',
-          'apps/app-1/.next/server/pages/de/blog/[slug].html': '',
-          'apps/app-1/.next/server/pages/en/test.html': '',
-          'apps/app-1/.next/server/pages/en/test2.html': '',
-          'apps/app-1/.next/server/pages/en/test3.html': '',
-          'apps/app-1/.next/server/pages/en/test3.json': '',
-          'apps/app-1/.next/server/pages/en/blog/[slug].html': '',
-        },
-        ctx,
-        {
-          dynamicRoutes: {
-            '/blog/[slug]': {
-              fallback: '/blog/[slug].html',
-            },
-          },
-          i18n: {
-            locales: ['en', 'de'],
-          },
-          pagesManifest: {
-            '/blog/[slug]': 'pages/blog/[slug].js',
-            '/en/test': 'pages/en/test.html',
-            '/de/test': 'pages/de/test.html',
-            '/en/test2': 'pages/en/test2.html',
-            '/de/test2': 'pages/de/test2.html',
-            '/test3': 'pages/test3.js',
-          },
-        },
-      )
-
-      await copyStaticContent(pluginContext)
-      const files = await glob('**/*', { cwd: pluginContext.blobDir, dot: true })
-
-      const expectedHtmlBlobs = [
-        'de/blog/[slug].html',
-        'de/test.html',
-        'de/test2.html',
-        'en/blog/[slug].html',
-        'en/test.html',
-        'en/test2.html',
-      ]
-      const expectedFullyStaticPages = new Set([
-        'en/test.html',
-        'de/test.html',
-        'en/test2.html',
-        'de/test2.html',
-      ])
-
-      expect(files.map((path) => decodeBlobKey(path)).sort()).toEqual(expectedHtmlBlobs)
-
-      for (const page of expectedHtmlBlobs) {
-        const expectedIsFullyStaticPage = expectedFullyStaticPages.has(page)
-
-        const blob = JSON.parse(
-          await readFile(join(pluginContext.blobDir, await encodeBlobKey(page)), 'utf-8'),
-        ) as HtmlBlob
-
-        expect(
-          blob,
-          `${page} should ${expectedIsFullyStaticPage ? '' : 'not '}be a fully static Page`,
-        ).toEqual({
-          html: '',
-          isFullyStaticPage: expectedIsFullyStaticPage,
-        })
-      }
-    })
-  })
-
-  test('should not copy the static pages to the publish directory if there are corresponding JSON files', async ({
-    pluginContext,
-    ...ctx
-  }) => {
-    await createFsFixtureWithBasePath(
-      {
-        'apps/app-1/.next/server/pages/test.html': '',
-        'apps/app-1/.next/server/pages/test.json': '',
-        'apps/app-1/.next/server/pages/test2.html': '',
-        'apps/app-1/.next/server/pages/test2.json': '',
-      },
-      ctx,
-    )
-
-    await copyStaticContent(pluginContext)
-    expect(await glob('**/*', { cwd: pluginContext.blobDir, dot: true })).toHaveLength(0)
-  })
-})
diff --git a/src/build/content/static.ts b/src/build/content/static.ts
index cb218f432b..739bd1c39b 100644
--- a/src/build/content/static.ts
+++ b/src/build/content/static.ts
@@ -1,59 +1,14 @@
 import { existsSync } from 'node:fs'
-import { cp, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
-import { basename, join } from 'node:path'
+import { cp, mkdir, rename, rm } from 'node:fs/promises'
+import { basename } from 'node:path'
 
 import { trace } from '@opentelemetry/api'
 import { wrapTracer } from '@opentelemetry/api/experimental'
-import glob from 'fast-glob'
 
-import type { HtmlBlob } from '../../shared/blob-types.cjs'
-import { encodeBlobKey } from '../../shared/blobkey.js'
 import { PluginContext } from '../plugin-context.js'
-import { verifyNetlifyForms } from '../verification.js'
 
 const tracer = wrapTracer(trace.getTracer('Next runtime'))
 
-/**
- * Assemble the static content for being uploaded to the blob storage
- */
-export const copyStaticContent = async (ctx: PluginContext): Promise => {
-  return tracer.withActiveSpan('copyStaticContent', async () => {
-    const srcDir = join(ctx.publishDir, 'server/pages')
-    const destDir = ctx.blobDir
-
-    const paths = await glob('**/*.+(html|json)', {
-      cwd: srcDir,
-      extglob: true,
-    })
-
-    const fallbacks = ctx.getFallbacks(await ctx.getPrerenderManifest())
-    const fullyStaticPages = await ctx.getFullyStaticHtmlPages()
-
-    try {
-      await mkdir(destDir, { recursive: true })
-      await Promise.all(
-        paths
-          .filter((path) => !path.endsWith('.json') && !paths.includes(`${path.slice(0, -5)}.json`))
-          .map(async (path): Promise => {
-            const html = await readFile(join(srcDir, path), 'utf-8')
-            verifyNetlifyForms(ctx, html)
-
-            const isFallback = fallbacks.includes(path.slice(0, -5))
-            const isFullyStaticPage = !isFallback && fullyStaticPages.includes(path)
-
-            await writeFile(
-              join(destDir, await encodeBlobKey(path)),
-              JSON.stringify({ html, isFullyStaticPage } satisfies HtmlBlob),
-              'utf-8',
-            )
-          }),
-      )
-    } catch (error) {
-      ctx.failBuild('Failed assembling static pages for upload', error)
-    }
-  })
-}
-
 export const copyStaticExport = async (ctx: PluginContext): Promise => {
   await tracer.withActiveSpan('copyStaticExport', async () => {
     if (!ctx.exportDetail?.outDirectory) {
diff --git a/src/index.ts b/src/index.ts
index f08a496ec3..804ea8a202 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -7,12 +7,7 @@ import { wrapTracer } from '@opentelemetry/api/experimental'
 
 import { restoreBuildCache, saveBuildCache } from './build/cache.js'
 import { copyPrerenderedContent } from './build/content/prerendered.js'
-import {
-  copyStaticContent,
-  copyStaticExport,
-  publishStaticDir,
-  unpublishStaticDir,
-} from './build/content/static.js'
+import { copyStaticExport, publishStaticDir, unpublishStaticDir } from './build/content/static.js'
 import { clearStaleEdgeHandlers } from './build/functions/edge.js'
 import { clearStaleServerHandlers, createServerHandler } from './build/functions/server.js'
 import { PluginContext } from './build/plugin-context.js'
@@ -93,7 +88,6 @@ export const onBuild = async (options: NetlifyPluginOptions) => {
     await verifyNetlifyFormsWorkaround(ctx)
 
     await Promise.all([
-      copyStaticContent(ctx), // this
       copyPrerenderedContent(ctx), // maybe this
       createServerHandler(ctx), // not this while we use standalone
     ])

From 5b436e2a250136586949e1b7705947b1eb04b20b Mon Sep 17 00:00:00 2001
From: Mateusz Bocian 
Date: Wed, 24 Sep 2025 11:15:08 -0400
Subject: [PATCH 28/39] actually static-content step is not needed

---
 src/adapter/adapter.ts        |  6 +----
 src/adapter/static-content.ts | 44 -----------------------------------
 2 files changed, 1 insertion(+), 49 deletions(-)
 delete mode 100644 src/adapter/static-content.ts

diff --git a/src/adapter/adapter.ts b/src/adapter/adapter.ts
index eeb878d6c5..ea7e68aeac 100644
--- a/src/adapter/adapter.ts
+++ b/src/adapter/adapter.ts
@@ -10,7 +10,6 @@ import {
 } from './image-cdn.js'
 import { onBuildComplete as onBuildCompleteForMiddleware } from './middleware.js'
 import { onBuildComplete as onBuildCompleteForStaticAssets } from './static-assets.js'
-import { onBuildComplete as onBuildCompleteForStaticContent } from './static-content.js'
 import { FrameworksAPIConfig } from './types.js'
 
 const NETLIFY_FRAMEWORKS_API_CONFIG_PATH = '.netlify/v1/config.json'
@@ -45,10 +44,7 @@ const adapter: NextAdapter = {
       nextAdapterContext,
       frameworksAPIConfig,
     )
-    frameworksAPIConfig = await onBuildCompleteForStaticContent(
-      nextAdapterContext,
-      frameworksAPIConfig,
-    )
+    // TODO: verifyNetlifyForms
     frameworksAPIConfig = onBuildCompleteForHeaders(nextAdapterContext, frameworksAPIConfig)
 
     if (frameworksAPIConfig) {
diff --git a/src/adapter/static-content.ts b/src/adapter/static-content.ts
deleted file mode 100644
index 1b0bb5f870..0000000000
--- a/src/adapter/static-content.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { mkdir, readFile, writeFile } from 'node:fs/promises'
-import { join } from 'node:path/posix'
-
-import type { HtmlBlob } from '../shared/blob-types.cjs'
-import { encodeBlobKey } from '../shared/blobkey.js'
-
-import type { FrameworksAPIConfig, OnBuildCompleteContext } from './types.js'
-
-export async function onBuildComplete(
-  ctx: OnBuildCompleteContext,
-  frameworksAPIConfigArg: FrameworksAPIConfig,
-) {
-  const frameworksAPIConfig: FrameworksAPIConfig = frameworksAPIConfigArg ?? {}
-
-  const BLOBS_DIRECTORY = join(ctx.projectDir, '.netlify/deploy/v1/blobs/deploy')
-
-  try {
-    await mkdir(BLOBS_DIRECTORY, { recursive: true })
-
-    for (const appPage of ctx.outputs.appPages) {
-      const html = await readFile(appPage.filePath, 'utf-8')
-
-      await writeFile(
-        join(BLOBS_DIRECTORY, await encodeBlobKey(appPage.pathname)),
-        JSON.stringify({ html, isFullyStaticPage: false } satisfies HtmlBlob),
-        'utf-8',
-      )
-    }
-
-    for (const appRoute of ctx.outputs.appRoutes) {
-      const html = await readFile(appRoute.filePath, 'utf-8')
-
-      await writeFile(
-        join(BLOBS_DIRECTORY, await encodeBlobKey(appRoute.pathname)),
-        JSON.stringify({ html, isFullyStaticPage: false } satisfies HtmlBlob),
-        'utf-8',
-      )
-    }
-  } catch (error) {
-    throw new Error(`Failed assembling static pages for upload`, { cause: error })
-  }
-
-  return frameworksAPIConfig
-}

From e140441204af8b488405374953a062284601c60f Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Wed, 24 Sep 2025 17:18:13 +0200
Subject: [PATCH 29/39] mark middleware handling as done

---
 adapters-notes.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/adapters-notes.md b/adapters-notes.md
index 6fddd43831..94218ac7cc 100644
--- a/adapters-notes.md
+++ b/adapters-notes.md
@@ -49,7 +49,7 @@
     \_next/image rewrite then)
   - (maybe/explore) set build time cache handler to avoid having to read output of default cache
     handler and convert those files into blobs to upload later
-- [partially done - for edge runtime] use middleware output to generate middleware edge function
+- [done] use middleware output to generate middleware edge function
 - [done] don't glob for static files and use `outputs.staticFiles` instead
 - check `output: 'export'` case
 - note any remaining manual manifest files reading in build plugin once everything that could be

From 3f76a9fc28199d8739723854fcef262b56ffb2d1 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Wed, 24 Sep 2025 17:48:54 +0200
Subject: [PATCH 30/39] add note/question about export output

---
 adapters-notes.md | 8 +++++++-
 1 file changed, 7 insertions(+), 1 deletion(-)

diff --git a/adapters-notes.md b/adapters-notes.md
index 94218ac7cc..3530390036 100644
--- a/adapters-notes.md
+++ b/adapters-notes.md
@@ -33,6 +33,11 @@
   could report issues in correct place in such cases. Not that important for nearest future / not
   blocking)
 
+- `output: 'export'` case seems to produce outputs as if it was not export mode (for example having
+  non-empty `outputs.appPages` or `outputs.prerenders`). To not have special handling for that in
+  adapters, only non-empty outputs should be `staticFiles` pointing to what's being written to `out`
+  (or custom `distDir`) directory?
+
 ## Plan
 
 1. There are some operations that are easier to do in a build plugin context due to helpers, so some
@@ -51,7 +56,8 @@
     handler and convert those files into blobs to upload later
 - [done] use middleware output to generate middleware edge function
 - [done] don't glob for static files and use `outputs.staticFiles` instead
-- check `output: 'export'` case
+- [checked, did not apply changes yet, due to question about this in feedback section] check
+  `output: 'export'` case
 - note any remaining manual manifest files reading in build plugin once everything that could be
   adjusted was handled
 

From 2341079996db14b980740625bf976ba9dbea2ad6 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Wed, 24 Sep 2025 18:17:15 +0200
Subject: [PATCH 31/39] preserve html extension for static files

---
 src/adapter/static-assets.ts | 14 +++++++++++---
 1 file changed, 11 insertions(+), 3 deletions(-)

diff --git a/src/adapter/static-assets.ts b/src/adapter/static-assets.ts
index fa1bd3c2da..04f49bf133 100644
--- a/src/adapter/static-assets.ts
+++ b/src/adapter/static-assets.ts
@@ -1,5 +1,5 @@
 import { cp } from 'node:fs/promises'
-import { join } from 'node:path/posix'
+import { extname, join } from 'node:path/posix'
 
 import type { FrameworksAPIConfig, OnBuildCompleteContext } from './types.js'
 
@@ -11,11 +11,19 @@ export async function onBuildComplete(
 
   for (const staticFile of ctx.outputs.staticFiles) {
     try {
-      await cp(staticFile.filePath, join('./.netlify/static', staticFile.pathname), {
+      let distPathname = staticFile.pathname
+      if (extname(distPathname) === '' && extname(staticFile.filePath) === '.html') {
+        // if pathname is extension-less, but source file has an .html extension, preserve it
+        distPathname += '.html'
+      }
+
+      await cp(staticFile.filePath, join('./.netlify/static', distPathname), {
         recursive: true,
       })
     } catch (error) {
-      throw new Error(`Failed copying static assets`, { cause: error })
+      throw new Error(`Failed copying static asset.\n\n${JSON.stringify(staticFile, null, 2)}`, {
+        cause: error,
+      })
     }
   }
 

From 98c8e76070af97252efb91f94eddeb719bf78fb0 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Wed, 24 Sep 2025 19:28:14 +0200
Subject: [PATCH 32/39] add links to repro for i18n problems

---
 adapters-notes.md | 17 ++++++++++-------
 1 file changed, 10 insertions(+), 7 deletions(-)

diff --git a/adapters-notes.md b/adapters-notes.md
index 3530390036..4920697075 100644
--- a/adapters-notes.md
+++ b/adapters-notes.md
@@ -1,7 +1,8 @@
 ## Feedback
 
 - Files from `public` directory not listed in `outputs.staticFiles`. Should they be?
-- `routes.headers` does not contain immutable cache-control headers for `_next/static`
+- `routes.headers` does not contain immutable cache-control headers for `_next/static`. Should those
+  be included?
 - In `onBuildComplete` - `config.images.remotePatterns` type is `(RemotePattern | URL)[]` but in
   reality `URL` inputs are converted to `RemotePattern` so type should be just `RemotePattern[]` in
   `onBuildComplete` (this would require different config type for `modifyConfig` (allow inputs
@@ -14,11 +15,10 @@
   or `wasm` (tho wasm files are included in assets, so I think I have a way to support those as-is,
   but need to to make some assumption about using extension-less file name of wasm file as
   identifier)
-- `outputs.staticFiles` (i18n enabled) custom fully static (no `getStaticProps`) `/pages/404.js`
-  `filePath` point to not existing file (it doesn't have i18n locale prefix in `staticFiles` array,
-  actual 404.html are written to i18n locale prefixed directories)
-- `outputs.staticFiles` (i18n enabled) custom `/pages/404.js` with `getStaticProps` result in fatal
-  `Error: Invariant: failed to find source route /en/404 for prerender /en/404` directly from
+- `outputs.staticFiles` (i18n enabled) custom fully static (no `getStaticProps`) `/pages/*`
+  `filePath` point to not existing file - see repro at https://github.com/pieh/i18n-adapters
+- `outputs.staticFiles` (i18n enabled) custom `/pages/*` with `getStaticProps` result in fatal
+  `Error: Invariant: failed to find source route /en(/*) for prerender /en(/*)` directly from
   Next.js:
 
   ```
@@ -31,7 +31,10 @@
   (additionally - invariant is reported as failing to run `onBuildComplete` from adapter, but it
   happens before adapter's `onBuildComplete` runs, would be good to clear this up a bit so users
   could report issues in correct place in such cases. Not that important for nearest future / not
-  blocking)
+  blocking).
+
+  See repro at https://github.com/pieh/i18n-adapters (it's same as for point above, need to
+  uncomment `getStaticProps` in one of the pages in repro to see this case)
 
 - `output: 'export'` case seems to produce outputs as if it was not export mode (for example having
   non-empty `outputs.appPages` or `outputs.prerenders`). To not have special handling for that in

From 17e5f50923dcfee625bc3edc575dd3668942aba2 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Wed, 24 Sep 2025 19:48:25 +0200
Subject: [PATCH 33/39] split feedback and notes for us

---
 adapters-notes.md => adapters-feedback.md | 33 -----------------------
 adapters-running-notes.md                 | 32 ++++++++++++++++++++++
 2 files changed, 32 insertions(+), 33 deletions(-)
 rename adapters-notes.md => adapters-feedback.md (60%)
 create mode 100644 adapters-running-notes.md

diff --git a/adapters-notes.md b/adapters-feedback.md
similarity index 60%
rename from adapters-notes.md
rename to adapters-feedback.md
index 4920697075..52169fb6b7 100644
--- a/adapters-notes.md
+++ b/adapters-feedback.md
@@ -40,36 +40,3 @@
   non-empty `outputs.appPages` or `outputs.prerenders`). To not have special handling for that in
   adapters, only non-empty outputs should be `staticFiles` pointing to what's being written to `out`
   (or custom `distDir`) directory?
-
-## Plan
-
-1. There are some operations that are easier to do in a build plugin context due to helpers, so some
-   handling will remain in build plugin (cache save/restore, moving static assets dirs for
-   publishing them etc).
-
-2. We will use adapters API where it's most helpful:
-
-- adjusting next config:
-  - [done] set standalone mode instead of using "private" env var (for now at least we will continue
-    with standalone mode as using outputs other than middleware require bigger changes which will be
-    explored in later phases)
-  - [done] set image loader (url generator) to use Netlify Image CDN directly (no need for
-    \_next/image rewrite then)
-  - (maybe/explore) set build time cache handler to avoid having to read output of default cache
-    handler and convert those files into blobs to upload later
-- [done] use middleware output to generate middleware edge function
-- [done] don't glob for static files and use `outputs.staticFiles` instead
-- [checked, did not apply changes yet, due to question about this in feedback section] check
-  `output: 'export'` case
-- note any remaining manual manifest files reading in build plugin once everything that could be
-  adjusted was handled
-
-## To figure out
-
-- Can we export build time otel spans from adapter similarly how we do that now in a build plugin?
-- Expose some constants from build plugin to adapter - what's best way to do that? (things like
-  packagePath, publishDir etc)
-- Looking forward - Platform change to accept a list of files to upload to cdn (avoids file system
-  operations such as `cp`)
-- Looking forward - allow using regexes for static headers matcher (needed to apply next.config.js
-  defined headers to apply to static assets)
diff --git a/adapters-running-notes.md b/adapters-running-notes.md
new file mode 100644
index 0000000000..f21771fe3c
--- /dev/null
+++ b/adapters-running-notes.md
@@ -0,0 +1,32 @@
+## Plan
+
+1. There are some operations that are easier to do in a build plugin context due to helpers, so some
+   handling will remain in build plugin (cache save/restore, moving static assets dirs for
+   publishing them etc).
+
+2. We will use adapters API where it's most helpful:
+
+- adjusting next config:
+  - [done] set standalone mode instead of using "private" env var (for now at least we will continue
+    with standalone mode as using outputs other than middleware require bigger changes which will be
+    explored in later phases)
+  - [done] set image loader (url generator) to use Netlify Image CDN directly (no need for
+    \_next/image rewrite then)
+  - (maybe/explore) set build time cache handler to avoid having to read output of default cache
+    handler and convert those files into blobs to upload later
+- [done] use middleware output to generate middleware edge function
+- [done] don't glob for static files and use `outputs.staticFiles` instead
+- [checked, did not apply changes yet, due to question about this in feedback section] check
+  `output: 'export'` case
+- note any remaining manual manifest files reading in build plugin once everything that could be
+  adjusted was handled
+
+## To figure out
+
+- Can we export build time otel spans from adapter similarly how we do that now in a build plugin?
+- Expose some constants from build plugin to adapter - what's best way to do that? (things like
+  packagePath, publishDir etc)
+- Looking forward - Platform change to accept a list of files to upload to cdn (avoids file system
+  operations such as `cp`)
+- Looking forward - allow using regexes for static headers matcher (needed to apply next.config.js
+  defined headers to apply to static assets)

From 0305db378c5e1bac6e202d6bea7f14c03dc01490 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Thu, 25 Sep 2025 10:35:57 +0200
Subject: [PATCH 34/39] add note about static files and trailingSlash config

---
 adapters-feedback.md | 11 +++++++++++
 1 file changed, 11 insertions(+)

diff --git a/adapters-feedback.md b/adapters-feedback.md
index 52169fb6b7..7b3cbf8098 100644
--- a/adapters-feedback.md
+++ b/adapters-feedback.md
@@ -40,3 +40,14 @@
   non-empty `outputs.appPages` or `outputs.prerenders`). To not have special handling for that in
   adapters, only non-empty outputs should be `staticFiles` pointing to what's being written to `out`
   (or custom `distDir`) directory?
+- `output.staticFiles` entries for fully static pages router pages don't have `trailingSlash: true`
+  option applied to `pathname`. For example:
+  ```json
+  {
+    "id": "/link/rewrite-target-fullystatic",
+    "//": "Should pathname below have trailing slash, if `trailingSlash: true` is set in next.config.js?",
+    "pathname": "/link/rewrite-target-fullystatic",
+    "type": "STATIC_FILE",
+    "filePath": "/Users/misiek/dev/next-runtime-adapter/tests/fixtures/middleware-pages/.next/server/pages/link/rewrite-target-fullystatic.html"
+  }
+  ```

From a298b3b0d6eed1a0dac7df0d25844b496ca32a48 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Thu, 25 Sep 2025 11:56:14 +0200
Subject: [PATCH 35/39] remove next patching as we don't use that for newer
 next versions

---
 .../content/next-shims/telemetry-storage.cts  |  46 -------
 src/build/content/server.test.ts              | 116 +-----------------
 src/build/content/server.ts                   | 106 ----------------
 tests/integration/simple-app.test.ts          |  76 ------------
 4 files changed, 1 insertion(+), 343 deletions(-)
 delete mode 100644 src/build/content/next-shims/telemetry-storage.cts

diff --git a/src/build/content/next-shims/telemetry-storage.cts b/src/build/content/next-shims/telemetry-storage.cts
deleted file mode 100644
index 371083366f..0000000000
--- a/src/build/content/next-shims/telemetry-storage.cts
+++ /dev/null
@@ -1,46 +0,0 @@
-import type { Telemetry } from 'next/dist/telemetry/storage.js'
-
-type PublicOf = { [K in keyof T]: T[K] }
-
-export class TelemetryShim implements PublicOf {
-  sessionId = 'shim'
-
-  get anonymousId(): string {
-    return 'shim'
-  }
-
-  get salt(): string {
-    return 'shim'
-  }
-
-  setEnabled(): string | null {
-    return null
-  }
-
-  get isEnabled(): boolean {
-    return false
-  }
-
-  oneWayHash(): string {
-    return 'shim'
-  }
-
-  record(): Promise<{
-    isFulfilled: boolean
-    isRejected: boolean
-    value?: unknown
-    reason?: unknown
-  }> {
-    return Promise.resolve({ isFulfilled: true, isRejected: false })
-  }
-
-  flush(): Promise<
-    { isFulfilled: boolean; isRejected: boolean; value?: unknown; reason?: unknown }[] | null
-  > {
-    return Promise.resolve(null)
-  }
-
-  flushDetached(): void {
-    // no-op
-  }
-}
diff --git a/src/build/content/server.test.ts b/src/build/content/server.test.ts
index 02f463e964..92c2ba279c 100644
--- a/src/build/content/server.test.ts
+++ b/src/build/content/server.test.ts
@@ -7,12 +7,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'
 import { mockFileSystem } from '../../../tests/index.js'
 import { PluginContext, RequiredServerFilesManifest } from '../plugin-context.js'
 
-import {
-  copyNextServerCode,
-  getPatchesToApply,
-  NextInternalModuleReplacement,
-  verifyHandlerDirStructure,
-} from './server.js'
+import { copyNextServerCode, verifyHandlerDirStructure } from './server.js'
 
 vi.mock('node:fs', async () => {
   // eslint-disable-next-line @typescript-eslint/no-explicit-any, unicorn/no-await-expression-member
@@ -272,112 +267,3 @@ describe('verifyHandlerDirStructure', () => {
     )
   })
 })
-
-describe(`getPatchesToApply`, () => {
-  beforeEach(() => {
-    delete process.env.NETLIFY_NEXT_FORCE_APPLY_ONGOING_PATCHES
-  })
-  test('ongoing: false', () => {
-    const shouldPatchBeApplied = {
-      '13.4.1': false, // before supported next version
-      '13.5.1': true, // first stable supported version
-      '14.1.4-canary.1': true, // canary version before stable with maxStableVersion - should be applied
-      '14.1.4': true, // latest stable tested version
-      '14.2.0': false, // untested stable version
-      '14.2.0-canary.37': true, // maxVersion, should be applied
-      '14.2.0-canary.38': false, // not ongoing patch so should not be applied
-    }
-
-    const nextModule = 'test'
-
-    const patches: NextInternalModuleReplacement[] = [
-      {
-        ongoing: false,
-        minVersion: '13.5.0-canary.0',
-        maxVersion: '14.2.0-canary.37',
-        nextModule,
-        shimModule: 'not-used-in-test',
-      },
-    ]
-
-    for (const [nextVersion, telemetryShimShouldBeApplied] of Object.entries(
-      shouldPatchBeApplied,
-    )) {
-      const patchesToApply = getPatchesToApply(nextVersion, patches)
-      const hasTelemetryShim = patchesToApply.some((patch) => patch.nextModule === nextModule)
-      expect({ nextVersion, apply: hasTelemetryShim }).toEqual({
-        nextVersion,
-        apply: telemetryShimShouldBeApplied,
-      })
-    }
-  })
-
-  test('ongoing: true', () => {
-    const shouldPatchBeApplied = {
-      '13.4.1': false, // before supported next version
-      '13.5.1': true, // first stable supported version
-      '14.1.4-canary.1': true, // canary version before stable with maxStableVersion - should be applied
-      '14.1.4': true, // latest stable tested version
-      '14.2.0': false, // untested stable version
-      '14.2.0-canary.38': true, // ongoing patch so should be applied on prerelease versions
-    }
-
-    const nextModule = 'test'
-
-    const patches: NextInternalModuleReplacement[] = [
-      {
-        ongoing: true,
-        minVersion: '13.5.0-canary.0',
-        maxStableVersion: '14.1.4',
-        nextModule,
-        shimModule: 'not-used-in-test',
-      },
-    ]
-
-    for (const [nextVersion, telemetryShimShouldBeApplied] of Object.entries(
-      shouldPatchBeApplied,
-    )) {
-      const patchesToApply = getPatchesToApply(nextVersion, patches)
-      const hasTelemetryShim = patchesToApply.some((patch) => patch.nextModule === nextModule)
-      expect({ nextVersion, apply: hasTelemetryShim }).toEqual({
-        nextVersion,
-        apply: telemetryShimShouldBeApplied,
-      })
-    }
-  })
-
-  test('ongoing: true + NETLIFY_NEXT_FORCE_APPLY_ONGOING_PATCHES', () => {
-    process.env.NETLIFY_NEXT_FORCE_APPLY_ONGOING_PATCHES = 'true'
-    const shouldPatchBeApplied = {
-      '13.4.1': false, // before supported next version
-      '13.5.1': true, // first stable supported version
-      '14.1.4-canary.1': true, // canary version before stable with maxStableVersion - should be applied
-      '14.1.4': true, // latest stable tested version
-      '14.2.0': true, // untested stable version but NETLIFY_NEXT_FORCE_APPLY_ONGOING_PATCHES is used
-      '14.2.0-canary.38': true, // ongoing patch so should be applied on prerelease versions
-    }
-
-    const nextModule = 'test'
-
-    const patches: NextInternalModuleReplacement[] = [
-      {
-        ongoing: true,
-        minVersion: '13.5.0-canary.0',
-        maxStableVersion: '14.1.4',
-        nextModule,
-        shimModule: 'not-used-in-test',
-      },
-    ]
-
-    for (const [nextVersion, telemetryShimShouldBeApplied] of Object.entries(
-      shouldPatchBeApplied,
-    )) {
-      const patchesToApply = getPatchesToApply(nextVersion, patches)
-      const hasTelemetryShim = patchesToApply.some((patch) => patch.nextModule === nextModule)
-      expect({ nextVersion, apply: hasTelemetryShim }).toEqual({
-        nextVersion,
-        apply: telemetryShimShouldBeApplied,
-      })
-    }
-  })
-})
diff --git a/src/build/content/server.ts b/src/build/content/server.ts
index e5beb3ef54..1ee14189e2 100644
--- a/src/build/content/server.ts
+++ b/src/build/content/server.ts
@@ -186,108 +186,6 @@ async function recreateNodeModuleSymlinks(src: string, dest: string, org?: strin
   )
 }
 
-export type NextInternalModuleReplacement = {
-  /**
-   * Minimum Next.js version that this patch should be applied to
-   */
-  minVersion: string
-  /**
-   * If the reason to patch was not addressed in Next.js we mark this as ongoing
-   * to continue to test latest versions to know wether we should bump `maxStableVersion`
-   */
-  ongoing: boolean
-  /**
-   * Module that should be replaced
-   */
-  nextModule: string
-  /**
-   * Location of replacement module (relative to `/dist/build/content`)
-   */
-  shimModule: string
-} & (
-  | {
-      ongoing: true
-      /**
-       * Maximum Next.js version that this patch should be applied to, note that for ongoing patches
-       * we will continue to apply patch for prerelease versions also as canary versions are released
-       * very frequently and trying to target canary versions is not practical. If user is using
-       * canary next versions they should be aware of the risks
-       */
-      maxStableVersion: string
-    }
-  | {
-      ongoing: false
-      /**
-       * Maximum Next.js version that this patch should be applied to. This should be last released
-       * version of Next.js before version making the patch not needed anymore (can be canary version).
-       */
-      maxVersion: string
-    }
-)
-
-const nextInternalModuleReplacements: NextInternalModuleReplacement[] = [
-  {
-    // standalone is loading expensive Telemetry module that is not actually used
-    // so this replace that module with lightweight no-op shim that doesn't load additional modules
-    // see https://github.com/vercel/next.js/pull/63574 that removed need for this shim
-    ongoing: false,
-    minVersion: '13.5.0-canary.0',
-    // perf released in https://github.com/vercel/next.js/releases/tag/v14.2.0-canary.43
-    maxVersion: '14.2.0-canary.42',
-    nextModule: 'next/dist/telemetry/storage.js',
-    shimModule: './next-shims/telemetry-storage.cjs',
-  },
-]
-
-export function getPatchesToApply(
-  nextVersion: string,
-  patches: NextInternalModuleReplacement[] = nextInternalModuleReplacements,
-) {
-  return patches.filter((patch) => {
-    // don't apply patches for next versions below minVersion
-    if (semverLowerThan(nextVersion, patch.minVersion)) {
-      return false
-    }
-
-    if (patch.ongoing) {
-      // apply ongoing patches when used next version is prerelease or NETLIFY_NEXT_FORCE_APPLY_ONGOING_PATCHES env var is used
-      if (prerelease(nextVersion) || process.env.NETLIFY_NEXT_FORCE_APPLY_ONGOING_PATCHES) {
-        return true
-      }
-
-      // apply ongoing patches for stable next versions below or equal maxStableVersion
-      return semverLowerThanOrEqual(nextVersion, patch.maxStableVersion)
-    }
-
-    // apply patches for next versions below or equal maxVersion
-    return semverLowerThanOrEqual(nextVersion, patch.maxVersion)
-  })
-}
-
-async function patchNextModules(
-  ctx: PluginContext,
-  nextVersion: string,
-  serverHandlerRequireResolve: NodeRequire['resolve'],
-): Promise {
-  // apply only those patches that target used Next version
-  const moduleReplacementsToApply = getPatchesToApply(nextVersion)
-
-  if (moduleReplacementsToApply.length !== 0) {
-    await Promise.all(
-      moduleReplacementsToApply.map(async ({ nextModule, shimModule }) => {
-        try {
-          const nextModulePath = serverHandlerRequireResolve(nextModule)
-          const shimModulePath = posixJoin(ctx.pluginDir, 'dist', 'build', 'content', shimModule)
-
-          await cp(shimModulePath, nextModulePath, { force: true })
-        } catch {
-          // this is perf optimization, so failing it shouldn't break the build
-        }
-      }),
-    )
-  }
-}
-
 export const copyNextDependencies = async (ctx: PluginContext): Promise => {
   await tracer.withActiveSpan('copyNextDependencies', async () => {
     const entries = await readdir(ctx.standaloneDir)
@@ -325,10 +223,6 @@ export const copyNextDependencies = async (ctx: PluginContext): Promise =>
 
     const serverHandlerRequire = createRequire(posixJoin(ctx.serverHandlerDir, ':internal:'))
 
-    if (ctx.nextVersion) {
-      await patchNextModules(ctx, ctx.nextVersion, serverHandlerRequire.resolve)
-    }
-
     // detect if it might lead to a runtime issue and throw an error upfront on build time instead of silently failing during runtime
     try {
       const nextEntryAbsolutePath = serverHandlerRequire.resolve('next')
diff --git a/tests/integration/simple-app.test.ts b/tests/integration/simple-app.test.ts
index d49dcc9e3d..e2a6afe480 100644
--- a/tests/integration/simple-app.test.ts
+++ b/tests/integration/simple-app.test.ts
@@ -19,7 +19,6 @@ import {
   test,
   vi,
 } from 'vitest'
-import { getPatchesToApply } from '../../src/build/content/server.js'
 import { type FixtureTestContext } from '../utils/contexts.js'
 import {
   createFixture,
@@ -434,78 +433,3 @@ test('can require CJS module that is not bundled', async (ct
   expect(parsedBody.notBundledCJSModule.isBundled).toEqual(false)
   expect(parsedBody.bundledCJSModule.isBundled).toEqual(true)
 })
-
-describe('next patching', async () => {
-  const { cp: originalCp, appendFile } = (await vi.importActual(
-    'node:fs/promises',
-  )) as typeof import('node:fs/promises')
-
-  const { version: nextVersion } = createRequire(
-    `${getFixtureSourceDirectory('simple')}/:internal:`,
-  )('next/package.json')
-
-  beforeAll(() => {
-    process.env.NETLIFY_NEXT_FORCE_APPLY_ONGOING_PATCHES = 'true'
-  })
-
-  afterAll(() => {
-    delete process.env.NETLIFY_NEXT_FORCE_APPLY_ONGOING_PATCHES
-  })
-
-  beforeEach(() => {
-    mockedCp.mockClear()
-    mockedCp.mockRestore()
-  })
-
-  test(`expected patches are applied and used (next version: "${nextVersion}")`, async (ctx) => {
-    const patches = getPatchesToApply(nextVersion)
-
-    await createFixture('simple', ctx)
-
-    const fieldNamePrefix = `TEST_${Date.now()}`
-
-    mockedCp.mockImplementation(async (...args) => {
-      const returnValue = await originalCp(...args)
-      if (typeof args[1] === 'string') {
-        for (const patch of patches) {
-          if (args[1].includes(join(patch.nextModule))) {
-            // we append something to assert that patch file was actually used
-            await appendFile(
-              args[1],
-              `;globalThis['${fieldNamePrefix}_${patch.nextModule}'] = 'patched'`,
-            )
-          }
-        }
-      }
-
-      return returnValue
-    })
-
-    await runPlugin(ctx)
-
-    // patched files was not used before function invocation
-    for (const patch of patches) {
-      expect(globalThis[`${fieldNamePrefix}_${patch.nextModule}`]).not.toBeDefined()
-    }
-
-    const home = await invokeFunction(ctx)
-    // make sure the function does work
-    expect(home.statusCode).toBe(200)
-    expect(load(home.body)('h1').text()).toBe('Home')
-
-    let shouldUpdateUpperBoundMessage = ''
-
-    // file was used during function invocation
-    for (const patch of patches) {
-      expect(globalThis[`${fieldNamePrefix}_${patch.nextModule}`]).toBe('patched')
-
-      if (patch.ongoing && !prerelease(nextVersion) && gt(nextVersion, patch.maxStableVersion)) {
-        shouldUpdateUpperBoundMessage += `Ongoing ${shouldUpdateUpperBoundMessage ? '\n' : ''}"${patch.nextModule}" patch still works on "${nextVersion}" which is higher than currently set maxStableVersion ("${patch.maxStableVersion}"). Update maxStableVersion in "src/build/content/server.ts" for this patch to at least "${nextVersion}".`
-      }
-    }
-
-    if (shouldUpdateUpperBoundMessage) {
-      expect.fail(shouldUpdateUpperBoundMessage)
-    }
-  })
-})

From 5215cf88db5f5f39c0c385346de4e9a637cd6422 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Thu, 25 Sep 2025 13:35:38 +0200
Subject: [PATCH 36/39] static assets trailing slashes, public and some
 constants moving

---
 src/adapter/adapter.ts       |  3 +--
 src/adapter/constants.ts     |  4 ++++
 src/adapter/middleware.ts    |  3 +--
 src/adapter/static-assets.ts | 21 +++++++++++++++++++--
 4 files changed, 25 insertions(+), 6 deletions(-)

diff --git a/src/adapter/adapter.ts b/src/adapter/adapter.ts
index ea7e68aeac..231427c6a8 100644
--- a/src/adapter/adapter.ts
+++ b/src/adapter/adapter.ts
@@ -3,6 +3,7 @@ import { dirname } from 'node:path'
 
 import type { NextAdapter } from 'next-with-adapters'
 
+import { NETLIFY_FRAMEWORKS_API_CONFIG_PATH } from './constants.js'
 import { onBuildComplete as onBuildCompleteForHeaders } from './header.js'
 import {
   modifyConfig as modifyConfigForImageCDN,
@@ -12,8 +13,6 @@ import { onBuildComplete as onBuildCompleteForMiddleware } from './middleware.js
 import { onBuildComplete as onBuildCompleteForStaticAssets } from './static-assets.js'
 import { FrameworksAPIConfig } from './types.js'
 
-const NETLIFY_FRAMEWORKS_API_CONFIG_PATH = '.netlify/v1/config.json'
-
 const adapter: NextAdapter = {
   name: 'Netlify',
   modifyConfig(config) {
diff --git a/src/adapter/constants.ts b/src/adapter/constants.ts
index cf25b1e856..902652e417 100644
--- a/src/adapter/constants.ts
+++ b/src/adapter/constants.ts
@@ -8,3 +8,7 @@ export const PLUGIN_DIR = join(MODULE_DIR, '../..')
 const packageJSON = JSON.parse(readFileSync(join(PLUGIN_DIR, 'package.json'), 'utf-8'))
 
 export const GENERATOR = `${packageJSON.name}@${packageJSON.version}`
+
+export const NETLIFY_FRAMEWORKS_API_CONFIG_PATH = '.netlify/v1/config.json'
+export const NETLIFY_FRAMEWORKS_API_EDGE_FUNCTIONS = '.netlify/v1/edge-functions'
+export const NEXT_RUNTIME_STATIC_ASSETS = '.netlify/static'
diff --git a/src/adapter/middleware.ts b/src/adapter/middleware.ts
index 0f332985d0..7be15985c4 100644
--- a/src/adapter/middleware.ts
+++ b/src/adapter/middleware.ts
@@ -4,10 +4,9 @@ import { dirname, join, parse, relative } from 'node:path/posix'
 import { glob } from 'fast-glob'
 import { pathToRegexp } from 'path-to-regexp'
 
-import { GENERATOR, PLUGIN_DIR } from './constants.js'
+import { GENERATOR, NETLIFY_FRAMEWORKS_API_EDGE_FUNCTIONS, PLUGIN_DIR } from './constants.js'
 import type { FrameworksAPIConfig, NextConfigComplete, OnBuildCompleteContext } from './types.js'
 
-const NETLIFY_FRAMEWORKS_API_EDGE_FUNCTIONS = '.netlify/v1/edge-functions'
 const MIDDLEWARE_FUNCTION_NAME = 'middleware'
 
 const MIDDLEWARE_FUNCTION_DIR = join(
diff --git a/src/adapter/static-assets.ts b/src/adapter/static-assets.ts
index 04f49bf133..45b74ced15 100644
--- a/src/adapter/static-assets.ts
+++ b/src/adapter/static-assets.ts
@@ -1,6 +1,8 @@
+import { existsSync } from 'node:fs'
 import { cp } from 'node:fs/promises'
 import { extname, join } from 'node:path/posix'
 
+import { NEXT_RUNTIME_STATIC_ASSETS } from './constants.js'
 import type { FrameworksAPIConfig, OnBuildCompleteContext } from './types.js'
 
 export async function onBuildComplete(
@@ -13,11 +15,18 @@ export async function onBuildComplete(
     try {
       let distPathname = staticFile.pathname
       if (extname(distPathname) === '' && extname(staticFile.filePath) === '.html') {
+        // FEEDBACK: should this be applied in Next.js before passing to context to adapters?
+        if (ctx.config.trailingSlash && !distPathname.endsWith('/')) {
+          distPathname += '/'
+        } else if (!ctx.config.trailingSlash && distPathname.endsWith('/')) {
+          distPathname = distPathname.slice(0, -1)
+        }
+
         // if pathname is extension-less, but source file has an .html extension, preserve it
-        distPathname += '.html'
+        distPathname += distPathname.endsWith('/') ? 'index.html' : '.html'
       }
 
-      await cp(staticFile.filePath, join('./.netlify/static', distPathname), {
+      await cp(staticFile.filePath, join(NEXT_RUNTIME_STATIC_ASSETS, distPathname), {
         recursive: true,
       })
     } catch (error) {
@@ -27,5 +36,13 @@ export async function onBuildComplete(
     }
   }
 
+  // FEEDBACK: files in public directory are not in `outputs.staticFiles`
+  if (existsSync('public')) {
+    // copy all files from public directory to static assets
+    await cp('public', NEXT_RUNTIME_STATIC_ASSETS, {
+      recursive: true,
+    })
+  }
+
   return frameworksAPIConfig
 }

From b19d48e27b1eb8f739bd231e011258f30d872735 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Thu, 25 Sep 2025 13:39:56 +0200
Subject: [PATCH 37/39] fix lint

---
 src/build/content/server.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/build/content/server.ts b/src/build/content/server.ts
index 1ee14189e2..d1cdfe1419 100644
--- a/src/build/content/server.ts
+++ b/src/build/content/server.ts
@@ -18,7 +18,7 @@ import { wrapTracer } from '@opentelemetry/api/experimental'
 import glob from 'fast-glob'
 import type { MiddlewareManifest } from 'next/dist/build/webpack/plugins/middleware-plugin.js'
 import type { FunctionsConfigManifest } from 'next-with-cache-handler-v2/dist/build/index.js'
-import { prerelease, satisfies, lt as semverLowerThan, lte as semverLowerThanOrEqual } from 'semver'
+import { satisfies } from 'semver'
 
 import type { RunConfig } from '../../run/config.js'
 import { RUN_CONFIG_FILE } from '../../run/constants.js'

From cc9eaab07f8189508c8633f540aa024c78f7ea58 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Thu, 25 Sep 2025 15:45:17 +0200
Subject: [PATCH 38/39] workaround: create empty static json for fully static
 pages

---
 src/adapter/static-assets.ts | 27 +++++++++++++++++++++------
 1 file changed, 21 insertions(+), 6 deletions(-)

diff --git a/src/adapter/static-assets.ts b/src/adapter/static-assets.ts
index 45b74ced15..b94987b59d 100644
--- a/src/adapter/static-assets.ts
+++ b/src/adapter/static-assets.ts
@@ -1,6 +1,6 @@
 import { existsSync } from 'node:fs'
-import { cp } from 'node:fs/promises'
-import { extname, join } from 'node:path/posix'
+import { cp, mkdir, readFile, writeFile } from 'node:fs/promises'
+import { dirname, extname, join } from 'node:path/posix'
 
 import { NEXT_RUNTIME_STATIC_ASSETS } from './constants.js'
 import type { FrameworksAPIConfig, OnBuildCompleteContext } from './types.js'
@@ -15,11 +15,26 @@ export async function onBuildComplete(
     try {
       let distPathname = staticFile.pathname
       if (extname(distPathname) === '' && extname(staticFile.filePath) === '.html') {
+        // if it's fully static page, we need to also create empty _next/data JSON file
+        // on Vercel this is done in routing layer, but we can't express that routing right now on Netlify
+        const buildID = await readFile(join(ctx.distDir, 'BUILD_ID'), 'utf-8')
+        const dataFilePath = join(
+          NEXT_RUNTIME_STATIC_ASSETS,
+          '_next',
+          'data',
+          buildID,
+          `${distPathname === '/' ? 'index' : distPathname}.json`,
+        )
+        await mkdir(dirname(dataFilePath), { recursive: true })
+        await writeFile(dataFilePath, '{}')
+
         // FEEDBACK: should this be applied in Next.js before passing to context to adapters?
-        if (ctx.config.trailingSlash && !distPathname.endsWith('/')) {
-          distPathname += '/'
-        } else if (!ctx.config.trailingSlash && distPathname.endsWith('/')) {
-          distPathname = distPathname.slice(0, -1)
+        if (distPathname !== '/') {
+          if (ctx.config.trailingSlash && !distPathname.endsWith('/')) {
+            distPathname += '/'
+          } else if (!ctx.config.trailingSlash && distPathname.endsWith('/')) {
+            distPathname = distPathname.slice(0, -1)
+          }
         }
 
         // if pathname is extension-less, but source file has an .html extension, preserve it

From 8365f8cb46a1f4d157eddf1aeb3f5a001ba0afa4 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Thu, 25 Sep 2025 16:40:05 +0200
Subject: [PATCH 39/39] ignore some files

---
 src/adapter/middleware.ts    | 4 ++++
 src/adapter/static-assets.ts | 3 ++-
 2 files changed, 6 insertions(+), 1 deletion(-)

diff --git a/src/adapter/middleware.ts b/src/adapter/middleware.ts
index 7be15985c4..0ed10988a0 100644
--- a/src/adapter/middleware.ts
+++ b/src/adapter/middleware.ts
@@ -111,6 +111,10 @@ const copyHandlerDependenciesForNodeMiddleware = async (
         await handleFileOrDirectory(join(fileOrDir, fileInDir))
       }
     } else {
+      // avoid unnecessary files
+      if (fileOrDir.endsWith('.d.ts') || fileOrDir.endsWith('.js.map')) {
+        return
+      }
       const content = await readFile(fileOrDir, 'utf8')
 
       parts.push(
diff --git a/src/adapter/static-assets.ts b/src/adapter/static-assets.ts
index b94987b59d..4600a80a57 100644
--- a/src/adapter/static-assets.ts
+++ b/src/adapter/static-assets.ts
@@ -23,7 +23,8 @@ export async function onBuildComplete(
           '_next',
           'data',
           buildID,
-          `${distPathname === '/' ? 'index' : distPathname}.json`,
+          // eslint-disable-next-line unicorn/no-nested-ternary
+          `${distPathname === '/' ? 'index' : distPathname.endsWith('/') ? distPathname.slice(0, -1) : distPathname}.json`,
         )
         await mkdir(dirname(dataFilePath), { recursive: true })
         await writeFile(dataFilePath, '{}')