From 2afe782b029e6cef32a2aebda6258037907cde64 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Mon, 22 Sep 2025 17:41:28 +0200 Subject: [PATCH 01/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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, '{}')

From 20e65cc28cfcaff687820a28085de82cc56d3b1c Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Mon, 29 Sep 2025 09:21:20 +0200
Subject: [PATCH 40/75] move .vercel gitignore to the root

---
 .gitignore                              | 3 +++
 e2e-report/.gitignore                   | 3 ---
 tests/fixtures/turborepo-npm/.gitignore | 3 +--
 tests/fixtures/turborepo/.gitignore     | 3 +--
 tests/smoke/fixtures/.gitignore         | 3 +--
 5 files changed, 6 insertions(+), 9 deletions(-)

diff --git a/.gitignore b/.gitignore
index 527665ac4f..0b27e64142 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,3 +22,6 @@ tests/**/package-lock.json
 tests/**/pnpm-lock.yaml
 tests/**/yarn.lock
 tests/**/out/
+
+# Local Vercel folder
+.vercel
diff --git a/e2e-report/.gitignore b/e2e-report/.gitignore
index fd3dbb571a..547a13d306 100644
--- a/e2e-report/.gitignore
+++ b/e2e-report/.gitignore
@@ -28,9 +28,6 @@ yarn-error.log*
 # local env files
 .env*.local
 
-# vercel
-.vercel
-
 # typescript
 *.tsbuildinfo
 next-env.d.ts
diff --git a/tests/fixtures/turborepo-npm/.gitignore b/tests/fixtures/turborepo-npm/.gitignore
index e4a81e6932..5427c05fa4 100644
--- a/tests/fixtures/turborepo-npm/.gitignore
+++ b/tests/fixtures/turborepo-npm/.gitignore
@@ -11,8 +11,7 @@ coverage
 # Turbo
 .turbo
 
-# Vercel
-.vercel
+
 
 # Build Outputs
 .next/
diff --git a/tests/fixtures/turborepo/.gitignore b/tests/fixtures/turborepo/.gitignore
index 96fab4fed3..da943c4024 100644
--- a/tests/fixtures/turborepo/.gitignore
+++ b/tests/fixtures/turborepo/.gitignore
@@ -18,8 +18,7 @@ coverage
 # Turbo
 .turbo
 
-# Vercel
-.vercel
+
 
 # Build Outputs
 .next/
diff --git a/tests/smoke/fixtures/.gitignore b/tests/smoke/fixtures/.gitignore
index 602e78e1b1..720e4b4f23 100644
--- a/tests/smoke/fixtures/.gitignore
+++ b/tests/smoke/fixtures/.gitignore
@@ -19,8 +19,7 @@ coverage
 # Turbo
 .turbo
 
-# Vercel
-.vercel
+
 
 # Build Outputs
 .next/

From efe99015f1e88475ecaf2684c36bd5fafc19bb1a Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Mon, 29 Sep 2025 09:22:08 +0200
Subject: [PATCH 41/75] generate pages and app handler from adapters and not
 standalone

---
 src/adapter/adapter-handler.ts        |  75 +++++++++++++
 src/adapter/adapter.ts                |   5 +
 src/adapter/constants.ts              |   4 +
 src/adapter/middleware.ts             |  13 ++-
 src/adapter/pages-and-app-handlers.ts | 146 ++++++++++++++++++++++++++
 src/index.ts                          |   2 +-
 6 files changed, 240 insertions(+), 5 deletions(-)
 create mode 100644 src/adapter/adapter-handler.ts
 create mode 100644 src/adapter/pages-and-app-handlers.ts

diff --git a/src/adapter/adapter-handler.ts b/src/adapter/adapter-handler.ts
new file mode 100644
index 0000000000..9137652957
--- /dev/null
+++ b/src/adapter/adapter-handler.ts
@@ -0,0 +1,75 @@
+import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'node:http'
+
+import { ComputeJsOutgoingMessage, toComputeResponse, toReqRes } from '@fastly/http-compute-js'
+import type { Context } from '@netlify/functions'
+
+/**
+ * When Next.js proxies requests externally, it writes the response back as-is.
+ * In some cases, this includes Transfer-Encoding: chunked.
+ * This triggers behaviour in @fastly/http-compute-js to separate chunks with chunk delimiters, which is not what we want at this level.
+ * We want Lambda to control the behaviour around chunking, not this.
+ * This workaround removes the Transfer-Encoding header, which makes the library send the response as-is.
+ */
+const disableFaultyTransferEncodingHandling = (res: ComputeJsOutgoingMessage) => {
+  const originalStoreHeader = res._storeHeader
+  res._storeHeader = function _storeHeader(firstLine, headers) {
+    if (headers) {
+      if (Array.isArray(headers)) {
+        // eslint-disable-next-line no-param-reassign
+        headers = headers.filter(([header]) => header.toLowerCase() !== 'transfer-encoding')
+      } else {
+        delete (headers as OutgoingHttpHeaders)['transfer-encoding']
+      }
+    }
+
+    return originalStoreHeader.call(this, firstLine, headers)
+  }
+}
+
+type NextHandler = (
+  req: IncomingMessage,
+  res: ServerResponse,
+  ctx: {
+    waitUntil: (promise: Promise) => void
+  },
+) => Promise
+
+export async function runNextHandler(
+  request: Request,
+  context: Context,
+  nextHandler: NextHandler,
+): Promise {
+  const { req, res } = toReqRes(request)
+  // Work around a bug in http-proxy in next@<14.0.2
+  Object.defineProperty(req, 'connection', {
+    get() {
+      return {}
+    },
+  })
+  Object.defineProperty(req, 'socket', {
+    get() {
+      return {}
+    },
+  })
+
+  disableFaultyTransferEncodingHandling(res as unknown as ComputeJsOutgoingMessage)
+
+  nextHandler(req, res, {
+    waitUntil: context.waitUntil,
+  })
+    .then(() => {
+      console.log('handler done')
+    })
+    .catch((error) => {
+      console.error('handler error', error)
+    })
+    .finally(() => {
+      // Next.js relies on `close` event emitted by response to trigger running callback variant of `next/after`
+      // however @fastly/http-compute-js never actually emits that event - so we have to emit it ourselves,
+      // otherwise Next would never run the callback variant of `next/after`
+      res.emit('close')
+    })
+
+  const response = await toComputeResponse(res)
+  return response
+}
diff --git a/src/adapter/adapter.ts b/src/adapter/adapter.ts
index 231427c6a8..4374f69464 100644
--- a/src/adapter/adapter.ts
+++ b/src/adapter/adapter.ts
@@ -10,6 +10,7 @@ import {
   onBuildComplete as onBuildCompleteForImageCDN,
 } from './image-cdn.js'
 import { onBuildComplete as onBuildCompleteForMiddleware } from './middleware.js'
+import { onBuildComplete as onBuildCompleteForPagesAndAppHandlers } from './pages-and-app-handlers.js'
 import { onBuildComplete as onBuildCompleteForStaticAssets } from './static-assets.js'
 import { FrameworksAPIConfig } from './types.js'
 
@@ -45,6 +46,10 @@ const adapter: NextAdapter = {
     )
     // TODO: verifyNetlifyForms
     frameworksAPIConfig = onBuildCompleteForHeaders(nextAdapterContext, frameworksAPIConfig)
+    frameworksAPIConfig = await onBuildCompleteForPagesAndAppHandlers(
+      nextAdapterContext,
+      frameworksAPIConfig,
+    )
 
     if (frameworksAPIConfig) {
       // write out config if there is any
diff --git a/src/adapter/constants.ts b/src/adapter/constants.ts
index 902652e417..97c8388f72 100644
--- a/src/adapter/constants.ts
+++ b/src/adapter/constants.ts
@@ -11,4 +11,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 NETLIFY_FRAMEWORKS_API_FUNCTIONS = '.netlify/v1/functions'
 export const NEXT_RUNTIME_STATIC_ASSETS = '.netlify/static'
+
+export const DISPLAY_NAME_MIDDLEWARE = 'Next.js Middleware Handler'
+export const DISPLAY_NAME_PAGES_AND_APP = 'Next.js Pages and App Router Handler'
diff --git a/src/adapter/middleware.ts b/src/adapter/middleware.ts
index 0ed10988a0..45e01e5ff9 100644
--- a/src/adapter/middleware.ts
+++ b/src/adapter/middleware.ts
@@ -4,14 +4,19 @@ import { dirname, join, parse, relative } from 'node:path/posix'
 import { glob } from 'fast-glob'
 import { pathToRegexp } from 'path-to-regexp'
 
-import { GENERATOR, NETLIFY_FRAMEWORKS_API_EDGE_FUNCTIONS, PLUGIN_DIR } from './constants.js'
+import {
+  DISPLAY_NAME_MIDDLEWARE,
+  GENERATOR,
+  NETLIFY_FRAMEWORKS_API_EDGE_FUNCTIONS,
+  PLUGIN_DIR,
+} from './constants.js'
 import type { FrameworksAPIConfig, NextConfigComplete, OnBuildCompleteContext } from './types.js'
 
-const MIDDLEWARE_FUNCTION_NAME = 'middleware'
+const MIDDLEWARE_FUNCTION_INTERNAL_NAME = 'next_middleware'
 
 const MIDDLEWARE_FUNCTION_DIR = join(
   NETLIFY_FRAMEWORKS_API_EDGE_FUNCTIONS,
-  MIDDLEWARE_FUNCTION_NAME,
+  MIDDLEWARE_FUNCTION_INTERNAL_NAME,
 )
 
 export async function onBuildComplete(
@@ -199,7 +204,7 @@ const writeHandlerFile = async (
     export const config = ${JSON.stringify({
       cache: undefined,
       generator: GENERATOR,
-      name: 'Next.js Middleware Handler',
+      name: DISPLAY_NAME_MIDDLEWARE,
       pattern: augmentMatchers(middleware, nextConfig).map((matcher) => matcher.regexp),
     })}
     `,
diff --git a/src/adapter/pages-and-app-handlers.ts b/src/adapter/pages-and-app-handlers.ts
new file mode 100644
index 0000000000..356e8298d2
--- /dev/null
+++ b/src/adapter/pages-and-app-handlers.ts
@@ -0,0 +1,146 @@
+import { cp, mkdir, writeFile } from 'node:fs/promises'
+import { join, relative } from 'node:path/posix'
+
+import { glob } from 'fast-glob'
+
+import {
+  DISPLAY_NAME_PAGES_AND_APP,
+  GENERATOR,
+  NETLIFY_FRAMEWORKS_API_FUNCTIONS,
+  PLUGIN_DIR,
+} from './constants.js'
+import type { FrameworksAPIConfig, OnBuildCompleteContext } from './types.js'
+
+const PAGES_AND_APP_FUNCTION_INTERNAL_NAME = 'next_pages_and_app'
+
+const RUNTIME_DIR = '.netlify'
+
+const PAGES_AND_APP_FUNCTION_DIR = join(
+  NETLIFY_FRAMEWORKS_API_FUNCTIONS,
+  PAGES_AND_APP_FUNCTION_INTERNAL_NAME,
+)
+
+export async function onBuildComplete(
+  ctx: OnBuildCompleteContext,
+  frameworksAPIConfigArg: FrameworksAPIConfig,
+) {
+  const frameworksAPIConfig: FrameworksAPIConfig = frameworksAPIConfigArg ?? {}
+
+  const requiredFiles = new Set()
+  const pathnameToEntry: Record = {}
+
+  for (const outputs of [
+    ctx.outputs.pages,
+    ctx.outputs.pagesApi,
+    ctx.outputs.appPages,
+    ctx.outputs.appRoutes,
+  ]) {
+    if (outputs) {
+      for (const output of outputs) {
+        if (output.runtime === 'edge') {
+          // TODO: figure something out here
+          continue
+        }
+        for (const asset of Object.values(output.assets)) {
+          requiredFiles.add(asset)
+        }
+
+        requiredFiles.add(output.filePath)
+        pathnameToEntry[output.pathname] = relative(ctx.repoRoot, output.filePath)
+      }
+    }
+  }
+
+  await mkdir(PAGES_AND_APP_FUNCTION_DIR, { recursive: true })
+
+  for (const filePath of requiredFiles) {
+    await cp(filePath, join(PAGES_AND_APP_FUNCTION_DIR, relative(ctx.repoRoot, filePath)), {
+      recursive: true,
+    })
+  }
+
+  // copy needed runtime files
+
+  await copyRuntime(join(PAGES_AND_APP_FUNCTION_DIR, RUNTIME_DIR))
+
+  // generate needed runtime files
+  const entrypoint = /* javascript */ `
+  import { AsyncLocalStorage } from 'node:async_hooks'
+  import { createRequire } from 'node:module'
+  import { runNextHandler } from './${RUNTIME_DIR}/dist/adapter/adapter-handler.js'
+
+  globalThis.AsyncLocalStorage = AsyncLocalStorage
+
+  const require = createRequire(import.meta.url)
+
+  const pathnameToEntry = ${JSON.stringify(pathnameToEntry, null, 2)}
+
+  export default async function handler(request, context) {
+    const url = new URL(request.url)
+
+    const entry = pathnameToEntry[url.pathname]
+    if (!entry) {
+      return new Response('Not Found', { status: 404 })
+    }
+
+    const nextHandler = require('./' + entry).handler
+
+    return runNextHandler(request, context, nextHandler)
+  }
+
+  export const config = {
+    
+    path: ${JSON.stringify(Object.keys(pathnameToEntry), null, 2)},
+  }
+  `
+  await writeFile(
+    join(PAGES_AND_APP_FUNCTION_DIR, `${PAGES_AND_APP_FUNCTION_INTERNAL_NAME}.mjs`),
+    entrypoint,
+  )
+
+  // configuration
+  // TODO: ideally allow to set `includedFilesBasePath` via frameworks api config
+  // frameworksAPIConfig.functions ??= { '*': {} }
+  // frameworksAPIConfig.functions[PAGES_AND_APP_FUNCTION_INTERNAL_NAME] = {
+  //   node_bundler: 'none',
+  //   included_files: ['**'],
+  //   // we can't define includedFilesBasePath via Frameworks API
+  //   // included_files_base_path: PAGES_AND_APP_FUNCTION_DIR,
+  // }
+
+  // not using frameworks api because ... it doesn't allow to set includedFilesBasePath
+  await writeFile(
+    join(PAGES_AND_APP_FUNCTION_DIR, `${PAGES_AND_APP_FUNCTION_INTERNAL_NAME}.json`),
+    JSON.stringify(
+      {
+        config: {
+          name: DISPLAY_NAME_PAGES_AND_APP,
+          generator: GENERATOR,
+          node_bundler: 'none',
+          included_files: ['**'],
+          includedFilesBasePath: PAGES_AND_APP_FUNCTION_DIR,
+        },
+      },
+      null,
+      2,
+    ),
+  )
+
+  return frameworksAPIConfig
+}
+
+const copyRuntime = async (handlerDirectory: string): Promise => {
+  const files = await glob('dist/**/*', {
+    cwd: PLUGIN_DIR,
+    ignore: ['**/*.test.ts'],
+    dot: true,
+  })
+  await Promise.all(
+    files.map((path) =>
+      cp(join(PLUGIN_DIR, path), join(handlerDirectory, path), { recursive: true }),
+    ),
+  )
+  // We need to create a package.json file with type: module to make sure that the runtime modules
+  // are handled correctly as ESM modules
+  await writeFile(join(handlerDirectory, 'package.json'), JSON.stringify({ type: 'module' }))
+}
diff --git a/src/index.ts b/src/index.ts
index 804ea8a202..368dc0c47a 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -89,7 +89,7 @@ export const onBuild = async (options: NetlifyPluginOptions) => {
 
     await Promise.all([
       copyPrerenderedContent(ctx), // maybe this
-      createServerHandler(ctx), // not this while we use standalone
+      // createServerHandler(ctx), // not this while we use standalone
     ])
   })
 }

From ef37f11ca2aaba5db1e1966557c8deabd6a9d4d8 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Mon, 29 Sep 2025 09:51:38 +0200
Subject: [PATCH 42/75] refactor: separate build-time and run-time concerns for
 adapter

---
 src/adapter/adapter.ts                        | 15 ++++++------
 src/adapter/{ => build}/constants.ts          |  2 +-
 src/adapter/{ => build}/header.ts             |  4 ++--
 .../image-cdn-next-image-loader.cts}          |  0
 src/adapter/{ => build}/image-cdn.ts          |  8 ++++---
 src/adapter/{ => build}/middleware.ts         |  8 +++----
 .../{ => build}/pages-and-app-handlers.ts     | 24 +++++++++++--------
 src/adapter/{ => build}/static-assets.ts      | 10 ++++----
 src/adapter/{ => build}/types.ts              |  0
 .../pages-and-app-handler.ts}                 |  0
 10 files changed, 39 insertions(+), 32 deletions(-)
 rename src/adapter/{ => build}/constants.ts (93%)
 rename src/adapter/{ => build}/header.ts (88%)
 rename src/adapter/{next-image-loader.cts => build/image-cdn-next-image-loader.cts} (100%)
 rename src/adapter/{ => build}/image-cdn.ts (94%)
 rename src/adapter/{ => build}/middleware.ts (97%)
 rename src/adapter/{ => build}/pages-and-app-handlers.ts (88%)
 rename src/adapter/{ => build}/static-assets.ts (85%)
 rename src/adapter/{ => build}/types.ts (100%)
 rename src/adapter/{adapter-handler.ts => run/pages-and-app-handler.ts} (100%)

diff --git a/src/adapter/adapter.ts b/src/adapter/adapter.ts
index 4374f69464..74042bab2b 100644
--- a/src/adapter/adapter.ts
+++ b/src/adapter/adapter.ts
@@ -3,16 +3,17 @@ 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 { NETLIFY_FRAMEWORKS_API_CONFIG_PATH } from './build/constants.js'
+import { onBuildComplete as onBuildCompleteForHeaders } from './build/header.js'
 import {
   modifyConfig as modifyConfigForImageCDN,
   onBuildComplete as onBuildCompleteForImageCDN,
-} from './image-cdn.js'
-import { onBuildComplete as onBuildCompleteForMiddleware } from './middleware.js'
-import { onBuildComplete as onBuildCompleteForPagesAndAppHandlers } from './pages-and-app-handlers.js'
-import { onBuildComplete as onBuildCompleteForStaticAssets } from './static-assets.js'
-import { FrameworksAPIConfig } from './types.js'
+} from './build/image-cdn.js'
+import { onBuildComplete as onBuildCompleteForMiddleware } from './build/middleware.js'
+import { onBuildComplete as onBuildCompleteForPagesAndAppHandlers } from './build/pages-and-app-handlers.js'
+import { onBuildComplete as onBuildCompleteForStaticAssets } from './build/static-assets.js'
+import { NETLIFY_FRAMEWORKS_API_CONFIG_PATH } from './build/constants.js'
+import { FrameworksAPIConfig } from './build/types.js'
 
 const adapter: NextAdapter = {
   name: 'Netlify',
diff --git a/src/adapter/constants.ts b/src/adapter/build/constants.ts
similarity index 93%
rename from src/adapter/constants.ts
rename to src/adapter/build/constants.ts
index 97c8388f72..eb2f0e0415 100644
--- a/src/adapter/constants.ts
+++ b/src/adapter/build/constants.ts
@@ -3,7 +3,7 @@ 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, '../..')
+export const PLUGIN_DIR = join(MODULE_DIR, '../../..')
 
 const packageJSON = JSON.parse(readFileSync(join(PLUGIN_DIR, 'package.json'), 'utf-8'))
 
diff --git a/src/adapter/header.ts b/src/adapter/build/header.ts
similarity index 88%
rename from src/adapter/header.ts
rename to src/adapter/build/header.ts
index 3f58e41d62..876842cb1e 100644
--- a/src/adapter/header.ts
+++ b/src/adapter/build/header.ts
@@ -1,7 +1,7 @@
 import type { FrameworksAPIConfig, OnBuildCompleteContext } from './types.js'
 
 export function onBuildComplete(
-  ctx: OnBuildCompleteContext,
+  nextAdapterContext: OnBuildCompleteContext,
   frameworksAPIConfigArg: FrameworksAPIConfig,
 ) {
   const frameworksAPIConfig: FrameworksAPIConfig = frameworksAPIConfigArg ?? {}
@@ -9,7 +9,7 @@ export function onBuildComplete(
   frameworksAPIConfig.headers ??= []
 
   frameworksAPIConfig.headers.push({
-    for: `${ctx.config.basePath}/_next/static/*`,
+    for: `${nextAdapterContext.config.basePath}/_next/static/*`,
     values: {
       'Cache-Control': 'public, max-age=31536000, immutable',
     },
diff --git a/src/adapter/next-image-loader.cts b/src/adapter/build/image-cdn-next-image-loader.cts
similarity index 100%
rename from src/adapter/next-image-loader.cts
rename to src/adapter/build/image-cdn-next-image-loader.cts
diff --git a/src/adapter/image-cdn.ts b/src/adapter/build/image-cdn.ts
similarity index 94%
rename from src/adapter/image-cdn.ts
rename to src/adapter/build/image-cdn.ts
index ae3ec3d21f..3f39dc1dba 100644
--- a/src/adapter/image-cdn.ts
+++ b/src/adapter/build/image-cdn.ts
@@ -5,7 +5,9 @@ import { makeRe } from 'picomatch'
 
 import type { FrameworksAPIConfig, NextConfigComplete, OnBuildCompleteContext } from './types.js'
 
-const NETLIFY_IMAGE_LOADER_FILE = fileURLToPath(import.meta.resolve(`./next-image-loader.cjs`))
+const NETLIFY_IMAGE_LOADER_FILE = fileURLToPath(
+  import.meta.resolve(`./image-cdn-next-image-loader.cjs`),
+)
 
 export function modifyConfig(config: NextConfigComplete) {
   if (config.images.loader === 'default') {
@@ -21,7 +23,7 @@ function generateRegexFromPattern(pattern: string): string {
 }
 
 export function onBuildComplete(
-  ctx: OnBuildCompleteContext,
+  nextAdapterContext: OnBuildCompleteContext,
   frameworksAPIConfigArg: FrameworksAPIConfig,
 ) {
   const frameworksAPIConfig: FrameworksAPIConfig = frameworksAPIConfigArg ?? {}
@@ -38,7 +40,7 @@ export function onBuildComplete(
     status: 200,
   })
 
-  const { images } = ctx.config
+  const { images } = nextAdapterContext.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
diff --git a/src/adapter/middleware.ts b/src/adapter/build/middleware.ts
similarity index 97%
rename from src/adapter/middleware.ts
rename to src/adapter/build/middleware.ts
index 45e01e5ff9..07594e18d0 100644
--- a/src/adapter/middleware.ts
+++ b/src/adapter/build/middleware.ts
@@ -20,12 +20,12 @@ const MIDDLEWARE_FUNCTION_DIR = join(
 )
 
 export async function onBuildComplete(
-  ctx: OnBuildCompleteContext,
+  nextAdapterContext: OnBuildCompleteContext,
   frameworksAPIConfigArg: FrameworksAPIConfig,
 ) {
   const frameworksAPIConfig: FrameworksAPIConfig = frameworksAPIConfigArg ?? {}
 
-  const { middleware } = ctx.outputs
+  const { middleware } = nextAdapterContext.outputs
   if (!middleware) {
     return frameworksAPIConfig
   }
@@ -34,10 +34,10 @@ export async function onBuildComplete(
     await copyHandlerDependenciesForEdgeMiddleware(middleware)
   } else if (middleware.runtime === 'nodejs') {
     // return frameworksAPIConfig
-    await copyHandlerDependenciesForNodeMiddleware(middleware, ctx.repoRoot)
+    await copyHandlerDependenciesForNodeMiddleware(middleware, nextAdapterContext.repoRoot)
   }
 
-  await writeHandlerFile(middleware, ctx.config)
+  await writeHandlerFile(middleware, nextAdapterContext.config)
 
   return frameworksAPIConfig
 }
diff --git a/src/adapter/pages-and-app-handlers.ts b/src/adapter/build/pages-and-app-handlers.ts
similarity index 88%
rename from src/adapter/pages-and-app-handlers.ts
rename to src/adapter/build/pages-and-app-handlers.ts
index 356e8298d2..ae69ccc271 100644
--- a/src/adapter/pages-and-app-handlers.ts
+++ b/src/adapter/build/pages-and-app-handlers.ts
@@ -21,7 +21,7 @@ const PAGES_AND_APP_FUNCTION_DIR = join(
 )
 
 export async function onBuildComplete(
-  ctx: OnBuildCompleteContext,
+  nextAdapterContext: OnBuildCompleteContext,
   frameworksAPIConfigArg: FrameworksAPIConfig,
 ) {
   const frameworksAPIConfig: FrameworksAPIConfig = frameworksAPIConfigArg ?? {}
@@ -30,10 +30,10 @@ export async function onBuildComplete(
   const pathnameToEntry: Record = {}
 
   for (const outputs of [
-    ctx.outputs.pages,
-    ctx.outputs.pagesApi,
-    ctx.outputs.appPages,
-    ctx.outputs.appRoutes,
+    nextAdapterContext.outputs.pages,
+    nextAdapterContext.outputs.pagesApi,
+    nextAdapterContext.outputs.appPages,
+    nextAdapterContext.outputs.appRoutes,
   ]) {
     if (outputs) {
       for (const output of outputs) {
@@ -46,7 +46,7 @@ export async function onBuildComplete(
         }
 
         requiredFiles.add(output.filePath)
-        pathnameToEntry[output.pathname] = relative(ctx.repoRoot, output.filePath)
+        pathnameToEntry[output.pathname] = relative(nextAdapterContext.repoRoot, output.filePath)
       }
     }
   }
@@ -54,9 +54,13 @@ export async function onBuildComplete(
   await mkdir(PAGES_AND_APP_FUNCTION_DIR, { recursive: true })
 
   for (const filePath of requiredFiles) {
-    await cp(filePath, join(PAGES_AND_APP_FUNCTION_DIR, relative(ctx.repoRoot, filePath)), {
-      recursive: true,
-    })
+    await cp(
+      filePath,
+      join(PAGES_AND_APP_FUNCTION_DIR, relative(nextAdapterContext.repoRoot, filePath)),
+      {
+        recursive: true,
+      },
+    )
   }
 
   // copy needed runtime files
@@ -67,7 +71,7 @@ export async function onBuildComplete(
   const entrypoint = /* javascript */ `
   import { AsyncLocalStorage } from 'node:async_hooks'
   import { createRequire } from 'node:module'
-  import { runNextHandler } from './${RUNTIME_DIR}/dist/adapter/adapter-handler.js'
+  import { runNextHandler } from './${RUNTIME_DIR}/dist/adapter/run/pages-and-app-handler.js'
 
   globalThis.AsyncLocalStorage = AsyncLocalStorage
 
diff --git a/src/adapter/static-assets.ts b/src/adapter/build/static-assets.ts
similarity index 85%
rename from src/adapter/static-assets.ts
rename to src/adapter/build/static-assets.ts
index 4600a80a57..ecf988926e 100644
--- a/src/adapter/static-assets.ts
+++ b/src/adapter/build/static-assets.ts
@@ -6,18 +6,18 @@ import { NEXT_RUNTIME_STATIC_ASSETS } from './constants.js'
 import type { FrameworksAPIConfig, OnBuildCompleteContext } from './types.js'
 
 export async function onBuildComplete(
-  ctx: OnBuildCompleteContext,
+  nextAdapterContext: OnBuildCompleteContext,
   frameworksAPIConfigArg: FrameworksAPIConfig,
 ) {
   const frameworksAPIConfig: FrameworksAPIConfig = frameworksAPIConfigArg ?? {}
 
-  for (const staticFile of ctx.outputs.staticFiles) {
+  for (const staticFile of nextAdapterContext.outputs.staticFiles) {
     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 buildID = await readFile(join(nextAdapterContext.distDir, 'BUILD_ID'), 'utf-8')
         const dataFilePath = join(
           NEXT_RUNTIME_STATIC_ASSETS,
           '_next',
@@ -31,9 +31,9 @@ export async function onBuildComplete(
 
         // FEEDBACK: should this be applied in Next.js before passing to context to adapters?
         if (distPathname !== '/') {
-          if (ctx.config.trailingSlash && !distPathname.endsWith('/')) {
+          if (nextAdapterContext.config.trailingSlash && !distPathname.endsWith('/')) {
             distPathname += '/'
-          } else if (!ctx.config.trailingSlash && distPathname.endsWith('/')) {
+          } else if (!nextAdapterContext.config.trailingSlash && distPathname.endsWith('/')) {
             distPathname = distPathname.slice(0, -1)
           }
         }
diff --git a/src/adapter/types.ts b/src/adapter/build/types.ts
similarity index 100%
rename from src/adapter/types.ts
rename to src/adapter/build/types.ts
diff --git a/src/adapter/adapter-handler.ts b/src/adapter/run/pages-and-app-handler.ts
similarity index 100%
rename from src/adapter/adapter-handler.ts
rename to src/adapter/run/pages-and-app-handler.ts

From fb49bea41d4c15c8669eafe166013241f3181c0e Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Mon, 29 Sep 2025 13:17:15 +0200
Subject: [PATCH 43/75] introduce netlify adapter context for reusable helpers

---
 src/adapter/adapter.ts                       | 28 +++++++-------------
 src/adapter/build/header.ts                  | 15 +++++------
 src/adapter/build/image-cdn.ts               | 17 ++++++------
 src/adapter/build/middleware.ts              | 12 +++------
 src/adapter/build/netlify-adapter-context.ts | 19 +++++++++++++
 src/adapter/build/pages-and-app-handlers.ts  |  9 +++----
 src/adapter/build/static-assets.ts           | 13 +++------
 src/adapter/build/types.ts                   |  4 +++
 8 files changed, 57 insertions(+), 60 deletions(-)
 create mode 100644 src/adapter/build/netlify-adapter-context.ts

diff --git a/src/adapter/adapter.ts b/src/adapter/adapter.ts
index 74042bab2b..75bbce8a43 100644
--- a/src/adapter/adapter.ts
+++ b/src/adapter/adapter.ts
@@ -10,10 +10,9 @@ import {
   onBuildComplete as onBuildCompleteForImageCDN,
 } from './build/image-cdn.js'
 import { onBuildComplete as onBuildCompleteForMiddleware } from './build/middleware.js'
+import { createNetlifyAdapterContext } from './build/netlify-adapter-context.js'
 import { onBuildComplete as onBuildCompleteForPagesAndAppHandlers } from './build/pages-and-app-handlers.js'
 import { onBuildComplete as onBuildCompleteForStaticAssets } from './build/static-assets.js'
-import { NETLIFY_FRAMEWORKS_API_CONFIG_PATH } from './build/constants.js'
-import { FrameworksAPIConfig } from './build/types.js'
 
 const adapter: NextAdapter = {
   name: 'Netlify',
@@ -34,30 +33,21 @@ const adapter: NextAdapter = {
 
     console.log('onBuildComplete hook called')
 
-    let frameworksAPIConfig: FrameworksAPIConfig = null
+    const netlifyAdapterContext = createNetlifyAdapterContext(nextAdapterContext)
 
-    frameworksAPIConfig = onBuildCompleteForImageCDN(nextAdapterContext, frameworksAPIConfig)
-    frameworksAPIConfig = await onBuildCompleteForMiddleware(
-      nextAdapterContext,
-      frameworksAPIConfig,
-    )
-    frameworksAPIConfig = await onBuildCompleteForStaticAssets(
-      nextAdapterContext,
-      frameworksAPIConfig,
-    )
+    await onBuildCompleteForImageCDN(nextAdapterContext, netlifyAdapterContext)
+    await onBuildCompleteForMiddleware(nextAdapterContext, netlifyAdapterContext)
+    await onBuildCompleteForStaticAssets(nextAdapterContext, netlifyAdapterContext)
     // TODO: verifyNetlifyForms
-    frameworksAPIConfig = onBuildCompleteForHeaders(nextAdapterContext, frameworksAPIConfig)
-    frameworksAPIConfig = await onBuildCompleteForPagesAndAppHandlers(
-      nextAdapterContext,
-      frameworksAPIConfig,
-    )
+    await onBuildCompleteForHeaders(nextAdapterContext, netlifyAdapterContext)
+    await onBuildCompleteForPagesAndAppHandlers(nextAdapterContext, netlifyAdapterContext)
 
-    if (frameworksAPIConfig) {
+    if (netlifyAdapterContext.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),
+        JSON.stringify(netlifyAdapterContext.frameworksAPIConfig, null, 2),
       )
     }
   },
diff --git a/src/adapter/build/header.ts b/src/adapter/build/header.ts
index 876842cb1e..6cd2cd0fae 100644
--- a/src/adapter/build/header.ts
+++ b/src/adapter/build/header.ts
@@ -1,14 +1,13 @@
-import type { FrameworksAPIConfig, OnBuildCompleteContext } from './types.js'
+import type { NetlifyAdapterContext, OnBuildCompleteContext } from './types.js'
 
-export function onBuildComplete(
+export async function onBuildComplete(
   nextAdapterContext: OnBuildCompleteContext,
-  frameworksAPIConfigArg: FrameworksAPIConfig,
+  netlifyAdapterContext: NetlifyAdapterContext,
 ) {
-  const frameworksAPIConfig: FrameworksAPIConfig = frameworksAPIConfigArg ?? {}
+  netlifyAdapterContext.frameworksAPIConfig ??= {}
+  netlifyAdapterContext.frameworksAPIConfig.headers ??= []
 
-  frameworksAPIConfig.headers ??= []
-
-  frameworksAPIConfig.headers.push({
+  netlifyAdapterContext.frameworksAPIConfig.headers.push({
     for: `${nextAdapterContext.config.basePath}/_next/static/*`,
     values: {
       'Cache-Control': 'public, max-age=31536000, immutable',
@@ -23,6 +22,4 @@ export function onBuildComplete(
   // }
   // 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/adapter/build/image-cdn.ts b/src/adapter/build/image-cdn.ts
index 3f39dc1dba..1c69c6123f 100644
--- a/src/adapter/build/image-cdn.ts
+++ b/src/adapter/build/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 { FrameworksAPIConfig, NextConfigComplete, OnBuildCompleteContext } from './types.js'
+import type { NetlifyAdapterContext, NextConfigComplete, OnBuildCompleteContext } from './types.js'
 
 const NETLIFY_IMAGE_LOADER_FILE = fileURLToPath(
   import.meta.resolve(`./image-cdn-next-image-loader.cjs`),
@@ -22,15 +22,15 @@ function generateRegexFromPattern(pattern: string): string {
   return makeRe(pattern).source
 }
 
-export function onBuildComplete(
+export async function onBuildComplete(
   nextAdapterContext: OnBuildCompleteContext,
-  frameworksAPIConfigArg: FrameworksAPIConfig,
+  netlifyAdapterContext: NetlifyAdapterContext,
 ) {
-  const frameworksAPIConfig: FrameworksAPIConfig = frameworksAPIConfigArg ?? {}
+  netlifyAdapterContext.frameworksAPIConfig ??= {}
 
   // when migrating from @netlify/plugin-nextjs@4 image redirect to ipx might be cached in the browser
-  frameworksAPIConfig.redirects ??= []
-  frameworksAPIConfig.redirects.push({
+  netlifyAdapterContext.frameworksAPIConfig.redirects ??= []
+  netlifyAdapterContext.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
@@ -100,9 +100,8 @@ export function onBuildComplete(
 
     if (remoteImageSources.length !== 0) {
       // https://docs.netlify.com/build/frameworks/frameworks-api/#images
-      frameworksAPIConfig.images ??= { remote_images: [] }
-      frameworksAPIConfig.images.remote_images = remoteImageSources
+      netlifyAdapterContext.frameworksAPIConfig.images ??= { remote_images: [] }
+      netlifyAdapterContext.frameworksAPIConfig.images.remote_images = remoteImageSources
     }
   }
-  return frameworksAPIConfig
 }
diff --git a/src/adapter/build/middleware.ts b/src/adapter/build/middleware.ts
index 07594e18d0..88ae3cfd92 100644
--- a/src/adapter/build/middleware.ts
+++ b/src/adapter/build/middleware.ts
@@ -10,7 +10,7 @@ import {
   NETLIFY_FRAMEWORKS_API_EDGE_FUNCTIONS,
   PLUGIN_DIR,
 } from './constants.js'
-import type { FrameworksAPIConfig, NextConfigComplete, OnBuildCompleteContext } from './types.js'
+import type { NetlifyAdapterContext, NextConfigComplete, OnBuildCompleteContext } from './types.js'
 
 const MIDDLEWARE_FUNCTION_INTERNAL_NAME = 'next_middleware'
 
@@ -21,25 +21,21 @@ const MIDDLEWARE_FUNCTION_DIR = join(
 
 export async function onBuildComplete(
   nextAdapterContext: OnBuildCompleteContext,
-  frameworksAPIConfigArg: FrameworksAPIConfig,
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars
+  _netlifyAdapterContext: NetlifyAdapterContext,
 ) {
-  const frameworksAPIConfig: FrameworksAPIConfig = frameworksAPIConfigArg ?? {}
-
   const { middleware } = nextAdapterContext.outputs
   if (!middleware) {
-    return frameworksAPIConfig
+    return
   }
 
   if (middleware.runtime === 'edge') {
     await copyHandlerDependenciesForEdgeMiddleware(middleware)
   } else if (middleware.runtime === 'nodejs') {
-    // return frameworksAPIConfig
     await copyHandlerDependenciesForNodeMiddleware(middleware, nextAdapterContext.repoRoot)
   }
 
   await writeHandlerFile(middleware, nextAdapterContext.config)
-
-  return frameworksAPIConfig
 }
 
 const copyHandlerDependenciesForEdgeMiddleware = async (
diff --git a/src/adapter/build/netlify-adapter-context.ts b/src/adapter/build/netlify-adapter-context.ts
new file mode 100644
index 0000000000..d46c11ea94
--- /dev/null
+++ b/src/adapter/build/netlify-adapter-context.ts
@@ -0,0 +1,19 @@
+import { readFile } from 'fs/promises'
+import { join } from 'path/posix'
+
+import type { FrameworksAPIConfig, OnBuildCompleteContext } from './types.js'
+
+export function createNetlifyAdapterContext(nextAdapterContext: OnBuildCompleteContext) {
+  let buildId: string | undefined
+  let frameworksAPIConfig: FrameworksAPIConfig | undefined
+
+  return {
+    async getBuildId() {
+      if (!buildId) {
+        buildId = await readFile(join(nextAdapterContext.distDir, 'BUILD_ID'), 'utf-8')
+      }
+      return buildId
+    },
+    frameworksAPIConfig,
+  }
+}
diff --git a/src/adapter/build/pages-and-app-handlers.ts b/src/adapter/build/pages-and-app-handlers.ts
index ae69ccc271..eb6d800714 100644
--- a/src/adapter/build/pages-and-app-handlers.ts
+++ b/src/adapter/build/pages-and-app-handlers.ts
@@ -9,7 +9,7 @@ import {
   NETLIFY_FRAMEWORKS_API_FUNCTIONS,
   PLUGIN_DIR,
 } from './constants.js'
-import type { FrameworksAPIConfig, OnBuildCompleteContext } from './types.js'
+import type { NetlifyAdapterContext, OnBuildCompleteContext } from './types.js'
 
 const PAGES_AND_APP_FUNCTION_INTERNAL_NAME = 'next_pages_and_app'
 
@@ -22,10 +22,9 @@ const PAGES_AND_APP_FUNCTION_DIR = join(
 
 export async function onBuildComplete(
   nextAdapterContext: OnBuildCompleteContext,
-  frameworksAPIConfigArg: FrameworksAPIConfig,
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars
+  _netlifyAdapterContext: NetlifyAdapterContext,
 ) {
-  const frameworksAPIConfig: FrameworksAPIConfig = frameworksAPIConfigArg ?? {}
-
   const requiredFiles = new Set()
   const pathnameToEntry: Record = {}
 
@@ -129,8 +128,6 @@ export async function onBuildComplete(
       2,
     ),
   )
-
-  return frameworksAPIConfig
 }
 
 const copyRuntime = async (handlerDirectory: string): Promise => {
diff --git a/src/adapter/build/static-assets.ts b/src/adapter/build/static-assets.ts
index ecf988926e..b4ad94e77a 100644
--- a/src/adapter/build/static-assets.ts
+++ b/src/adapter/build/static-assets.ts
@@ -1,28 +1,25 @@
 import { existsSync } from 'node:fs'
-import { cp, mkdir, readFile, writeFile } from 'node:fs/promises'
+import { cp, mkdir, 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'
+import type { NetlifyAdapterContext, OnBuildCompleteContext } from './types.js'
 
 export async function onBuildComplete(
   nextAdapterContext: OnBuildCompleteContext,
-  frameworksAPIConfigArg: FrameworksAPIConfig,
+  netlifyAdapterContext: NetlifyAdapterContext,
 ) {
-  const frameworksAPIConfig: FrameworksAPIConfig = frameworksAPIConfigArg ?? {}
-
   for (const staticFile of nextAdapterContext.outputs.staticFiles) {
     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(nextAdapterContext.distDir, 'BUILD_ID'), 'utf-8')
         const dataFilePath = join(
           NEXT_RUNTIME_STATIC_ASSETS,
           '_next',
           'data',
-          buildID,
+          await netlifyAdapterContext.getBuildId(),
           // eslint-disable-next-line unicorn/no-nested-ternary
           `${distPathname === '/' ? 'index' : distPathname.endsWith('/') ? distPathname.slice(0, -1) : distPathname}.json`,
         )
@@ -59,6 +56,4 @@ export async function onBuildComplete(
       recursive: true,
     })
   }
-
-  return frameworksAPIConfig
 }
diff --git a/src/adapter/build/types.ts b/src/adapter/build/types.ts
index 1cf42c517d..f27ad4b5a3 100644
--- a/src/adapter/build/types.ts
+++ b/src/adapter/build/types.ts
@@ -1,9 +1,13 @@
 import type { NetlifyConfig } from '@netlify/build'
 import type { NextAdapter } from 'next-with-adapters'
 
+import type { createNetlifyAdapterContext } from './netlify-adapter-context.js'
+
 export type OnBuildCompleteContext = Parameters['onBuildComplete']>[0]
 export type NextConfigComplete = OnBuildCompleteContext['config']
 
 export type FrameworksAPIConfig = Partial<
   Pick
 > | null
+
+export type NetlifyAdapterContext = ReturnType

From 7cdda4fcb21ce04f824e4d48cfd24acd22ae96f1 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Wed, 29 Oct 2025 19:03:30 +0100
Subject: [PATCH 44/75] br

---
 src/adapter/adapter.ts                       |   9 +-
 src/adapter/build/constants.ts               |   1 +
 src/adapter/build/middleware.ts              |   7 +-
 src/adapter/build/netlify-adapter-context.ts |   8 +-
 src/adapter/build/pages-and-app-handlers.ts  |  80 ++-
 src/adapter/build/routing.ts                 | 482 +++++++++++++++++++
 src/adapter/build/static-assets.ts           |   6 +
 src/adapter/run/routing.ts                   | 148 ++++++
 src/index.ts                                 |   6 +-
 9 files changed, 688 insertions(+), 59 deletions(-)
 create mode 100644 src/adapter/build/routing.ts
 create mode 100644 src/adapter/run/routing.ts

diff --git a/src/adapter/adapter.ts b/src/adapter/adapter.ts
index 75bbce8a43..7eb8655ccd 100644
--- a/src/adapter/adapter.ts
+++ b/src/adapter/adapter.ts
@@ -12,14 +12,16 @@ import {
 import { onBuildComplete as onBuildCompleteForMiddleware } from './build/middleware.js'
 import { createNetlifyAdapterContext } from './build/netlify-adapter-context.js'
 import { onBuildComplete as onBuildCompleteForPagesAndAppHandlers } from './build/pages-and-app-handlers.js'
+import { onBuildComplete as onBuildCompleteForRouting } from './build/routing.js'
 import { onBuildComplete as onBuildCompleteForStaticAssets } from './build/static-assets.js'
 
 const adapter: NextAdapter = {
   name: 'Netlify',
   modifyConfig(config) {
     if (config.output !== 'export') {
-      // Enable Next.js standalone mode at build time
-      config.output = 'standalone'
+      // If not export, make sure to not build standalone output as it will become useless
+      // @ts-expect-error types don't unsetting output to not use 'standalone'
+      config.output = undefined
     }
 
     modifyConfigForImageCDN(config)
@@ -31,8 +33,6 @@ const adapter: NextAdapter = {
     await writeFile('./onBuildComplete.json', JSON.stringify(nextAdapterContext, null, 2))
     // debugger
 
-    console.log('onBuildComplete hook called')
-
     const netlifyAdapterContext = createNetlifyAdapterContext(nextAdapterContext)
 
     await onBuildCompleteForImageCDN(nextAdapterContext, netlifyAdapterContext)
@@ -41,6 +41,7 @@ const adapter: NextAdapter = {
     // TODO: verifyNetlifyForms
     await onBuildCompleteForHeaders(nextAdapterContext, netlifyAdapterContext)
     await onBuildCompleteForPagesAndAppHandlers(nextAdapterContext, netlifyAdapterContext)
+    await onBuildCompleteForRouting(nextAdapterContext, netlifyAdapterContext)
 
     if (netlifyAdapterContext.frameworksAPIConfig) {
       // write out config if there is any
diff --git a/src/adapter/build/constants.ts b/src/adapter/build/constants.ts
index eb2f0e0415..8aa5efa518 100644
--- a/src/adapter/build/constants.ts
+++ b/src/adapter/build/constants.ts
@@ -15,4 +15,5 @@ export const NETLIFY_FRAMEWORKS_API_FUNCTIONS = '.netlify/v1/functions'
 export const NEXT_RUNTIME_STATIC_ASSETS = '.netlify/static'
 
 export const DISPLAY_NAME_MIDDLEWARE = 'Next.js Middleware Handler'
+export const DISPLAY_NAME_ROUTING = 'Next.js Routing Handler'
 export const DISPLAY_NAME_PAGES_AND_APP = 'Next.js Pages and App Router Handler'
diff --git a/src/adapter/build/middleware.ts b/src/adapter/build/middleware.ts
index 88ae3cfd92..56d885101f 100644
--- a/src/adapter/build/middleware.ts
+++ b/src/adapter/build/middleware.ts
@@ -21,8 +21,7 @@ const MIDDLEWARE_FUNCTION_DIR = join(
 
 export async function onBuildComplete(
   nextAdapterContext: OnBuildCompleteContext,
-  // eslint-disable-next-line @typescript-eslint/no-unused-vars
-  _netlifyAdapterContext: NetlifyAdapterContext,
+  netlifyAdapterContext: NetlifyAdapterContext,
 ) {
   const { middleware } = nextAdapterContext.outputs
   if (!middleware) {
@@ -36,6 +35,8 @@ export async function onBuildComplete(
   }
 
   await writeHandlerFile(middleware, nextAdapterContext.config)
+
+  netlifyAdapterContext.preparedOutputs.middleware = true
 }
 
 const copyHandlerDependenciesForEdgeMiddleware = async (
@@ -186,7 +187,7 @@ const writeHandlerFile = async (
   // compatibility layer mentioned above.
   await writeFile(
     join(MIDDLEWARE_FUNCTION_DIR, `middleware.js`),
-    `
+    /* javascript */ `
     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';
diff --git a/src/adapter/build/netlify-adapter-context.ts b/src/adapter/build/netlify-adapter-context.ts
index d46c11ea94..3cdbeadd8e 100644
--- a/src/adapter/build/netlify-adapter-context.ts
+++ b/src/adapter/build/netlify-adapter-context.ts
@@ -5,7 +5,6 @@ import type { FrameworksAPIConfig, OnBuildCompleteContext } from './types.js'
 
 export function createNetlifyAdapterContext(nextAdapterContext: OnBuildCompleteContext) {
   let buildId: string | undefined
-  let frameworksAPIConfig: FrameworksAPIConfig | undefined
 
   return {
     async getBuildId() {
@@ -14,6 +13,11 @@ export function createNetlifyAdapterContext(nextAdapterContext: OnBuildCompleteC
       }
       return buildId
     },
-    frameworksAPIConfig,
+    frameworksAPIConfig: undefined as FrameworksAPIConfig | undefined,
+    preparedOutputs: {
+      staticAssets: [] as string[],
+      endpoints: [] as string[],
+      middleware: false,
+    },
   }
 }
diff --git a/src/adapter/build/pages-and-app-handlers.ts b/src/adapter/build/pages-and-app-handlers.ts
index eb6d800714..94cf7071ee 100644
--- a/src/adapter/build/pages-and-app-handlers.ts
+++ b/src/adapter/build/pages-and-app-handlers.ts
@@ -22,8 +22,7 @@ const PAGES_AND_APP_FUNCTION_DIR = join(
 
 export async function onBuildComplete(
   nextAdapterContext: OnBuildCompleteContext,
-  // eslint-disable-next-line @typescript-eslint/no-unused-vars
-  _netlifyAdapterContext: NetlifyAdapterContext,
+  netlifyAdapterContext: NetlifyAdapterContext,
 ) {
   const requiredFiles = new Set()
   const pathnameToEntry: Record = {}
@@ -66,35 +65,37 @@ export async function onBuildComplete(
 
   await copyRuntime(join(PAGES_AND_APP_FUNCTION_DIR, RUNTIME_DIR))
 
+  const functionsPaths = Object.keys(pathnameToEntry)
+
   // generate needed runtime files
   const entrypoint = /* javascript */ `
-  import { AsyncLocalStorage } from 'node:async_hooks'
-  import { createRequire } from 'node:module'
-  import { runNextHandler } from './${RUNTIME_DIR}/dist/adapter/run/pages-and-app-handler.js'
+    import { AsyncLocalStorage } from 'node:async_hooks'
+    import { createRequire } from 'node:module'
+    import { runNextHandler } from './${RUNTIME_DIR}/dist/adapter/run/pages-and-app-handler.js'
 
-  globalThis.AsyncLocalStorage = AsyncLocalStorage
+    globalThis.AsyncLocalStorage = AsyncLocalStorage
 
-  const require = createRequire(import.meta.url)
+    const require = createRequire(import.meta.url)
 
-  const pathnameToEntry = ${JSON.stringify(pathnameToEntry, null, 2)}
+    const pathnameToEntry = ${JSON.stringify(pathnameToEntry, null, 2)}
 
-  export default async function handler(request, context) {
-    const url = new URL(request.url)
+    export default async function handler(request, context) {
+      const url = new URL(request.url)
 
-    const entry = pathnameToEntry[url.pathname]
-    if (!entry) {
-      return new Response('Not Found', { status: 404 })
-    }
+      const entry = pathnameToEntry[url.pathname]
+      if (!entry) {
+        return new Response('Not Found', { status: 404 })
+      }
 
-    const nextHandler = require('./' + entry).handler
+      const nextHandler = require('./' + entry).handler
 
-    return runNextHandler(request, context, nextHandler)
-  }
+      return runNextHandler(request, context, nextHandler)
+    }
 
-  export const config = {
-    
-    path: ${JSON.stringify(Object.keys(pathnameToEntry), null, 2)},
-  }
+    export const config = {
+      
+      path: ${JSON.stringify(functionsPaths, null, 2)},
+    }
   `
   await writeFile(
     join(PAGES_AND_APP_FUNCTION_DIR, `${PAGES_AND_APP_FUNCTION_INTERNAL_NAME}.mjs`),
@@ -102,32 +103,17 @@ export async function onBuildComplete(
   )
 
   // configuration
-  // TODO: ideally allow to set `includedFilesBasePath` via frameworks api config
-  // frameworksAPIConfig.functions ??= { '*': {} }
-  // frameworksAPIConfig.functions[PAGES_AND_APP_FUNCTION_INTERNAL_NAME] = {
-  //   node_bundler: 'none',
-  //   included_files: ['**'],
-  //   // we can't define includedFilesBasePath via Frameworks API
-  //   // included_files_base_path: PAGES_AND_APP_FUNCTION_DIR,
-  // }
-
-  // not using frameworks api because ... it doesn't allow to set includedFilesBasePath
-  await writeFile(
-    join(PAGES_AND_APP_FUNCTION_DIR, `${PAGES_AND_APP_FUNCTION_INTERNAL_NAME}.json`),
-    JSON.stringify(
-      {
-        config: {
-          name: DISPLAY_NAME_PAGES_AND_APP,
-          generator: GENERATOR,
-          node_bundler: 'none',
-          included_files: ['**'],
-          includedFilesBasePath: PAGES_AND_APP_FUNCTION_DIR,
-        },
-      },
-      null,
-      2,
-    ),
-  )
+  netlifyAdapterContext.frameworksAPIConfig ??= {}
+  netlifyAdapterContext.frameworksAPIConfig.functions ??= { '*': {} }
+  netlifyAdapterContext.frameworksAPIConfig.functions[PAGES_AND_APP_FUNCTION_INTERNAL_NAME] = {
+    node_bundler: 'none',
+    included_files: ['**'],
+    // TODO(pieh): allow to set `includedFilesBasePath` via frameworks api config
+    // @ts-expect-error  we can't define includedFilesBasePath via Frameworks API, this only works because of local monkey patching of CLI
+    included_files_base_path: PAGES_AND_APP_FUNCTION_DIR,
+  }
+
+  netlifyAdapterContext.preparedOutputs.endpoints.push(...functionsPaths)
 }
 
 const copyRuntime = async (handlerDirectory: string): Promise => {
diff --git a/src/adapter/build/routing.ts b/src/adapter/build/routing.ts
new file mode 100644
index 0000000000..d1918b00f8
--- /dev/null
+++ b/src/adapter/build/routing.ts
@@ -0,0 +1,482 @@
+import { cp, writeFile } from 'node:fs/promises'
+import { join } from 'node:path/posix'
+import { format as formatUrl, parse as parseUrl } from 'node:url'
+
+import { glob } from 'fast-glob'
+import {
+  pathToRegexp,
+  compile as pathToRegexpCompile,
+  type Key as PathToRegexpKey,
+} from 'path-to-regexp'
+
+import type { RoutingRule, RoutingRuleRedirect, RoutingRuleRewrite } from '../run/routing.js'
+
+import {
+  DISPLAY_NAME_ROUTING,
+  GENERATOR,
+  NETLIFY_FRAMEWORKS_API_EDGE_FUNCTIONS,
+  PLUGIN_DIR,
+} from './constants.js'
+import type { NetlifyAdapterContext, OnBuildCompleteContext } from './types.js'
+
+const UN_NAMED_SEGMENT = '__UN_NAMED_SEGMENT__'
+
+// https://github.com/vercel/vercel/blob/8beae7035bf0d3e5cfc1df337b83fbbe530c4d9b/packages/routing-utils/src/superstatic.ts#L273
+export function sourceToRegex(source: string) {
+  const keys: PathToRegexpKey[] = []
+  const regexp = pathToRegexp(source, keys, {
+    strict: true,
+    sensitive: true,
+    delimiter: '/',
+  })
+
+  return {
+    sourceRegexString: regexp.source,
+    segments: keys
+      .map((key) => key.name)
+      .map((keyName) => {
+        if (typeof keyName !== 'string') {
+          return UN_NAMED_SEGMENT
+        }
+        return keyName
+      }),
+  }
+}
+
+// https://github.com/vercel/vercel/blob/8beae7035bf0d3e5cfc1df337b83fbbe530c4d9b/packages/routing-utils/src/superstatic.ts#L345
+const escapeSegment = (str: string, segmentName: string) =>
+  str.replace(new RegExp(`:${segmentName}`, 'g'), `__esc_colon_${segmentName}`)
+
+// https://github.com/vercel/vercel/blob/8beae7035bf0d3e5cfc1df337b83fbbe530c4d9b/packages/routing-utils/src/superstatic.ts#L348
+const unescapeSegments = (str: string) => str.replace(/__esc_colon_/gi, ':')
+
+// https://github.com/vercel/vercel/blob/8beae7035bf0d3e5cfc1df337b83fbbe530c4d9b/packages/routing-utils/src/superstatic.ts#L464
+function safelyCompile(
+  val: string,
+  indexes: { [k: string]: string },
+  attemptDirectCompile?: boolean,
+): string {
+  let value = val
+  if (!value) {
+    return value
+  }
+
+  if (attemptDirectCompile) {
+    try {
+      // Attempt compiling normally with path-to-regexp first and fall back
+      // to safely compiling to handle edge cases if path-to-regexp compile
+      // fails
+      return pathToRegexpCompile(value, { validate: false })(indexes)
+    } catch {
+      // non-fatal, we continue to safely compile
+    }
+  }
+
+  for (const key of Object.keys(indexes)) {
+    if (value.includes(`:${key}`)) {
+      value = value
+        .replace(new RegExp(`:${key}\\*`, 'g'), `:${key}--ESCAPED_PARAM_ASTERISK`)
+        .replace(new RegExp(`:${key}\\?`, 'g'), `:${key}--ESCAPED_PARAM_QUESTION`)
+        .replace(new RegExp(`:${key}\\+`, 'g'), `:${key}--ESCAPED_PARAM_PLUS`)
+        .replace(new RegExp(`:${key}(?!\\w)`, 'g'), `--ESCAPED_PARAM_COLON${key}`)
+    }
+  }
+  value = value
+    // eslint-disable-next-line unicorn/better-regex
+    .replace(/(:|\*|\?|\+|\(|\)|\{|\})/g, '\\$1')
+    .replace(/--ESCAPED_PARAM_PLUS/g, '+')
+    .replace(/--ESCAPED_PARAM_COLON/g, ':')
+    .replace(/--ESCAPED_PARAM_QUESTION/g, '?')
+    .replace(/--ESCAPED_PARAM_ASTERISK/g, '*')
+
+  // the value needs to start with a forward-slash to be compiled
+  // correctly
+  return pathToRegexpCompile(`/${value}`, { validate: false })(indexes).slice(1)
+}
+
+// https://github.com/vercel/vercel/blob/8beae7035bf0d3e5cfc1df337b83fbbe530c4d9b/packages/routing-utils/src/superstatic.ts#L350
+export function destinationToReplacementString(destination: string, segments: string[]) {
+  // convert /path/:id/route to /path/$1/route
+  // convert /path/:id+ to /path/$1
+
+  let escapedDestination = destination
+
+  const indexes: { [k: string]: string } = {}
+
+  segments.forEach((name, index) => {
+    indexes[name] = `$${index + 1}`
+    escapedDestination = escapeSegment(escapedDestination, name)
+  })
+
+  const parsedDestination = parseUrl(escapedDestination, true)
+  delete (parsedDestination as any).href
+  delete (parsedDestination as any).path
+  delete (parsedDestination as any).search
+  delete (parsedDestination as any).host
+  let { pathname, ...rest } = parsedDestination
+  pathname = unescapeSegments(pathname || '')
+
+  const pathnameKeys: PathToRegexpKey[] = []
+
+  try {
+    pathToRegexp(pathname, pathnameKeys)
+  } catch {
+    // this is not fatal so don't error when failing to parse the
+    // params from the destination
+  }
+
+  pathname = safelyCompile(pathname, indexes, true)
+
+  const finalDestination = formatUrl({
+    ...rest,
+    // hostname,
+    pathname,
+    // query,
+    // hash,
+  })
+  // url.format() escapes the dollar sign but it must be preserved for now-proxy
+  return finalDestination.replace(/%24/g, '$')
+}
+
+export function convertRedirectToRoutingRule(
+  redirect: Pick<
+    OnBuildCompleteContext['routes']['redirects'][number],
+    'source' | 'destination' | 'priority'
+  >,
+  description?: string,
+): RoutingRuleRedirect {
+  const { sourceRegexString, segments } = sourceToRegex(redirect.source)
+
+  const convertedDestination = destinationToReplacementString(redirect.destination, segments)
+
+  return {
+    description,
+    match: {
+      path: sourceRegexString,
+    },
+    apply: {
+      type: 'redirect',
+      destination: convertedDestination,
+    },
+  } satisfies RoutingRuleRedirect
+}
+
+export async function generateRoutingRules(
+  nextAdapterContext: OnBuildCompleteContext,
+  netlifyAdapterContext: NetlifyAdapterContext,
+) {
+  const hasMiddleware = Boolean(nextAdapterContext.outputs.middleware)
+  const hasPages = nextAdapterContext.outputs.pages.length !== 0
+  const shouldDenormalizeJsonDataForMiddleware =
+    hasMiddleware && hasPages && nextAdapterContext.config.skipMiddlewareUrlNormalize
+
+  // group redirects by priority, as it impact ordering of routing rules
+  const priorityRedirects: RoutingRuleRedirect[] = []
+  const redirects: RoutingRuleRedirect[] = []
+  for (const redirect of nextAdapterContext.routes.redirects) {
+    if (redirect.priority) {
+      priorityRedirects.push(
+        convertRedirectToRoutingRule(
+          redirect,
+          `Priority redirect from ${redirect.source} to ${redirect.destination}`,
+        ),
+      )
+    } else {
+      redirects.push(
+        convertRedirectToRoutingRule(
+          redirect,
+          `Redirect from ${redirect.source} to ${redirect.destination}`,
+        ),
+      )
+    }
+  }
+
+  const normalizeNextData: RoutingRuleRewrite[] = [
+    {
+      description: 'Normalize _next/data',
+      match: {
+        path: `^${nextAdapterContext.config.basePath}/_next/data/${await netlifyAdapterContext.getBuildId()}/(.*)\\.json`,
+        has: [
+          {
+            type: 'header',
+            key: 'x-nextjs-data',
+          },
+        ],
+      },
+      apply: {
+        type: 'rewrite',
+        destination: `${nextAdapterContext.config.basePath}/$1${nextAdapterContext.config.trailingSlash ? '/' : ''}`,
+      },
+    },
+    {
+      description: 'Fix _next/data index normalization',
+      match: {
+        path: `^${nextAdapterContext.config.basePath}/index(?:/)?`,
+        has: [
+          {
+            type: 'header',
+            key: 'x-nextjs-data',
+          },
+        ],
+      },
+      apply: {
+        type: 'rewrite',
+        destination: `${nextAdapterContext.config.basePath}/`,
+      },
+    },
+  ]
+
+  const denormalizeNextData: RoutingRuleRewrite[] = [
+    {
+      description: 'Fix _next/data index denormalization',
+      match: {
+        path: `^${nextAdapterContext.config.basePath}/$`,
+        has: [
+          {
+            type: 'header',
+            key: 'x-nextjs-data',
+          },
+        ],
+      },
+      apply: {
+        type: 'rewrite',
+        destination: `${nextAdapterContext.config.basePath}/index`,
+      },
+    },
+    {
+      description: 'Denormalize _next/data',
+      match: {
+        path: `^${nextAdapterContext.config.basePath}/((?!_next/)(?:.*[^/]|.*))/?$`,
+        has: [
+          {
+            type: 'header',
+            key: 'x-nextjs-data',
+          },
+        ],
+      },
+      apply: {
+        type: 'rewrite',
+        destination: `${nextAdapterContext.config.basePath}/_next/data/${await netlifyAdapterContext.getBuildId()}/$1.json`,
+      },
+    },
+  ]
+
+  const routing: RoutingRule[] = [
+    // order inherited from
+    // - () https://github.com/nextjs/adapter-vercel/blob/5ffd14bcb6ac780d2179d9a76e9e83747915bef3/packages/adapter/src/index.ts#L169
+    // - https://github.com/vercel/vercel/blob/f0a9aaeef1390acbe25fb755aff0a0d4b04e4f13/packages/next/src/server-build.ts#L1971
+
+    // Desired routes order
+    // - Runtime headers
+    // - User headers and redirects
+    // - Runtime redirects
+    // - Runtime routes
+    // - Check filesystem, if nothing found continue
+    // - User rewrites
+    // - Builder rewrites
+
+    // priority redirects includes trailing slash redirect
+    ...priorityRedirects, // originally: ...convertedPriorityRedirects,
+
+    ...(hasPages ? normalizeNextData : []), // originally: // normalize _next/data if middleware + pages
+
+    // i18n prefixing routes
+
+    // ...convertedHeaders,
+
+    ...redirects, // originally: ...convertedRedirects,
+
+    // server actions name meta routes
+
+    ...(shouldDenormalizeJsonDataForMiddleware ? denormalizeNextData : []), // originally: // if skip middleware url normalize we denormalize _next/data if middleware + pages
+
+    ...(hasMiddleware
+      ? [
+          {
+            // originally: middleware route
+            description: 'Middleware',
+            match: { type: 'middleware' },
+          } as const,
+        ]
+      : []),
+
+    ...(shouldDenormalizeJsonDataForMiddleware ? normalizeNextData : []), // originally: // if skip middleware url normalize we normalize _next/data if middleware + pages
+
+    // ...convertedRewrites.beforeFiles,
+
+    // add 404 handling if /404 or locale variants are requested literally
+
+    // add 500 handling if /500 or locale variants are requested literally
+
+    ...(hasPages ? denormalizeNextData : []), // originally: // denormalize _next/data if middleware + pages
+
+    // segment prefetch request rewriting
+
+    // non-segment prefetch rsc request rewriting
+
+    // full rsc request rewriting
+
+    {
+      // originally: { handle: 'filesystem' },
+      description: 'Static assets or Functions',
+      match: { type: 'static-asset-or-function' },
+    },
+
+    // TODO(pieh): do we need this given our next/image url loader/generator?
+    // ensure the basePath prefixed _next/image is rewritten to the root
+    // _next/image path
+    // ...(config.basePath
+    //   ? [
+    //       {
+    //         src: path.posix.join('/', config.basePath, '_next/image/?'),
+    //         dest: '/_next/image',
+    //         check: true,
+    //       },
+    //     ]
+    //   : []),
+
+    ...(hasPages ? normalizeNextData : []), // originally: // normalize _next/data if middleware + pages
+
+    // normalize /index.rsc to just /
+
+    // ...convertedRewrites.afterFiles,
+
+    // ensure bad rewrites with /.rsc are fixed
+
+    {
+      // originally: { handle: 'resource' },
+      description: 'Image CDN',
+      match: { type: 'image-cdn' },
+    },
+
+    // ...convertedRewrites.fallback,
+
+    // make sure 404 page is used when a directory is matched without
+    // an index page
+    // { src: path.posix.join('/', config.basePath, '.*'), status: 404 },
+
+    // { handle: 'miss' },
+
+    // 404 to plain text file for _next/static
+
+    // if i18n is enabled attempt removing locale prefix to check public files
+
+    // rewrite segment prefetch to prefetch/rsc
+
+    // { handle: 'rewrite' },
+
+    // denormalize _next/data if middleware + pages
+
+    // apply _next/data routes (including static ones if middleware + pages)
+
+    // apply 404 if _next/data request since above should have matched
+    // and we don't want to match a catch-all dynamic route
+
+    // apply normal dynamic routes
+    // ...convertedDynamicRoutes,
+
+    // apply x-nextjs-matched-path header and __next_data_catchall rewrite
+    // if middleware + pages
+
+    // { handle: 'hit' },
+
+    // Before we handle static files we need to set proper caching headers
+    // {
+    //   // This ensures we only match known emitted-by-Next.js files and not
+    //   // user-emitted files which may be missing a hash in their filename.
+    //   src: path.posix.join(
+    //     '/',
+    //     config.basePath,
+    //     `_next/static/(?:[^/]+/pages|pages|chunks|runtime|css|image|media)/.+`,
+    //   ),
+    //   // Next.js assets contain a hash or entropy in their filenames, so they
+    //   // are guaranteed to be unique and cacheable indefinitely.
+    //   headers: {
+    //     'cache-control': `public,max-age=${MAX_AGE_ONE_YEAR},immutable`,
+    //   },
+    //   continue: true,
+    //   important: true,
+    // },
+    // {
+    //   src: path.posix.join('/', config.basePath, '/index(?:/)?'),
+    //   headers: {
+    //     'x-matched-path': '/',
+    //   },
+    //   continue: true,
+    //   important: true,
+    // },
+    // {
+    //   src: path.posix.join('/', config.basePath, `/((?!index$).*?)(?:/)?`),
+    //   headers: {
+    //     'x-matched-path': '/$1',
+    //   },
+    //   continue: true,
+    //   important: true,
+    // },
+
+    // { handle: 'error' },
+
+    // apply 404 output mapping
+
+    // apply 500 output mapping
+  ]
+
+  return routing
+}
+
+const ROUTING_FUNCTION_INTERNAL_NAME = 'next_routing'
+const ROUTING_FUNCTION_DIR = join(
+  NETLIFY_FRAMEWORKS_API_EDGE_FUNCTIONS,
+  ROUTING_FUNCTION_INTERNAL_NAME,
+)
+
+export async function onBuildComplete(
+  nextAdapterContext: OnBuildCompleteContext,
+  netlifyAdapterContext: NetlifyAdapterContext,
+) {
+  const routing = await generateRoutingRules(nextAdapterContext, netlifyAdapterContext)
+
+  // for dev/debugging purposes only
+  await writeFile('./routes.json', JSON.stringify(routing, null, 2))
+  await writeFile(
+    './prepared-outputs.json',
+    JSON.stringify(netlifyAdapterContext.preparedOutputs, null, 2),
+  )
+
+  await copyRuntime(ROUTING_FUNCTION_DIR)
+
+  // TODO(pieh): middleware case would need to be split in 2 functions
+
+  const entrypoint = /* javascript */ `
+    import { runNextRouting } from "./dist/adapter/run/routing.js";
+
+    const routingRules = ${JSON.stringify(routing, null, 2)}
+    const outputs = ${JSON.stringify(netlifyAdapterContext.preparedOutputs, null, 2)}
+
+    export default async function handler(request, context) {
+      return runNextRouting(request, context, routingRules, outputs)
+    }
+
+    export const config = ${JSON.stringify({
+      cache: undefined,
+      generator: GENERATOR,
+      name: DISPLAY_NAME_ROUTING,
+      pattern: '.*',
+    })}
+  `
+
+  await writeFile(join(ROUTING_FUNCTION_DIR, `${ROUTING_FUNCTION_INTERNAL_NAME}.js`), entrypoint)
+}
+
+const copyRuntime = async (handlerDirectory: string): Promise => {
+  const files = await glob('dist/**/*', {
+    cwd: PLUGIN_DIR,
+    ignore: ['**/*.test.ts'],
+    dot: true,
+  })
+  await Promise.all(
+    files.map((path) =>
+      cp(join(PLUGIN_DIR, path), join(handlerDirectory, path), { recursive: true }),
+    ),
+  )
+}
diff --git a/src/adapter/build/static-assets.ts b/src/adapter/build/static-assets.ts
index b4ad94e77a..cb0ab55e57 100644
--- a/src/adapter/build/static-assets.ts
+++ b/src/adapter/build/static-assets.ts
@@ -35,8 +35,14 @@ export async function onBuildComplete(
           }
         }
 
+        // register static asset for routing before applying .html extension for pretty urls
+        netlifyAdapterContext.preparedOutputs.staticAssets.push(distPathname)
+
         // if pathname is extension-less, but source file has an .html extension, preserve it
         distPathname += distPathname.endsWith('/') ? 'index.html' : '.html'
+      } else {
+        // register static asset for routing
+        netlifyAdapterContext.preparedOutputs.staticAssets.push(distPathname)
       }
 
       await cp(staticFile.filePath, join(NEXT_RUNTIME_STATIC_ASSETS, distPathname), {
diff --git a/src/adapter/run/routing.ts b/src/adapter/run/routing.ts
new file mode 100644
index 0000000000..1a3dd713d5
--- /dev/null
+++ b/src/adapter/run/routing.ts
@@ -0,0 +1,148 @@
+import type { Context } from '@netlify/edge-functions'
+
+import type { NetlifyAdapterContext } from '../build/types.js'
+
+type RoutingRuleBase = {
+  /**
+   * Human readable description of the rule (for debugging purposes only)
+   */
+  description?: string
+}
+
+type Match = {
+  /** Regex */
+  path: string
+
+  /** additional conditions */
+  has?: {
+    type: 'header'
+    key: string
+  }[]
+}
+
+export type RoutingRuleRedirect = RoutingRuleBase & {
+  match: Match
+  apply: {
+    type: 'redirect'
+    /** Can use capture groups from match.path */
+    destination: string
+    /** Allowed redirect status code, defaults to 307 if not defined */
+    statusCode?: 301 | 302 | 307 | 308
+  }
+}
+
+export type RoutingRuleRewrite = RoutingRuleBase & {
+  match: Match
+  apply: {
+    type: 'rewrite'
+    /** Can use capture groups from match.path */
+    destination: string
+    /** Forced status code for response, if not defined rewrite response status code will be used */
+    statusCode?: 200 | 404 | 500
+  }
+}
+
+export type RoutingRuleMatchPrimitive = RoutingRuleBase & {
+  match: {
+    type: 'static-asset-or-function' | 'middleware' | 'image-cdn'
+  }
+}
+
+export type RoutingRule = RoutingRuleRedirect | RoutingRuleRewrite | RoutingRuleMatchPrimitive
+
+export function testRedirectRewriteRule(rule: RoutingRuleRedirect, request: Request) {
+  const sourceRegexp = new RegExp(rule.match.path)
+  const { pathname } = new URL(request.url)
+  if (sourceRegexp.test(pathname)) {
+    const replaced = pathname.replace(sourceRegexp, rule.apply.destination)
+    return { matched: true, replaced }
+  }
+  return { matched: false }
+}
+
+export async function runNextRouting(
+  request: Request,
+  context: Context,
+  routingRules: RoutingRule[],
+  outputs: NetlifyAdapterContext['preparedOutputs'],
+) {
+  if (request.headers.has('x-ntl-routing')) {
+    // don't route multiple times for same request
+    return
+  }
+
+  const prefix = `[${Date.now()}]`
+
+  console.log(prefix, 'Incoming request for routing:', request.url)
+
+  let currentRequest = new Request(request)
+  currentRequest.headers.set('x-ntl-routing', '1')
+  let maybeResponse: Response | undefined
+
+  for (const rule of routingRules) {
+    console.log(prefix, 'Evaluating rule:', rule.description ?? JSON.stringify(rule))
+    if ('match' in rule) {
+      const currentURL = new URL(currentRequest.url)
+      const { pathname } = currentURL
+
+      if ('type' in rule.match) {
+        if (rule.match.type === 'static-asset-or-function') {
+          let matchedType: 'static-asset' | 'function' | null = null
+          if (outputs.staticAssets.includes(pathname)) {
+            matchedType = 'static-asset'
+          } else if (outputs.endpoints.includes(pathname)) {
+            matchedType = 'function'
+          }
+
+          if (matchedType) {
+            console.log(prefix, 'Matched static asset:', pathname)
+            maybeResponse = await context.next(currentRequest)
+          }
+        } else if (rule.match.type === 'image-cdn' && pathname.startsWith('/.netlify/image/')) {
+          console.log(prefix, 'Matched image cdn:', pathname)
+
+          maybeResponse = await context.next(currentRequest)
+        }
+      } else if ('apply' in rule) {
+        const sourceRegexp = new RegExp(rule.match.path)
+        if (sourceRegexp.test(pathname)) {
+          // check additional conditions
+          if (rule.match.has) {
+            let hasAllMatch = true
+            for (const condition of rule.match.has) {
+              if (condition.type === 'header' && !currentRequest.headers.has(condition.key)) {
+                hasAllMatch = false
+                break
+              }
+            }
+
+            if (!hasAllMatch) {
+              continue
+            }
+          }
+
+          const replaced = pathname.replace(sourceRegexp, rule.apply.destination)
+
+          if (rule.apply.type === 'rewrite') {
+            console.log(prefix, `Rewriting ${pathname} to ${replaced}`)
+            const destURL = new URL(replaced, currentURL)
+            currentRequest = new Request(destURL, currentRequest)
+          } else {
+            console.log(prefix, `Redirecting ${pathname} to ${replaced}`)
+            maybeResponse = new Response(null, {
+              status: rule.apply.statusCode ?? 307,
+              headers: {
+                Location: replaced,
+              },
+            })
+          }
+        }
+      }
+    }
+
+    if (maybeResponse) {
+      console.log(prefix, 'Serving response', maybeResponse.status)
+      return maybeResponse
+    }
+  }
+}
diff --git a/src/index.ts b/src/index.ts
index 368dc0c47a..00820a5759 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -70,7 +70,7 @@ export const onBuild = async (options: NetlifyPluginOptions) => {
   await tracer.withActiveSpan('onBuild', async (span) => {
     const ctx = new PluginContext(options)
 
-    verifyPublishDir(ctx)
+    // verifyPublishDir(ctx)
 
     span.setAttribute('next.buildConfig', JSON.stringify(ctx.buildConfig))
 
@@ -84,8 +84,8 @@ export const onBuild = async (options: NetlifyPluginOptions) => {
       return Promise.all([copyStaticExport(ctx)])
     }
 
-    await verifyAdvancedAPIRoutes(ctx)
-    await verifyNetlifyFormsWorkaround(ctx)
+    // await verifyAdvancedAPIRoutes(ctx)
+    // await verifyNetlifyFormsWorkaround(ctx)
 
     await Promise.all([
       copyPrerenderedContent(ctx), // maybe this

From b62c819783613a9e3cb4a7a59042fb6a2a5e1435 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Wed, 29 Oct 2025 19:34:55 +0100
Subject: [PATCH 45/75] chore: bump next-with-adapters version to get up to
 date types

---
 package-lock.json | 2 +-
 package.json      | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index f46d79b6be..c8adaa752d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -43,7 +43,7 @@
         "msw": "^2.0.7",
         "netlify-cli": "23.9.5",
         "next": "^15.0.0-canary.28",
-        "next-with-adapters": "npm:next@15.6.0-canary.20",
+        "next-with-adapters": "npm:16.0.2-canary.0",
         "next-with-cache-handler-v2": "npm:next@15.3.0-canary.13",
         "next-with-cache-handler-v3": "npm:next@16.0.0-beta.0",
         "os": "^0.1.2",
diff --git a/package.json b/package.json
index 9ad2d4026b..3c0a2a3c70 100644
--- a/package.json
+++ b/package.json
@@ -88,7 +88,7 @@
     "next": "^15.0.0-canary.28",
     "next-with-cache-handler-v2": "npm:next@15.3.0-canary.13",
     "next-with-cache-handler-v3": "npm:next@16.0.0-beta.0",
-    "next-with-adapters": "npm:next@15.6.0-canary.20",
+    "next-with-adapters": "npm:16.0.2-canary.0",
     "os": "^0.1.2",
     "outdent": "^0.8.0",
     "p-limit": "^6.0.0",

From 1208aa4068e3c37376f80040fbf6e50e33e25b1b Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Wed, 29 Oct 2025 19:37:17 +0100
Subject: [PATCH 46/75] ci: disable integration tests as setup was not updated

---
 .github/workflows/run-tests.yml | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml
index 6984934426..7afbce0f2f 100644
--- a/.github/workflows/run-tests.yml
+++ b/.github/workflows/run-tests.yml
@@ -133,6 +133,8 @@ jobs:
 
   test:
     needs: setup
+    # integration tests are completely broken in adapters branch for now, so no point in running them currently
+    if: false
     strategy:
       fail-fast: false
       matrix:

From ccb1b9d5d8839bef76ca90af3d2cb50bbc33784c Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Thu, 30 Oct 2025 09:17:27 +0100
Subject: [PATCH 47/75] tmp: use locally patched cli

---
 package-lock.json                             | 401 +++++++++++++++++-
 package.json                                  |   4 +-
 patches/@netlify+build+35.2.10.patch          |  16 +
 .../netlify-cli++@netlify+build+35.2.7.patch  |  13 +
 src/adapter/build/pages-and-app-handlers.ts   |   3 +-
 tests/utils/create-e2e-fixture.ts             |  20 +-
 6 files changed, 435 insertions(+), 22 deletions(-)
 create mode 100644 patches/@netlify+build+35.2.10.patch
 create mode 100644 patches/netlify-cli++@netlify+build+35.2.7.patch

diff --git a/package-lock.json b/package-lock.json
index c8adaa752d..0775354cc3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,6 +7,7 @@
     "": {
       "name": "@netlify/plugin-nextjs",
       "version": "5.14.4",
+      "hasInstallScript": true,
       "license": "MIT",
       "devDependencies": {
         "@fastly/http-compute-js": "1.1.5",
@@ -49,6 +50,7 @@
         "os": "^0.1.2",
         "outdent": "^0.8.0",
         "p-limit": "^6.0.0",
+        "patch-package": "^8.0.1",
         "path-to-regexp": "^6.2.1",
         "picomatch": "^4.0.2",
         "prettier": "^3.2.5",
@@ -6881,6 +6883,13 @@
         "node": ">=18.0.0"
       }
     },
+    "node_modules/@yarnpkg/lockfile": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
+      "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
+      "dev": true,
+      "license": "BSD-2-Clause"
+    },
     "node_modules/abbrev": {
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz",
@@ -7653,16 +7662,16 @@
       }
     },
     "node_modules/call-bind": {
-      "version": "1.0.7",
-      "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
-      "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==",
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
+      "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
       "dev": true,
+      "license": "MIT",
       "dependencies": {
+        "call-bind-apply-helpers": "^1.0.0",
         "es-define-property": "^1.0.0",
-        "es-errors": "^1.3.0",
-        "function-bind": "^1.1.2",
         "get-intrinsic": "^1.2.4",
-        "set-function-length": "^1.2.1"
+        "set-function-length": "^1.2.2"
       },
       "engines": {
         "node": ">= 0.4"
@@ -11112,6 +11121,16 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/find-yarn-workspace-root": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
+      "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "micromatch": "^4.0.2"
+      }
+    },
     "node_modules/flat-cache": {
       "version": "3.1.1",
       "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.1.1.tgz",
@@ -12176,6 +12195,22 @@
         "url": "https://github.com/sponsors/wooorm"
       }
     },
+    "node_modules/is-docker": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
+      "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "is-docker": "cli.js"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
     "node_modules/is-error-instance": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/is-error-instance/-/is-error-instance-2.0.0.tgz",
@@ -12533,6 +12568,19 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/is-wsl": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
+      "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-docker": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/isarray": {
       "version": "2.0.5",
       "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
@@ -12659,6 +12707,26 @@
       "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
       "dev": true
     },
+    "node_modules/json-stable-stringify": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
+      "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.4",
+        "isarray": "^2.0.5",
+        "jsonify": "^0.0.1",
+        "object-keys": "^1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
     "node_modules/json-stable-stringify-without-jsonify": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
@@ -12689,6 +12757,16 @@
         "graceful-fs": "^4.1.6"
       }
     },
+    "node_modules/jsonify": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
+      "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
+      "dev": true,
+      "license": "Public Domain",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
     "node_modules/jsonparse": {
       "version": "1.3.1",
       "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz",
@@ -12786,6 +12864,16 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/klaw-sync": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
+      "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "graceful-fs": "^4.1.11"
+      }
+    },
     "node_modules/kuler": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz",
@@ -29886,6 +29974,23 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/open": {
+      "version": "7.4.2",
+      "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
+      "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-docker": "^2.0.0",
+        "is-wsl": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
     "node_modules/optionator": {
       "version": "0.9.3",
       "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz",
@@ -30266,6 +30371,127 @@
         "url": "https://github.com/inikulin/parse5?sponsor=1"
       }
     },
+    "node_modules/patch-package": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz",
+      "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@yarnpkg/lockfile": "^1.1.0",
+        "chalk": "^4.1.2",
+        "ci-info": "^3.7.0",
+        "cross-spawn": "^7.0.3",
+        "find-yarn-workspace-root": "^2.0.0",
+        "fs-extra": "^10.0.0",
+        "json-stable-stringify": "^1.0.2",
+        "klaw-sync": "^6.0.0",
+        "minimist": "^1.2.6",
+        "open": "^7.4.2",
+        "semver": "^7.5.3",
+        "slash": "^2.0.0",
+        "tmp": "^0.2.4",
+        "yaml": "^2.2.2"
+      },
+      "bin": {
+        "patch-package": "index.js"
+      },
+      "engines": {
+        "node": ">=14",
+        "npm": ">5"
+      }
+    },
+    "node_modules/patch-package/node_modules/ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "color-convert": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/patch-package/node_modules/chalk": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+      "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-styles": "^4.1.0",
+        "supports-color": "^7.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/chalk?sponsor=1"
+      }
+    },
+    "node_modules/patch-package/node_modules/color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "color-name": "~1.1.4"
+      },
+      "engines": {
+        "node": ">=7.0.0"
+      }
+    },
+    "node_modules/patch-package/node_modules/color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/patch-package/node_modules/fs-extra": {
+      "version": "10.1.0",
+      "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
+      "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "graceful-fs": "^4.2.0",
+        "jsonfile": "^6.0.1",
+        "universalify": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/patch-package/node_modules/slash": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
+      "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/patch-package/node_modules/supports-color": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/path-exists": {
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz",
@@ -38399,6 +38625,12 @@
         "tslib": "^2.6.3"
       }
     },
+    "@yarnpkg/lockfile": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
+      "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
+      "dev": true
+    },
     "abbrev": {
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz",
@@ -38957,16 +39189,15 @@
       "dev": true
     },
     "call-bind": {
-      "version": "1.0.7",
-      "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
-      "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==",
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
+      "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
       "dev": true,
       "requires": {
+        "call-bind-apply-helpers": "^1.0.0",
         "es-define-property": "^1.0.0",
-        "es-errors": "^1.3.0",
-        "function-bind": "^1.1.2",
         "get-intrinsic": "^1.2.4",
-        "set-function-length": "^1.2.1"
+        "set-function-length": "^1.2.2"
       }
     },
     "call-bind-apply-helpers": {
@@ -41441,6 +41672,15 @@
       "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==",
       "dev": true
     },
+    "find-yarn-workspace-root": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
+      "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
+      "dev": true,
+      "requires": {
+        "micromatch": "^4.0.2"
+      }
+    },
     "flat-cache": {
       "version": "3.1.1",
       "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.1.1.tgz",
@@ -42168,6 +42408,12 @@
       "integrity": "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==",
       "dev": true
     },
+    "is-docker": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
+      "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
+      "dev": true
+    },
     "is-error-instance": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/is-error-instance/-/is-error-instance-2.0.0.tgz",
@@ -42394,6 +42640,15 @@
         "get-intrinsic": "^1.1.1"
       }
     },
+    "is-wsl": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
+      "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
+      "dev": true,
+      "requires": {
+        "is-docker": "^2.0.0"
+      }
+    },
     "isarray": {
       "version": "2.0.5",
       "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
@@ -42503,6 +42758,19 @@
       "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
       "dev": true
     },
+    "json-stable-stringify": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
+      "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
+      "dev": true,
+      "requires": {
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.4",
+        "isarray": "^2.0.5",
+        "jsonify": "^0.0.1",
+        "object-keys": "^1.1.1"
+      }
+    },
     "json-stable-stringify-without-jsonify": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
@@ -42525,6 +42793,12 @@
         "universalify": "^2.0.0"
       }
     },
+    "jsonify": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
+      "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
+      "dev": true
+    },
     "jsonparse": {
       "version": "1.3.1",
       "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz",
@@ -42595,6 +42869,15 @@
       "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
       "dev": true
     },
+    "klaw-sync": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
+      "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
+      "dev": true,
+      "requires": {
+        "graceful-fs": "^4.1.11"
+      }
+    },
     "kuler": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz",
@@ -54169,6 +54452,16 @@
         "mimic-fn": "^4.0.0"
       }
     },
+    "open": {
+      "version": "7.4.2",
+      "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
+      "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
+      "dev": true,
+      "requires": {
+        "is-docker": "^2.0.0",
+        "is-wsl": "^2.1.1"
+      }
+    },
     "optionator": {
       "version": "0.9.3",
       "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz",
@@ -54429,6 +54722,90 @@
         "parse5": "^7.0.0"
       }
     },
+    "patch-package": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz",
+      "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==",
+      "dev": true,
+      "requires": {
+        "@yarnpkg/lockfile": "^1.1.0",
+        "chalk": "^4.1.2",
+        "ci-info": "^3.7.0",
+        "cross-spawn": "^7.0.3",
+        "find-yarn-workspace-root": "^2.0.0",
+        "fs-extra": "^10.0.0",
+        "json-stable-stringify": "^1.0.2",
+        "klaw-sync": "^6.0.0",
+        "minimist": "^1.2.6",
+        "open": "^7.4.2",
+        "semver": "^7.5.3",
+        "slash": "^2.0.0",
+        "tmp": "^0.2.4",
+        "yaml": "^2.2.2"
+      },
+      "dependencies": {
+        "ansi-styles": {
+          "version": "4.3.0",
+          "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+          "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+          "dev": true,
+          "requires": {
+            "color-convert": "^2.0.1"
+          }
+        },
+        "chalk": {
+          "version": "4.1.2",
+          "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+          "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+          "dev": true,
+          "requires": {
+            "ansi-styles": "^4.1.0",
+            "supports-color": "^7.1.0"
+          }
+        },
+        "color-convert": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+          "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+          "dev": true,
+          "requires": {
+            "color-name": "~1.1.4"
+          }
+        },
+        "color-name": {
+          "version": "1.1.4",
+          "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+          "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+          "dev": true
+        },
+        "fs-extra": {
+          "version": "10.1.0",
+          "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
+          "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
+          "dev": true,
+          "requires": {
+            "graceful-fs": "^4.2.0",
+            "jsonfile": "^6.0.1",
+            "universalify": "^2.0.0"
+          }
+        },
+        "slash": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
+          "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
+          "dev": true
+        },
+        "supports-color": {
+          "version": "7.2.0",
+          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+          "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+          "dev": true,
+          "requires": {
+            "has-flag": "^4.0.0"
+          }
+        }
+      }
+    },
     "path-exists": {
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz",
diff --git a/package.json b/package.json
index 3c0a2a3c70..6e6a6c1b87 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,7 @@
   },
   "scripts": {
     "prepack": "clean-package",
+    "postinstall": "patch-package",
     "postpack": "clean-package restore",
     "pretest": "npm run pretest:integration",
     "pretest:integration": "npm run build && node tests/prepare.mjs",
@@ -86,12 +87,13 @@
     "msw": "^2.0.7",
     "netlify-cli": "23.9.5",
     "next": "^15.0.0-canary.28",
+    "next-with-adapters": "npm:16.0.2-canary.0",
     "next-with-cache-handler-v2": "npm:next@15.3.0-canary.13",
     "next-with-cache-handler-v3": "npm:next@16.0.0-beta.0",
-    "next-with-adapters": "npm:16.0.2-canary.0",
     "os": "^0.1.2",
     "outdent": "^0.8.0",
     "p-limit": "^6.0.0",
+    "patch-package": "^8.0.1",
     "path-to-regexp": "^6.2.1",
     "picomatch": "^4.0.2",
     "prettier": "^3.2.5",
diff --git a/patches/@netlify+build+35.2.10.patch b/patches/@netlify+build+35.2.10.patch
new file mode 100644
index 0000000000..fc3e204e95
--- /dev/null
+++ b/patches/@netlify+build+35.2.10.patch
@@ -0,0 +1,16 @@
+diff --git a/node_modules/@netlify/build/lib/types/config/functions.d.ts b/node_modules/@netlify/build/lib/types/config/functions.d.ts
+index 273066d..e56e320 100644
+--- a/node_modules/@netlify/build/lib/types/config/functions.d.ts
++++ b/node_modules/@netlify/build/lib/types/config/functions.d.ts
+@@ -4,6 +4,11 @@ type FunctionsObject = {
+      * a list of additional paths to include in the function bundle. Although our build system includes statically referenced files (like `import * from "./some-file.js"`) by default, `included_files` lets you specify additional files or directories and reference them dynamically in function code. You can use `*` to match any character or prefix an entry with `!` to exclude files. Paths are relative to the [base directory](https://docs.netlify.com/configure-builds/get-started/#definitions-1).
+      */
+     included_files?: string[];
++
++    /**
++     * [patched] allow to set included files base
++     */
++    included_files_base_path?: string
+ } & ({
+     /**
+      * the function bundling method used in [`@netlify/zip-it-and-ship-it`](https://github.com/netlify/zip-it-and-ship-it).
diff --git a/patches/netlify-cli++@netlify+build+35.2.7.patch b/patches/netlify-cli++@netlify+build+35.2.7.patch
new file mode 100644
index 0000000000..5bc2cb615d
--- /dev/null
+++ b/patches/netlify-cli++@netlify+build+35.2.7.patch
@@ -0,0 +1,13 @@
+diff --git a/node_modules/netlify-cli/node_modules/@netlify/build/lib/plugins_core/functions/zisi.js b/node_modules/netlify-cli/node_modules/@netlify/build/lib/plugins_core/functions/zisi.js
+index 96cfa0b..8c9e154 100644
+--- a/node_modules/netlify-cli/node_modules/@netlify/build/lib/plugins_core/functions/zisi.js
++++ b/node_modules/netlify-cli/node_modules/@netlify/build/lib/plugins_core/functions/zisi.js
+@@ -45,7 +45,7 @@ export const normalizeFunctionConfig = ({ buildDir, functionConfig = {}, isRunni
+     externalNodeModules: functionConfig.external_node_modules,
+     includedFiles: functionConfig.included_files,
+     name: functionConfig.name,
+-    includedFilesBasePath: buildDir,
++    includedFilesBasePath: functionConfig.included_files_base_path ?? buildDir,
+     ignoredNodeModules: functionConfig.ignored_node_modules,
+     nodeVersion,
+     schedule: functionConfig.schedule,
diff --git a/src/adapter/build/pages-and-app-handlers.ts b/src/adapter/build/pages-and-app-handlers.ts
index 94cf7071ee..d4d5c4c0c7 100644
--- a/src/adapter/build/pages-and-app-handlers.ts
+++ b/src/adapter/build/pages-and-app-handlers.ts
@@ -108,8 +108,7 @@ export async function onBuildComplete(
   netlifyAdapterContext.frameworksAPIConfig.functions[PAGES_AND_APP_FUNCTION_INTERNAL_NAME] = {
     node_bundler: 'none',
     included_files: ['**'],
-    // TODO(pieh): allow to set `includedFilesBasePath` via frameworks api config
-    // @ts-expect-error  we can't define includedFilesBasePath via Frameworks API, this only works because of local monkey patching of CLI
+    // TODO(pieh): below only works due to local patches, need to ship proper support
     included_files_base_path: PAGES_AND_APP_FUNCTION_DIR,
   }
 
diff --git a/tests/utils/create-e2e-fixture.ts b/tests/utils/create-e2e-fixture.ts
index ce42372fe9..949d7e70c7 100644
--- a/tests/utils/create-e2e-fixture.ts
+++ b/tests/utils/create-e2e-fixture.ts
@@ -67,6 +67,10 @@ interface E2EConfig {
   env?: Record
 }
 
+function getNetlifyCLIExecutable() {
+  return `node ${fileURLToPath(new URL(`../../node_modules/.bin/netlify`, import.meta.url))}`
+}
+
 /**
  * Copies a fixture to a temp folder on the system and runs the tests inside.
  * @param fixture name of the folder inside the fixtures folder
@@ -271,7 +275,9 @@ async function installRuntime(
 
 async function verifyFixture(isolatedFixtureRoot: string, { expectedCliVersion }: E2EConfig) {
   if (expectedCliVersion) {
-    const { stdout } = await execaCommand('npx netlify --version', { cwd: isolatedFixtureRoot })
+    const { stdout } = await execaCommand(`${getNetlifyCLIExecutable()} --version`, {
+      cwd: isolatedFixtureRoot,
+    })
 
     const match = stdout.match(/netlify-cli\/(?\S+)/)
 
@@ -300,7 +306,7 @@ export async function deploySiteWithCLI(
   console.log(`🚀 Building and deploying site...`)
 
   const outputFile = 'deploy-output.txt'
-  let cmd = `npx netlify deploy --build --site ${siteId} --alias ${NETLIFY_DEPLOY_ALIAS}`
+  let cmd = `${getNetlifyCLIExecutable()} deploy --build --site ${siteId} --alias ${NETLIFY_DEPLOY_ALIAS}`
 
   if (packagePath) {
     cmd += ` --filter ${packagePath}`
@@ -382,7 +388,7 @@ export async function deploySiteWithBuildbot(
   // poll for status
   while (true) {
     const { stdout } = await execaCommand(
-      `npx netlify api getDeploy --data=${JSON.stringify({ deploy_id })}`,
+      `${getNetlifyCLIExecutable()} api getDeploy --data=${JSON.stringify({ deploy_id })}`,
     )
     const { state } = JSON.parse(stdout)
 
@@ -416,7 +422,7 @@ export async function deleteDeploy(deployID?: string): Promise {
     return
   }
 
-  const cmd = `npx netlify api deleteDeploy --data='{"deploy_id":"${deployID}"}'`
+  const cmd = `${getNetlifyCLIExecutable()} api deleteDeploy --data='{"deploy_id":"${deployID}"}'`
   // execa mangles around with the json so let's use exec here
   return new Promise((resolve, reject) => exec(cmd, (err) => (err ? reject(err) : resolve())))
 }
@@ -435,7 +441,7 @@ export function getBuildFixtureVariantCommand(variantName: string) {
 }
 
 export async function createSite(siteConfig?: { name: string }) {
-  const cmd = `npx netlify api createSiteInTeam --data=${JSON.stringify({
+  const cmd = `${getNetlifyCLIExecutable()} api createSiteInTeam --data=${JSON.stringify({
     account_slug: 'netlify-integration-testing',
     body: siteConfig ?? {},
   })}`
@@ -453,12 +459,12 @@ export async function createSite(siteConfig?: { name: string }) {
 }
 
 export async function deleteSite(siteId: string) {
-  const cmd = `npx netlify api deleteSite --data=${JSON.stringify({ site_id: siteId })}`
+  const cmd = `${getNetlifyCLIExecutable()} api deleteSite --data=${JSON.stringify({ site_id: siteId })}`
   await execaCommand(cmd)
 }
 
 export async function publishDeploy(siteId: string, deployID: string) {
-  const cmd = `npx netlify api restoreSiteDeploy --data=${JSON.stringify({ site_id: siteId, deploy_id: deployID })}`
+  const cmd = `${getNetlifyCLIExecutable()} api restoreSiteDeploy --data=${JSON.stringify({ site_id: siteId, deploy_id: deployID })}`
   await execaCommand(cmd)
 }
 

From 058278282d4b86bdcb23d6c0a31b96fb645fd6aa Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Thu, 30 Oct 2025 09:37:01 +0100
Subject: [PATCH 48/75] test: skip skew protection / buildbot tests

---
 tests/e2e/skew-protection.test.ts | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/tests/e2e/skew-protection.test.ts b/tests/e2e/skew-protection.test.ts
index dfec460313..d4d5bfc4ab 100644
--- a/tests/e2e/skew-protection.test.ts
+++ b/tests/e2e/skew-protection.test.ts
@@ -152,7 +152,8 @@ const test = baseTest.extend<
   ],
 })
 
-test.describe('Skew Protection', () => {
+// buildbot deploy not working due to cli-patching, skipping for now, until need to patching is removed
+test.describe.skip('Skew Protection', () => {
   test.describe('App Router', () => {
     test('should scope next/link navigation to initial deploy', async ({
       page,

From 07d3a82088af157c3f86dda8784aff56cd3c1ca7 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Thu, 30 Oct 2025 13:14:19 +0100
Subject: [PATCH 49/75] get more routes node output from prerenders

---
 src/adapter/build/pages-and-app-handlers.ts | 40 ++++++++++++++-------
 1 file changed, 28 insertions(+), 12 deletions(-)

diff --git a/src/adapter/build/pages-and-app-handlers.ts b/src/adapter/build/pages-and-app-handlers.ts
index d4d5c4c0c7..13643f9a9b 100644
--- a/src/adapter/build/pages-and-app-handlers.ts
+++ b/src/adapter/build/pages-and-app-handlers.ts
@@ -33,19 +33,35 @@ export async function onBuildComplete(
     nextAdapterContext.outputs.appPages,
     nextAdapterContext.outputs.appRoutes,
   ]) {
-    if (outputs) {
-      for (const output of outputs) {
-        if (output.runtime === 'edge') {
-          // TODO: figure something out here
-          continue
-        }
-        for (const asset of Object.values(output.assets)) {
-          requiredFiles.add(asset)
-        }
-
-        requiredFiles.add(output.filePath)
-        pathnameToEntry[output.pathname] = relative(nextAdapterContext.repoRoot, output.filePath)
+    for (const output of outputs) {
+      if (output.runtime === 'edge') {
+        // TODO: figure something out here
+        continue
       }
+      for (const asset of Object.values(output.assets)) {
+        requiredFiles.add(asset)
+      }
+
+      requiredFiles.add(output.filePath)
+      pathnameToEntry[output.pathname] = relative(nextAdapterContext.repoRoot, output.filePath)
+    }
+  }
+
+  for (const prerender of nextAdapterContext.outputs.prerenders) {
+    if (prerender.pathname in pathnameToEntry) {
+      console.log('Skipping prerender, already have route:', prerender.pathname)
+    } else if (prerender.parentOutputId in pathnameToEntry) {
+      // if we don't have routing for this route yet, add it
+      console.log('prerender mapping', {
+        from: prerender.pathname,
+        to: prerender.parentOutputId,
+      })
+      pathnameToEntry[prerender.pathname] = pathnameToEntry[prerender.parentOutputId]
+    } else {
+      console.warn('Could not find parent output for prerender:', {
+        pathname: prerender,
+        parentOutputId: prerender.parentOutputId,
+      })
     }
   }
 

From b029575ae70db871a91c81ef61e34a3aff504d37 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Thu, 30 Oct 2025 13:14:53 +0100
Subject: [PATCH 50/75] restore headers we use to set to try to produce output
 compatible for tests

---
 src/adapter/run/pages-and-app-handler.ts | 77 ++++++++++++++++++++++++
 1 file changed, 77 insertions(+)

diff --git a/src/adapter/run/pages-and-app-handler.ts b/src/adapter/run/pages-and-app-handler.ts
index 9137652957..9467ec85ac 100644
--- a/src/adapter/run/pages-and-app-handler.ts
+++ b/src/adapter/run/pages-and-app-handler.ts
@@ -26,6 +26,33 @@ const disableFaultyTransferEncodingHandling = (res: ComputeJsOutgoingMessage) =>
   }
 }
 
+const getHeaderValueArray = (header: string): string[] => {
+  return header
+    .split(',')
+    .map((value) => value.trim())
+    .filter(Boolean)
+}
+
+const omitHeaderValues = (header: string, values: string[]): string => {
+  const headerValues = getHeaderValueArray(header)
+  const filteredValues = headerValues.filter(
+    (value) => !values.some((val) => value.startsWith(val)),
+  )
+  return filteredValues.join(', ')
+}
+
+/**
+ * https://httpwg.org/specs/rfc9211.html
+ *
+ * We get HIT, MISS, STALE statuses from Next cache.
+ * We will ignore other statuses and will not set Cache-Status header in those cases.
+ */
+const NEXT_CACHE_TO_CACHE_STATUS: Record = {
+  HIT: `hit`,
+  MISS: `fwd=miss`,
+  STALE: `hit; fwd=stale`,
+}
+
 type NextHandler = (
   req: IncomingMessage,
   res: ServerResponse,
@@ -39,6 +66,11 @@ export async function runNextHandler(
   context: Context,
   nextHandler: NextHandler,
 ): Promise {
+  console.log('Handling request', {
+    url: request.url,
+    isDataRequest: request.headers.get('x-nextjs-data'),
+  })
+
   const { req, res } = toReqRes(request)
   // Work around a bug in http-proxy in next@<14.0.2
   Object.defineProperty(req, 'connection', {
@@ -71,5 +103,50 @@ export async function runNextHandler(
     })
 
   const response = await toComputeResponse(res)
+
+  {
+    // move cache-control to cdn-cache-control
+    const cacheControl = response.headers.get('cache-control')
+    if (
+      cacheControl &&
+      ['GET', 'HEAD'].includes(request.method) &&
+      !response.headers.has('cdn-cache-control') &&
+      !response.headers.has('netlify-cdn-cache-control')
+    ) {
+      // handle CDN Cache Control on ISR and App Router page responses
+      const browserCacheControl = omitHeaderValues(cacheControl, [
+        's-maxage',
+        'stale-while-revalidate',
+      ])
+      const cdnCacheControl =
+        // if we are serving already stale response, instruct edge to not attempt to cache that response
+        response.headers.get('x-nextjs-cache') === 'STALE'
+          ? 'public, max-age=0, must-revalidate, durable'
+          : [
+              ...getHeaderValueArray(cacheControl).map((value) =>
+                value === 'stale-while-revalidate' ? 'stale-while-revalidate=31536000' : value,
+              ),
+              'durable',
+            ].join(', ')
+
+      response.headers.set(
+        'cache-control',
+        browserCacheControl || 'public, max-age=0, must-revalidate',
+      )
+      response.headers.set('netlify-cdn-cache-control', cdnCacheControl)
+    }
+  }
+
+  {
+    // set Cache-Status header based on Next.js cache status
+    const nextCache = response.headers.get('x-nextjs-cache')
+    if (nextCache) {
+      if (nextCache in NEXT_CACHE_TO_CACHE_STATUS) {
+        response.headers.set('cache-status', NEXT_CACHE_TO_CACHE_STATUS[nextCache])
+      }
+      response.headers.delete('x-nextjs-cache')
+    }
+  }
+
   return response
 }

From 1062a412d5c07956af47d27cfe8f5154f0e6b4be Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Fri, 31 Oct 2025 12:10:44 +0100
Subject: [PATCH 51/75] chore: move image loader to shared dir

---
 src/adapter/build/image-cdn.ts                                | 2 +-
 src/adapter/{build => shared}/image-cdn-next-image-loader.cts | 0
 2 files changed, 1 insertion(+), 1 deletion(-)
 rename src/adapter/{build => shared}/image-cdn-next-image-loader.cts (100%)

diff --git a/src/adapter/build/image-cdn.ts b/src/adapter/build/image-cdn.ts
index 1c69c6123f..fa0dee5b9d 100644
--- a/src/adapter/build/image-cdn.ts
+++ b/src/adapter/build/image-cdn.ts
@@ -6,7 +6,7 @@ import { makeRe } from 'picomatch'
 import type { NetlifyAdapterContext, NextConfigComplete, OnBuildCompleteContext } from './types.js'
 
 const NETLIFY_IMAGE_LOADER_FILE = fileURLToPath(
-  import.meta.resolve(`./image-cdn-next-image-loader.cjs`),
+  import.meta.resolve(`../shared/image-cdn-next-image-loader.cjs`),
 )
 
 export function modifyConfig(config: NextConfigComplete) {
diff --git a/src/adapter/build/image-cdn-next-image-loader.cts b/src/adapter/shared/image-cdn-next-image-loader.cts
similarity index 100%
rename from src/adapter/build/image-cdn-next-image-loader.cts
rename to src/adapter/shared/image-cdn-next-image-loader.cts

From 4d4cad6f7bf29e9623300c625e4b1f2f71bdc951 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Fri, 31 Oct 2025 13:30:02 +0100
Subject: [PATCH 52/75] fix: use typed inline configs for produced functions

---
 src/adapter/build/middleware.ts             | 15 +++++++-----
 src/adapter/build/pages-and-app-handlers.ts | 26 ++++++++-------------
 2 files changed, 19 insertions(+), 22 deletions(-)

diff --git a/src/adapter/build/middleware.ts b/src/adapter/build/middleware.ts
index 56d885101f..cc556978d0 100644
--- a/src/adapter/build/middleware.ts
+++ b/src/adapter/build/middleware.ts
@@ -1,6 +1,7 @@
 import { cp, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'
 import { dirname, join, parse, relative } from 'node:path/posix'
 
+import type { IntegrationsConfig } from '@netlify/edge-functions'
 import { glob } from 'fast-glob'
 import { pathToRegexp } from 'path-to-regexp'
 
@@ -183,6 +184,13 @@ const writeHandlerFile = async (
     ),
   )
 
+  const functionConfig = {
+    cache: undefined,
+    generator: GENERATOR,
+    name: DISPLAY_NAME_MIDDLEWARE,
+    pattern: augmentMatchers(middleware, nextConfig).map((matcher) => matcher.regexp),
+  } satisfies IntegrationsConfig
+
   // Writing the function entry file. It wraps the middleware code with the
   // compatibility layer mentioned above.
   await writeFile(
@@ -198,12 +206,7 @@ const writeHandlerFile = async (
 
     export default (req, context) => handleMiddleware(req, context, handler);
 
-    export const config = ${JSON.stringify({
-      cache: undefined,
-      generator: GENERATOR,
-      name: DISPLAY_NAME_MIDDLEWARE,
-      pattern: augmentMatchers(middleware, nextConfig).map((matcher) => matcher.regexp),
-    })}
+    export const config = ${JSON.stringify(functionConfig, null, 2)}
     `,
   )
 }
diff --git a/src/adapter/build/pages-and-app-handlers.ts b/src/adapter/build/pages-and-app-handlers.ts
index 13643f9a9b..d9c088aa81 100644
--- a/src/adapter/build/pages-and-app-handlers.ts
+++ b/src/adapter/build/pages-and-app-handlers.ts
@@ -1,6 +1,7 @@
 import { cp, mkdir, writeFile } from 'node:fs/promises'
 import { join, relative } from 'node:path/posix'
 
+import type { InSourceConfig } from '@netlify/zip-it-and-ship-it/dist/runtimes/node/in_source_config/index.js'
 import { glob } from 'fast-glob'
 
 import {
@@ -81,7 +82,13 @@ export async function onBuildComplete(
 
   await copyRuntime(join(PAGES_AND_APP_FUNCTION_DIR, RUNTIME_DIR))
 
-  const functionsPaths = Object.keys(pathnameToEntry)
+  const functionConfig = {
+    path: Object.keys(pathnameToEntry),
+    nodeBundler: 'none',
+    includedFiles: ['**'],
+    generator: GENERATOR,
+    name: DISPLAY_NAME_PAGES_AND_APP,
+  } as const satisfies InSourceConfig
 
   // generate needed runtime files
   const entrypoint = /* javascript */ `
@@ -108,27 +115,14 @@ export async function onBuildComplete(
       return runNextHandler(request, context, nextHandler)
     }
 
-    export const config = {
-      
-      path: ${JSON.stringify(functionsPaths, null, 2)},
-    }
+    export const config = ${JSON.stringify(functionConfig, null, 2)}
   `
   await writeFile(
     join(PAGES_AND_APP_FUNCTION_DIR, `${PAGES_AND_APP_FUNCTION_INTERNAL_NAME}.mjs`),
     entrypoint,
   )
 
-  // configuration
-  netlifyAdapterContext.frameworksAPIConfig ??= {}
-  netlifyAdapterContext.frameworksAPIConfig.functions ??= { '*': {} }
-  netlifyAdapterContext.frameworksAPIConfig.functions[PAGES_AND_APP_FUNCTION_INTERNAL_NAME] = {
-    node_bundler: 'none',
-    included_files: ['**'],
-    // TODO(pieh): below only works due to local patches, need to ship proper support
-    included_files_base_path: PAGES_AND_APP_FUNCTION_DIR,
-  }
-
-  netlifyAdapterContext.preparedOutputs.endpoints.push(...functionsPaths)
+  netlifyAdapterContext.preparedOutputs.endpoints.push(...functionConfig.path)
 }
 
 const copyRuntime = async (handlerDirectory: string): Promise => {

From 268c79cdaee2b57f03e546743f5a1aeed0bf7baa Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Sun, 2 Nov 2025 11:06:59 +0100
Subject: [PATCH 53/75] fix: ntl serve compat

---
 src/adapter/run/pages-and-app-handler.ts | 16 ++++++++++++++--
 1 file changed, 14 insertions(+), 2 deletions(-)

diff --git a/src/adapter/run/pages-and-app-handler.ts b/src/adapter/run/pages-and-app-handler.ts
index 9467ec85ac..5b6fa5fe3f 100644
--- a/src/adapter/run/pages-and-app-handler.ts
+++ b/src/adapter/run/pages-and-app-handler.ts
@@ -1,4 +1,6 @@
 import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'node:http'
+import { join } from 'node:path/posix'
+import { fileURLToPath } from 'node:url'
 
 import { ComputeJsOutgoingMessage, toComputeResponse, toReqRes } from '@fastly/http-compute-js'
 import type { Context } from '@netlify/functions'
@@ -53,6 +55,15 @@ const NEXT_CACHE_TO_CACHE_STATUS: Record = {
   STALE: `hit; fwd=stale`,
 }
 
+const FUNCTION_ROOT = fileURLToPath(new URL('.', import.meta.url))
+export const FUNCTION_ROOT_DIR = join(FUNCTION_ROOT, '..', '..', '..', '..')
+if (process.cwd() !== FUNCTION_ROOT_DIR) {
+  // setting CWD only needed for `ntl serve` as otherwise CWD is set to root of the project
+  // when deployed CWD is correct
+  // TODO(pieh): test with monorepo if this will work there as well, or cwd will need to have packagePath appended
+  process.cwd = () => FUNCTION_ROOT_DIR
+}
+
 type NextHandler = (
   req: IncomingMessage,
   res: ServerResponse,
@@ -133,7 +144,7 @@ export async function runNextHandler(
         'cache-control',
         browserCacheControl || 'public, max-age=0, must-revalidate',
       )
-      response.headers.set('netlify-cdn-cache-control', cdnCacheControl)
+      // response.headers.set('netlify-cdn-cache-control', cdnCacheControl)
     }
   }
 
@@ -141,10 +152,11 @@ export async function runNextHandler(
     // set Cache-Status header based on Next.js cache status
     const nextCache = response.headers.get('x-nextjs-cache')
     if (nextCache) {
+      // eslint-disable-next-line unicorn/no-lonely-if
       if (nextCache in NEXT_CACHE_TO_CACHE_STATUS) {
         response.headers.set('cache-status', NEXT_CACHE_TO_CACHE_STATUS[nextCache])
       }
-      response.headers.delete('x-nextjs-cache')
+      // response.headers.delete('x-nextjs-cache')
     }
   }
 

From ebb839d797d3ee1ece5ebf0871fa8c9b59d1644e Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Sun, 2 Nov 2025 11:19:51 +0100
Subject: [PATCH 54/75] more ntl serve compat + some routing adjustments

---
 src/adapter/build/routing.ts | 162 +++++++++++++++++++----------------
 src/adapter/run/routing.ts   |  36 ++++++--
 src/index.ts                 |   2 +-
 3 files changed, 120 insertions(+), 80 deletions(-)

diff --git a/src/adapter/build/routing.ts b/src/adapter/build/routing.ts
index d1918b00f8..85a6c0591a 100644
--- a/src/adapter/build/routing.ts
+++ b/src/adapter/build/routing.ts
@@ -143,7 +143,7 @@ export function convertRedirectToRoutingRule(
     OnBuildCompleteContext['routes']['redirects'][number],
     'source' | 'destination' | 'priority'
   >,
-  description?: string,
+  description: string,
 ): RoutingRuleRedirect {
   const { sourceRegexString, segments } = sourceToRegex(redirect.source)
 
@@ -191,75 +191,79 @@ export async function generateRoutingRules(
     }
   }
 
-  const normalizeNextData: RoutingRuleRewrite[] = [
-    {
-      description: 'Normalize _next/data',
-      match: {
-        path: `^${nextAdapterContext.config.basePath}/_next/data/${await netlifyAdapterContext.getBuildId()}/(.*)\\.json`,
-        has: [
-          {
-            type: 'header',
-            key: 'x-nextjs-data',
+  const normalizeNextData: RoutingRuleRewrite[] = shouldDenormalizeJsonDataForMiddleware
+    ? [
+        {
+          description: 'Normalize _next/data',
+          match: {
+            path: `^${nextAdapterContext.config.basePath}/_next/data/${await netlifyAdapterContext.getBuildId()}/(.*)\\.json`,
+            has: [
+              {
+                type: 'header',
+                key: 'x-nextjs-data',
+              },
+            ],
           },
-        ],
-      },
-      apply: {
-        type: 'rewrite',
-        destination: `${nextAdapterContext.config.basePath}/$1${nextAdapterContext.config.trailingSlash ? '/' : ''}`,
-      },
-    },
-    {
-      description: 'Fix _next/data index normalization',
-      match: {
-        path: `^${nextAdapterContext.config.basePath}/index(?:/)?`,
-        has: [
-          {
-            type: 'header',
-            key: 'x-nextjs-data',
+          apply: {
+            type: 'rewrite',
+            destination: `${nextAdapterContext.config.basePath}/$1${nextAdapterContext.config.trailingSlash ? '/' : ''}`,
           },
-        ],
-      },
-      apply: {
-        type: 'rewrite',
-        destination: `${nextAdapterContext.config.basePath}/`,
-      },
-    },
-  ]
-
-  const denormalizeNextData: RoutingRuleRewrite[] = [
-    {
-      description: 'Fix _next/data index denormalization',
-      match: {
-        path: `^${nextAdapterContext.config.basePath}/$`,
-        has: [
-          {
-            type: 'header',
-            key: 'x-nextjs-data',
+        },
+        {
+          description: 'Fix _next/data index normalization',
+          match: {
+            path: `^${nextAdapterContext.config.basePath}/index(?:/)?`,
+            has: [
+              {
+                type: 'header',
+                key: 'x-nextjs-data',
+              },
+            ],
           },
-        ],
-      },
-      apply: {
-        type: 'rewrite',
-        destination: `${nextAdapterContext.config.basePath}/index`,
-      },
-    },
-    {
-      description: 'Denormalize _next/data',
-      match: {
-        path: `^${nextAdapterContext.config.basePath}/((?!_next/)(?:.*[^/]|.*))/?$`,
-        has: [
-          {
-            type: 'header',
-            key: 'x-nextjs-data',
+          apply: {
+            type: 'rewrite',
+            destination: `${nextAdapterContext.config.basePath}/`,
           },
-        ],
-      },
-      apply: {
-        type: 'rewrite',
-        destination: `${nextAdapterContext.config.basePath}/_next/data/${await netlifyAdapterContext.getBuildId()}/$1.json`,
-      },
-    },
-  ]
+        },
+      ]
+    : []
+
+  const denormalizeNextData: RoutingRuleRewrite[] = shouldDenormalizeJsonDataForMiddleware
+    ? [
+        {
+          description: 'Fix _next/data index denormalization',
+          match: {
+            path: `^${nextAdapterContext.config.basePath}/$`,
+            has: [
+              {
+                type: 'header',
+                key: 'x-nextjs-data',
+              },
+            ],
+          },
+          apply: {
+            type: 'rewrite',
+            destination: `${nextAdapterContext.config.basePath}/index`,
+          },
+        },
+        {
+          description: 'Denormalize _next/data',
+          match: {
+            path: `^${nextAdapterContext.config.basePath}/((?!_next/)(?:.*[^/]|.*))/?$`,
+            has: [
+              {
+                type: 'header',
+                key: 'x-nextjs-data',
+              },
+            ],
+          },
+          apply: {
+            type: 'rewrite',
+            destination: `${nextAdapterContext.config.basePath}/_next/data/${await netlifyAdapterContext.getBuildId()}/$1.json`,
+          },
+        },
+      ]
+    : []
 
   const routing: RoutingRule[] = [
     // order inherited from
@@ -278,7 +282,7 @@ export async function generateRoutingRules(
     // priority redirects includes trailing slash redirect
     ...priorityRedirects, // originally: ...convertedPriorityRedirects,
 
-    ...(hasPages ? normalizeNextData : []), // originally: // normalize _next/data if middleware + pages
+    ...normalizeNextData, // originally: // normalize _next/data if middleware + pages
 
     // i18n prefixing routes
 
@@ -288,7 +292,7 @@ export async function generateRoutingRules(
 
     // server actions name meta routes
 
-    ...(shouldDenormalizeJsonDataForMiddleware ? denormalizeNextData : []), // originally: // if skip middleware url normalize we denormalize _next/data if middleware + pages
+    ...denormalizeNextData, // originally: // if skip middleware url normalize we denormalize _next/data if middleware + pages
 
     ...(hasMiddleware
       ? [
@@ -300,7 +304,7 @@ export async function generateRoutingRules(
         ]
       : []),
 
-    ...(shouldDenormalizeJsonDataForMiddleware ? normalizeNextData : []), // originally: // if skip middleware url normalize we normalize _next/data if middleware + pages
+    ...normalizeNextData, // originally: // if skip middleware url normalize we normalize _next/data if middleware + pages
 
     // ...convertedRewrites.beforeFiles,
 
@@ -308,17 +312,24 @@ export async function generateRoutingRules(
 
     // add 500 handling if /500 or locale variants are requested literally
 
-    ...(hasPages ? denormalizeNextData : []), // originally: // denormalize _next/data if middleware + pages
+    ...denormalizeNextData, // originally: // denormalize _next/data if middleware + pages
 
     // segment prefetch request rewriting
 
     // non-segment prefetch rsc request rewriting
 
     // full rsc request rewriting
+    {
+      // originally: { handle: 'filesystem' },
+      // this is no-op on its own, it's just marker to be able to run subset of routing rules
+      description: "'filesystem' routing phase marker",
+      routingPhase: 'filesystem',
+    },
 
     {
       // originally: { handle: 'filesystem' },
-      description: 'Static assets or Functions',
+      // this is to actual match on things 'filesystem' should match on
+      description: 'Static assets or Functions (no dynamic paths for functions)',
       match: { type: 'static-asset-or-function' },
     },
 
@@ -335,7 +346,7 @@ export async function generateRoutingRules(
     //     ]
     //   : []),
 
-    ...(hasPages ? normalizeNextData : []), // originally: // normalize _next/data if middleware + pages
+    ...normalizeNextData, // originally: // normalize _next/data if middleware + pages
 
     // normalize /index.rsc to just /
 
@@ -363,7 +374,12 @@ export async function generateRoutingRules(
 
     // rewrite segment prefetch to prefetch/rsc
 
-    // { handle: 'rewrite' },
+    {
+      // originally: { handle: 'rewrite' },
+      // this is no-op on its own, it's just marker to be able to run subset of routing rules
+      description: "'rewrite' routing phase marker",
+      routingPhase: 'rewrite',
+    },
 
     // denormalize _next/data if middleware + pages
 
diff --git a/src/adapter/run/routing.ts b/src/adapter/run/routing.ts
index 1a3dd713d5..bcab6028f7 100644
--- a/src/adapter/run/routing.ts
+++ b/src/adapter/run/routing.ts
@@ -1,3 +1,5 @@
+import process from 'node:process'
+
 import type { Context } from '@netlify/edge-functions'
 
 import type { NetlifyAdapterContext } from '../build/types.js'
@@ -6,7 +8,7 @@ type RoutingRuleBase = {
   /**
    * Human readable description of the rule (for debugging purposes only)
    */
-  description?: string
+  description: string
 }
 
 type Match = {
@@ -48,7 +50,17 @@ export type RoutingRuleMatchPrimitive = RoutingRuleBase & {
   }
 }
 
-export type RoutingRule = RoutingRuleRedirect | RoutingRuleRewrite | RoutingRuleMatchPrimitive
+export type RoutingPhase = 'filesystem' | 'rewrite'
+
+export type RoutingPhaseRule = RoutingRuleBase & {
+  routingPhase: RoutingPhase
+}
+
+export type RoutingRule =
+  | RoutingRuleRedirect
+  | RoutingRuleRewrite
+  | RoutingPhaseRule
+  | RoutingRuleMatchPrimitive
 
 export function testRedirectRewriteRule(rule: RoutingRuleRedirect, request: Request) {
   const sourceRegexp = new RegExp(rule.match.path)
@@ -60,6 +72,8 @@ export function testRedirectRewriteRule(rule: RoutingRuleRedirect, request: Requ
   return { matched: false }
 }
 
+let requestCounter = 0
+
 export async function runNextRouting(
   request: Request,
   context: Context,
@@ -71,7 +85,12 @@ export async function runNextRouting(
     return
   }
 
-  const prefix = `[${Date.now()}]`
+  const prefix = `[${
+    request.headers.get('x-nf-request-id') ??
+    // for ntl serve, we use a combination of timestamp and pid to have a unique id per request as we don't have x-nf-request-id header then
+    // eslint-disable-next-line no-plusplus
+    `${Date.now()} - #${process.pid}:${++requestCounter}`
+  }]`
 
   console.log(prefix, 'Incoming request for routing:', request.url)
 
@@ -86,16 +105,19 @@ export async function runNextRouting(
       const { pathname } = currentURL
 
       if ('type' in rule.match) {
+        const pathnameToMatch = pathname === '/' ? '/index' : pathname
+
         if (rule.match.type === 'static-asset-or-function') {
           let matchedType: 'static-asset' | 'function' | null = null
-          if (outputs.staticAssets.includes(pathname)) {
+
+          if (outputs.staticAssets.includes(pathnameToMatch)) {
             matchedType = 'static-asset'
-          } else if (outputs.endpoints.includes(pathname)) {
+          } else if (outputs.endpoints.includes(pathnameToMatch)) {
             matchedType = 'function'
           }
 
           if (matchedType) {
-            console.log(prefix, 'Matched static asset:', pathname)
+            console.log(prefix, `Matched static asset or function (${matchedType}):`)
             maybeResponse = await context.next(currentRequest)
           }
         } else if (rule.match.type === 'image-cdn' && pathname.startsWith('/.netlify/image/')) {
@@ -141,6 +163,8 @@ export async function runNextRouting(
     }
 
     if (maybeResponse) {
+      // for debugging add log prefixes to response headers to make it easy to find logs for a given request
+      maybeResponse.headers.set('x-ntl-log-prefix', prefix)
       console.log(prefix, 'Serving response', maybeResponse.status)
       return maybeResponse
     }
diff --git a/src/index.ts b/src/index.ts
index 7e7abd4eed..e9d7832fa2 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -129,6 +129,6 @@ export const onEnd = async (options: NetlifyPluginOptions) => {
   }
 
   await tracer.withActiveSpan('onEnd', async () => {
-    await unpublishStaticDir(new PluginContext(options))
+    // await unpublishStaticDir(new PluginContext(options))
   })
 }

From f3e466e9bf2e6ad4980235f3d5b71bcd86ebbeab Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Sun, 2 Nov 2025 11:39:02 +0100
Subject: [PATCH 55/75] fix next-with-adapters bump

---
 package-lock.json | 162 +++++++++++++++++++++++-----------------------
 package.json      |   2 +-
 2 files changed, 82 insertions(+), 82 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index 0775354cc3..d20fa6e146 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -44,7 +44,7 @@
         "msw": "^2.0.7",
         "netlify-cli": "23.9.5",
         "next": "^15.0.0-canary.28",
-        "next-with-adapters": "npm:16.0.2-canary.0",
+        "next-with-adapters": "npm:next@16.0.2-canary.0",
         "next-with-cache-handler-v2": "npm:next@15.3.0-canary.13",
         "next-with-cache-handler-v3": "npm:next@16.0.0-beta.0",
         "os": "^0.1.2",
@@ -28014,13 +28014,13 @@
     },
     "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==",
+      "version": "16.0.2-canary.0",
+      "resolved": "https://registry.npmjs.org/next/-/next-16.0.2-canary.0.tgz",
+      "integrity": "sha512-lFYIwOzBw52e6eZYa0HwH6d5738H5BjYPhFf7VRzvWyI7X9aca2u+L8EyTgyJrVOE1HyHWh+hzx0JH0Ve1Ie3g==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@next/env": "15.6.0-canary.20",
+        "@next/env": "16.0.2-canary.0",
         "@swc/helpers": "0.5.15",
         "caniuse-lite": "^1.0.30001579",
         "postcss": "8.4.31",
@@ -28033,14 +28033,14 @@
         "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",
+        "@next/swc-darwin-arm64": "16.0.2-canary.0",
+        "@next/swc-darwin-x64": "16.0.2-canary.0",
+        "@next/swc-linux-arm64-gnu": "16.0.2-canary.0",
+        "@next/swc-linux-arm64-musl": "16.0.2-canary.0",
+        "@next/swc-linux-x64-gnu": "16.0.2-canary.0",
+        "@next/swc-linux-x64-musl": "16.0.2-canary.0",
+        "@next/swc-win32-arm64-msvc": "16.0.2-canary.0",
+        "@next/swc-win32-x64-msvc": "16.0.2-canary.0",
         "sharp": "^0.34.4"
       },
       "peerDependencies": {
@@ -28447,16 +28447,16 @@
       }
     },
     "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==",
+      "version": "16.0.2-canary.0",
+      "resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.2-canary.0.tgz",
+      "integrity": "sha512-qmCcEhjC4FTr+yVwEwrzWdr+hSzfM6UHa8RizlyZZK4MxFyGaJeGCjj+yviYP9Pg2Ag90/Y+4eD8IM10zIRpLQ==",
       "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==",
+      "version": "16.0.2-canary.0",
+      "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.2-canary.0.tgz",
+      "integrity": "sha512-FI5DM22s/x+ULwqz2mX2HCw1soxiFtkl1OyeAvKQHF+0UWMPfD76O/Z8X8KanfUoOL3jmJhwLbdBOqk6np0SAg==",
       "cpu": [
         "arm64"
       ],
@@ -28471,9 +28471,9 @@
       }
     },
     "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==",
+      "version": "16.0.2-canary.0",
+      "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.2-canary.0.tgz",
+      "integrity": "sha512-7W3Zj4aVVSF9rSMRL8XkfKHWLYRd+zp4MmYIaZFPZZHnstq0ga3aF5Pz9zSgdQNnptga53c+u5t/FvYhTWIkGQ==",
       "cpu": [
         "x64"
       ],
@@ -28488,9 +28488,9 @@
       }
     },
     "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==",
+      "version": "16.0.2-canary.0",
+      "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.2-canary.0.tgz",
+      "integrity": "sha512-kJDW1pCNYzhAiZwqrwhVopcCUxSSXfHjVOfl6DOvuI2TW/SEnr/wm+HEhe7lD4w/oMD63S841uKxMBvO9cfeFw==",
       "cpu": [
         "arm64"
       ],
@@ -28505,9 +28505,9 @@
       }
     },
     "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==",
+      "version": "16.0.2-canary.0",
+      "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.2-canary.0.tgz",
+      "integrity": "sha512-rti0MoaPicwVs4CfM5HH28yHJJHkFhcWO+j+hKS+I7mOmBevcap1OiMQHvaIGKpvSTZabtZy/bngxDvVtO3HQQ==",
       "cpu": [
         "arm64"
       ],
@@ -28522,9 +28522,9 @@
       }
     },
     "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==",
+      "version": "16.0.2-canary.0",
+      "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.2-canary.0.tgz",
+      "integrity": "sha512-ff50Kc8+J8j163twVYVEqZreFHHXE1Hv/p3+LtByhVYGo/jmkfOHUjdBGKhllA5ybBlIRzRgqMAU1BeeRgCudA==",
       "cpu": [
         "x64"
       ],
@@ -28539,9 +28539,9 @@
       }
     },
     "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==",
+      "version": "16.0.2-canary.0",
+      "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.2-canary.0.tgz",
+      "integrity": "sha512-pBUr5T9/emoafMu8m3J32kg843a/V7kiDQ/E5/qxA/i5CprxXWcj8ZvvcXn5PknWmlV+x5vWqxl73ql1EJTlkg==",
       "cpu": [
         "x64"
       ],
@@ -28556,9 +28556,9 @@
       }
     },
     "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==",
+      "version": "16.0.2-canary.0",
+      "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.2-canary.0.tgz",
+      "integrity": "sha512-skbE/b22+odqdewjBpy5vyD35nB96guDsvjZhxh2eiSebImJ653TDJcbb304ct8IstZrZTALtP/r3GHw0OZu8g==",
       "cpu": [
         "arm64"
       ],
@@ -28573,9 +28573,9 @@
       }
     },
     "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==",
+      "version": "16.0.2-canary.0",
+      "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.2-canary.0.tgz",
+      "integrity": "sha512-bP0YfnmD1YP/2MSn1NTh6LKjgJLw/YC15xutH4hoO8TpJfjhHwXdtvNdRcg/tvKgb0hoFlOfvfCxhpSjrNgJ9w==",
       "cpu": [
         "x64"
       ],
@@ -53430,20 +53430,20 @@
       }
     },
     "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",
+      "version": "npm:next@16.0.2-canary.0",
+      "resolved": "https://registry.npmjs.org/next/-/next-16.0.2-canary.0.tgz",
+      "integrity": "sha512-lFYIwOzBw52e6eZYa0HwH6d5738H5BjYPhFf7VRzvWyI7X9aca2u+L8EyTgyJrVOE1HyHWh+hzx0JH0Ve1Ie3g==",
+      "dev": true,
+      "requires": {
+        "@next/env": "16.0.2-canary.0",
+        "@next/swc-darwin-arm64": "16.0.2-canary.0",
+        "@next/swc-darwin-x64": "16.0.2-canary.0",
+        "@next/swc-linux-arm64-gnu": "16.0.2-canary.0",
+        "@next/swc-linux-arm64-musl": "16.0.2-canary.0",
+        "@next/swc-linux-x64-gnu": "16.0.2-canary.0",
+        "@next/swc-linux-x64-musl": "16.0.2-canary.0",
+        "@next/swc-win32-arm64-msvc": "16.0.2-canary.0",
+        "@next/swc-win32-x64-msvc": "16.0.2-canary.0",
         "@swc/helpers": "0.5.15",
         "caniuse-lite": "^1.0.30001579",
         "postcss": "8.4.31",
@@ -53612,64 +53612,64 @@
           "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==",
+          "version": "16.0.2-canary.0",
+          "resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.2-canary.0.tgz",
+          "integrity": "sha512-qmCcEhjC4FTr+yVwEwrzWdr+hSzfM6UHa8RizlyZZK4MxFyGaJeGCjj+yviYP9Pg2Ag90/Y+4eD8IM10zIRpLQ==",
           "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==",
+          "version": "16.0.2-canary.0",
+          "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.2-canary.0.tgz",
+          "integrity": "sha512-FI5DM22s/x+ULwqz2mX2HCw1soxiFtkl1OyeAvKQHF+0UWMPfD76O/Z8X8KanfUoOL3jmJhwLbdBOqk6np0SAg==",
           "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==",
+          "version": "16.0.2-canary.0",
+          "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.2-canary.0.tgz",
+          "integrity": "sha512-7W3Zj4aVVSF9rSMRL8XkfKHWLYRd+zp4MmYIaZFPZZHnstq0ga3aF5Pz9zSgdQNnptga53c+u5t/FvYhTWIkGQ==",
           "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==",
+          "version": "16.0.2-canary.0",
+          "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.2-canary.0.tgz",
+          "integrity": "sha512-kJDW1pCNYzhAiZwqrwhVopcCUxSSXfHjVOfl6DOvuI2TW/SEnr/wm+HEhe7lD4w/oMD63S841uKxMBvO9cfeFw==",
           "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==",
+          "version": "16.0.2-canary.0",
+          "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.2-canary.0.tgz",
+          "integrity": "sha512-rti0MoaPicwVs4CfM5HH28yHJJHkFhcWO+j+hKS+I7mOmBevcap1OiMQHvaIGKpvSTZabtZy/bngxDvVtO3HQQ==",
           "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==",
+          "version": "16.0.2-canary.0",
+          "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.2-canary.0.tgz",
+          "integrity": "sha512-ff50Kc8+J8j163twVYVEqZreFHHXE1Hv/p3+LtByhVYGo/jmkfOHUjdBGKhllA5ybBlIRzRgqMAU1BeeRgCudA==",
           "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==",
+          "version": "16.0.2-canary.0",
+          "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.2-canary.0.tgz",
+          "integrity": "sha512-pBUr5T9/emoafMu8m3J32kg843a/V7kiDQ/E5/qxA/i5CprxXWcj8ZvvcXn5PknWmlV+x5vWqxl73ql1EJTlkg==",
           "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==",
+          "version": "16.0.2-canary.0",
+          "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.2-canary.0.tgz",
+          "integrity": "sha512-skbE/b22+odqdewjBpy5vyD35nB96guDsvjZhxh2eiSebImJ653TDJcbb304ct8IstZrZTALtP/r3GHw0OZu8g==",
           "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==",
+          "version": "16.0.2-canary.0",
+          "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.2-canary.0.tgz",
+          "integrity": "sha512-bP0YfnmD1YP/2MSn1NTh6LKjgJLw/YC15xutH4hoO8TpJfjhHwXdtvNdRcg/tvKgb0hoFlOfvfCxhpSjrNgJ9w==",
           "dev": true,
           "optional": true
         },
diff --git a/package.json b/package.json
index 6e6a6c1b87..8bc634d12d 100644
--- a/package.json
+++ b/package.json
@@ -87,7 +87,7 @@
     "msw": "^2.0.7",
     "netlify-cli": "23.9.5",
     "next": "^15.0.0-canary.28",
-    "next-with-adapters": "npm:16.0.2-canary.0",
+    "next-with-adapters": "npm:next@16.0.2-canary.0",
     "next-with-cache-handler-v2": "npm:next@15.3.0-canary.13",
     "next-with-cache-handler-v3": "npm:next@16.0.0-beta.0",
     "os": "^0.1.2",

From 73c6bb9ff31a2661bd6ec7451833ec72f6a1891c Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Sun, 2 Nov 2025 11:45:25 +0100
Subject: [PATCH 56/75] simplify redirect handling with sourceRegex shipped
 recently in adapters API

---
 src/adapter/build/routing.ts | 135 +----------------------------------
 1 file changed, 3 insertions(+), 132 deletions(-)

diff --git a/src/adapter/build/routing.ts b/src/adapter/build/routing.ts
index 85a6c0591a..43a3974de1 100644
--- a/src/adapter/build/routing.ts
+++ b/src/adapter/build/routing.ts
@@ -1,13 +1,7 @@
 import { cp, writeFile } from 'node:fs/promises'
 import { join } from 'node:path/posix'
-import { format as formatUrl, parse as parseUrl } from 'node:url'
 
 import { glob } from 'fast-glob'
-import {
-  pathToRegexp,
-  compile as pathToRegexpCompile,
-  type Key as PathToRegexpKey,
-} from 'path-to-regexp'
 
 import type { RoutingRule, RoutingRuleRedirect, RoutingRuleRewrite } from '../run/routing.js'
 
@@ -19,144 +13,21 @@ import {
 } from './constants.js'
 import type { NetlifyAdapterContext, OnBuildCompleteContext } from './types.js'
 
-const UN_NAMED_SEGMENT = '__UN_NAMED_SEGMENT__'
-
-// https://github.com/vercel/vercel/blob/8beae7035bf0d3e5cfc1df337b83fbbe530c4d9b/packages/routing-utils/src/superstatic.ts#L273
-export function sourceToRegex(source: string) {
-  const keys: PathToRegexpKey[] = []
-  const regexp = pathToRegexp(source, keys, {
-    strict: true,
-    sensitive: true,
-    delimiter: '/',
-  })
-
-  return {
-    sourceRegexString: regexp.source,
-    segments: keys
-      .map((key) => key.name)
-      .map((keyName) => {
-        if (typeof keyName !== 'string') {
-          return UN_NAMED_SEGMENT
-        }
-        return keyName
-      }),
-  }
-}
-
-// https://github.com/vercel/vercel/blob/8beae7035bf0d3e5cfc1df337b83fbbe530c4d9b/packages/routing-utils/src/superstatic.ts#L345
-const escapeSegment = (str: string, segmentName: string) =>
-  str.replace(new RegExp(`:${segmentName}`, 'g'), `__esc_colon_${segmentName}`)
-
-// https://github.com/vercel/vercel/blob/8beae7035bf0d3e5cfc1df337b83fbbe530c4d9b/packages/routing-utils/src/superstatic.ts#L348
-const unescapeSegments = (str: string) => str.replace(/__esc_colon_/gi, ':')
-
-// https://github.com/vercel/vercel/blob/8beae7035bf0d3e5cfc1df337b83fbbe530c4d9b/packages/routing-utils/src/superstatic.ts#L464
-function safelyCompile(
-  val: string,
-  indexes: { [k: string]: string },
-  attemptDirectCompile?: boolean,
-): string {
-  let value = val
-  if (!value) {
-    return value
-  }
-
-  if (attemptDirectCompile) {
-    try {
-      // Attempt compiling normally with path-to-regexp first and fall back
-      // to safely compiling to handle edge cases if path-to-regexp compile
-      // fails
-      return pathToRegexpCompile(value, { validate: false })(indexes)
-    } catch {
-      // non-fatal, we continue to safely compile
-    }
-  }
-
-  for (const key of Object.keys(indexes)) {
-    if (value.includes(`:${key}`)) {
-      value = value
-        .replace(new RegExp(`:${key}\\*`, 'g'), `:${key}--ESCAPED_PARAM_ASTERISK`)
-        .replace(new RegExp(`:${key}\\?`, 'g'), `:${key}--ESCAPED_PARAM_QUESTION`)
-        .replace(new RegExp(`:${key}\\+`, 'g'), `:${key}--ESCAPED_PARAM_PLUS`)
-        .replace(new RegExp(`:${key}(?!\\w)`, 'g'), `--ESCAPED_PARAM_COLON${key}`)
-    }
-  }
-  value = value
-    // eslint-disable-next-line unicorn/better-regex
-    .replace(/(:|\*|\?|\+|\(|\)|\{|\})/g, '\\$1')
-    .replace(/--ESCAPED_PARAM_PLUS/g, '+')
-    .replace(/--ESCAPED_PARAM_COLON/g, ':')
-    .replace(/--ESCAPED_PARAM_QUESTION/g, '?')
-    .replace(/--ESCAPED_PARAM_ASTERISK/g, '*')
-
-  // the value needs to start with a forward-slash to be compiled
-  // correctly
-  return pathToRegexpCompile(`/${value}`, { validate: false })(indexes).slice(1)
-}
-
-// https://github.com/vercel/vercel/blob/8beae7035bf0d3e5cfc1df337b83fbbe530c4d9b/packages/routing-utils/src/superstatic.ts#L350
-export function destinationToReplacementString(destination: string, segments: string[]) {
-  // convert /path/:id/route to /path/$1/route
-  // convert /path/:id+ to /path/$1
-
-  let escapedDestination = destination
-
-  const indexes: { [k: string]: string } = {}
-
-  segments.forEach((name, index) => {
-    indexes[name] = `$${index + 1}`
-    escapedDestination = escapeSegment(escapedDestination, name)
-  })
-
-  const parsedDestination = parseUrl(escapedDestination, true)
-  delete (parsedDestination as any).href
-  delete (parsedDestination as any).path
-  delete (parsedDestination as any).search
-  delete (parsedDestination as any).host
-  let { pathname, ...rest } = parsedDestination
-  pathname = unescapeSegments(pathname || '')
-
-  const pathnameKeys: PathToRegexpKey[] = []
-
-  try {
-    pathToRegexp(pathname, pathnameKeys)
-  } catch {
-    // this is not fatal so don't error when failing to parse the
-    // params from the destination
-  }
-
-  pathname = safelyCompile(pathname, indexes, true)
-
-  const finalDestination = formatUrl({
-    ...rest,
-    // hostname,
-    pathname,
-    // query,
-    // hash,
-  })
-  // url.format() escapes the dollar sign but it must be preserved for now-proxy
-  return finalDestination.replace(/%24/g, '$')
-}
-
 export function convertRedirectToRoutingRule(
   redirect: Pick<
     OnBuildCompleteContext['routes']['redirects'][number],
-    'source' | 'destination' | 'priority'
+    'sourceRegex' | 'destination' | 'priority'
   >,
   description: string,
 ): RoutingRuleRedirect {
-  const { sourceRegexString, segments } = sourceToRegex(redirect.source)
-
-  const convertedDestination = destinationToReplacementString(redirect.destination, segments)
-
   return {
     description,
     match: {
-      path: sourceRegexString,
+      path: redirect.sourceRegex,
     },
     apply: {
       type: 'redirect',
-      destination: convertedDestination,
+      destination: redirect.destination,
     },
   } satisfies RoutingRuleRedirect
 }

From da1494fb9b974c38225a0d0c8a33d97e17b8ba90 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Sun, 2 Nov 2025 15:20:31 +0100
Subject: [PATCH 57/75] support dynamic routes

---
 src/adapter/build/netlify-adapter-context.ts |   1 +
 src/adapter/build/pages-and-app-handlers.ts  |  10 +-
 src/adapter/build/routing.ts                 |  89 +++++++++++-
 src/adapter/build/static-assets.ts           |   6 +-
 src/adapter/run/routing.ts                   | 134 +++++++++++++------
 5 files changed, 196 insertions(+), 44 deletions(-)

diff --git a/src/adapter/build/netlify-adapter-context.ts b/src/adapter/build/netlify-adapter-context.ts
index 3cdbeadd8e..a649169001 100644
--- a/src/adapter/build/netlify-adapter-context.ts
+++ b/src/adapter/build/netlify-adapter-context.ts
@@ -16,6 +16,7 @@ export function createNetlifyAdapterContext(nextAdapterContext: OnBuildCompleteC
     frameworksAPIConfig: undefined as FrameworksAPIConfig | undefined,
     preparedOutputs: {
       staticAssets: [] as string[],
+      staticAssetsAliases: {} as Record,
       endpoints: [] as string[],
       middleware: false,
     },
diff --git a/src/adapter/build/pages-and-app-handlers.ts b/src/adapter/build/pages-and-app-handlers.ts
index d9c088aa81..77b2072afe 100644
--- a/src/adapter/build/pages-and-app-handlers.ts
+++ b/src/adapter/build/pages-and-app-handlers.ts
@@ -21,6 +21,8 @@ const PAGES_AND_APP_FUNCTION_DIR = join(
   PAGES_AND_APP_FUNCTION_INTERNAL_NAME,
 )
 
+// const abc: OneOfThePaths = 'asa(/abc/)dsa'
+
 export async function onBuildComplete(
   nextAdapterContext: OnBuildCompleteContext,
   netlifyAdapterContext: NetlifyAdapterContext,
@@ -110,9 +112,13 @@ export async function onBuildComplete(
         return new Response('Not Found', { status: 404 })
       }
 
-      const nextHandler = require('./' + entry).handler
+      const nextHandler = await require('./' + entry)
+
+      if (typeof nextHandler.handler !== 'function') {
+        console.log('.handler is not a function', { nextHandler })
+      }
 
-      return runNextHandler(request, context, nextHandler)
+      return runNextHandler(request, context, nextHandler.handler)
     }
 
     export const config = ${JSON.stringify(functionConfig, null, 2)}
diff --git a/src/adapter/build/routing.ts b/src/adapter/build/routing.ts
index 43a3974de1..a59c3fc211 100644
--- a/src/adapter/build/routing.ts
+++ b/src/adapter/build/routing.ts
@@ -13,6 +13,34 @@ import {
 } from './constants.js'
 import type { NetlifyAdapterContext, OnBuildCompleteContext } from './types.js'
 
+function fixDestinationGroupReplacements(destination: string, sourceRegex: string): string {
+  // convert $nxtPslug to $ etc
+
+  // find all capturing groups in sourceRegex
+  const segments = [...sourceRegex.matchAll(/\(\?<(?[^>]+)>/g)]
+
+  let adjustedDestination = destination
+  for (const segment of segments) {
+    if (segment.groups?.segment_name) {
+      adjustedDestination = adjustedDestination.replaceAll(
+        `$${segment.groups.segment_name}`,
+        `$<${segment.groups.segment_name}>`,
+      )
+    }
+  }
+
+  if (adjustedDestination !== destination) {
+    console.log('fixing named captured group replacement', {
+      sourceRegex,
+      segments,
+      destination,
+      adjustedDestination,
+    })
+  }
+
+  return adjustedDestination
+}
+
 export function convertRedirectToRoutingRule(
   redirect: Pick<
     OnBuildCompleteContext['routes']['redirects'][number],
@@ -27,11 +55,34 @@ export function convertRedirectToRoutingRule(
     },
     apply: {
       type: 'redirect',
-      destination: redirect.destination,
+      destination: fixDestinationGroupReplacements(redirect.destination, redirect.sourceRegex),
     },
   } satisfies RoutingRuleRedirect
 }
 
+export function convertDynamicRouteToRoutingRule(
+  dynamicRoute: Pick<
+    OnBuildCompleteContext['routes']['dynamicRoutes'][number],
+    'sourceRegex' | 'destination'
+  >,
+  description: string,
+): RoutingRuleRewrite {
+  return {
+    description,
+    match: {
+      path: dynamicRoute.sourceRegex,
+    },
+    apply: {
+      type: 'rewrite',
+      destination: fixDestinationGroupReplacements(
+        dynamicRoute.destination,
+        dynamicRoute.sourceRegex,
+      ),
+      rerunRoutingPhases: ['filesystem', 'rewrite'], // this is attempt to mimic Vercel's check: true
+    },
+  } satisfies RoutingRuleRewrite
+}
+
 export async function generateRoutingRules(
   nextAdapterContext: OnBuildCompleteContext,
   netlifyAdapterContext: NetlifyAdapterContext,
@@ -62,6 +113,34 @@ export async function generateRoutingRules(
     }
   }
 
+  const dynamicRoutes: RoutingRuleRewrite[] = []
+
+  for (const dynamicRoute of nextAdapterContext.routes.dynamicRoutes) {
+    const isNextData = dynamicRoute.sourceRegex.includes('_next/data')
+
+    if (hasPages && !hasMiddleware) {
+      // this was copied from Vercel adapter, not fully sure what it does - especially with the condition
+      // not applying equavalent right now, but leaving it commented out
+      // if (!route.sourceRegex.includes('_next/data') && !addedNextData404Route) {
+      //   addedNextData404Route = true
+      //   dynamicRoutes.push({
+      //     src: path.posix.join('/', config.basePath || '', '_next/data/(.*)'),
+      //     dest: path.posix.join('/', config.basePath || '', '404'),
+      //     status: 404,
+      //     check: true,
+      //   })
+      // }
+    }
+    dynamicRoutes.push(
+      convertDynamicRouteToRoutingRule(
+        dynamicRoute,
+        isNextData
+          ? `Mapping dynamic route _next/data to entrypoint: ${dynamicRoute.destination}`
+          : `Mapping dynamic route to entrypoint: ${dynamicRoute.destination}`,
+      ),
+    )
+  }
+
   const normalizeNextData: RoutingRuleRewrite[] = shouldDenormalizeJsonDataForMiddleware
     ? [
         {
@@ -150,6 +229,12 @@ export async function generateRoutingRules(
     // - User rewrites
     // - Builder rewrites
 
+    {
+      // this is no-op on its own, it's just marker to be able to run subset of routing rules
+      description: "'entry' routing phase marker",
+      routingPhase: 'entry',
+    },
+
     // priority redirects includes trailing slash redirect
     ...priorityRedirects, // originally: ...convertedPriorityRedirects,
 
@@ -260,7 +345,7 @@ export async function generateRoutingRules(
     // and we don't want to match a catch-all dynamic route
 
     // apply normal dynamic routes
-    // ...convertedDynamicRoutes,
+    ...dynamicRoutes, // originally: ...convertedDynamicRoutes,
 
     // apply x-nextjs-matched-path header and __next_data_catchall rewrite
     // if middleware + pages
diff --git a/src/adapter/build/static-assets.ts b/src/adapter/build/static-assets.ts
index cb0ab55e57..1318d78d88 100644
--- a/src/adapter/build/static-assets.ts
+++ b/src/adapter/build/static-assets.ts
@@ -36,10 +36,14 @@ export async function onBuildComplete(
         }
 
         // register static asset for routing before applying .html extension for pretty urls
-        netlifyAdapterContext.preparedOutputs.staticAssets.push(distPathname)
+        const extensionLessPathname = distPathname
 
         // if pathname is extension-less, but source file has an .html extension, preserve it
         distPathname += distPathname.endsWith('/') ? 'index.html' : '.html'
+
+        netlifyAdapterContext.preparedOutputs.staticAssets.push(distPathname)
+        netlifyAdapterContext.preparedOutputs.staticAssetsAliases[extensionLessPathname] =
+          distPathname
       } else {
         // register static asset for routing
         netlifyAdapterContext.preparedOutputs.staticAssets.push(distPathname)
diff --git a/src/adapter/run/routing.ts b/src/adapter/run/routing.ts
index bcab6028f7..a6bfaed180 100644
--- a/src/adapter/run/routing.ts
+++ b/src/adapter/run/routing.ts
@@ -4,6 +4,8 @@ import type { Context } from '@netlify/edge-functions'
 
 import type { NetlifyAdapterContext } from '../build/types.js'
 
+export type RoutingPhase = 'entry' | 'filesystem' | 'rewrite'
+
 type RoutingRuleBase = {
   /**
    * Human readable description of the rule (for debugging purposes only)
@@ -41,6 +43,8 @@ export type RoutingRuleRewrite = RoutingRuleBase & {
     destination: string
     /** Forced status code for response, if not defined rewrite response status code will be used */
     statusCode?: 200 | 404 | 500
+    /** Phases to re-run after matching this rewrite */
+    rerunRoutingPhases?: RoutingPhase[]
   }
 }
 
@@ -50,8 +54,6 @@ export type RoutingRuleMatchPrimitive = RoutingRuleBase & {
   }
 }
 
-export type RoutingPhase = 'filesystem' | 'rewrite'
-
 export type RoutingPhaseRule = RoutingRuleBase & {
   routingPhase: RoutingPhase
 }
@@ -62,62 +64,73 @@ export type RoutingRule =
   | RoutingPhaseRule
   | RoutingRuleMatchPrimitive
 
-export function testRedirectRewriteRule(rule: RoutingRuleRedirect, request: Request) {
-  const sourceRegexp = new RegExp(rule.match.path)
-  const { pathname } = new URL(request.url)
-  if (sourceRegexp.test(pathname)) {
-    const replaced = pathname.replace(sourceRegexp, rule.apply.destination)
-    return { matched: true, replaced }
+function selectRoutingPhasesRules(routingRules: RoutingRule[], phases: RoutingPhase[]) {
+  const selectedRules: RoutingRule[] = []
+  let currentPhase: RoutingPhase | undefined
+  for (const rule of routingRules) {
+    if ('routingPhase' in rule) {
+      currentPhase = rule.routingPhase
+    } else if (currentPhase && phases.includes(currentPhase)) {
+      selectedRules.push(rule)
+    }
   }
-  return { matched: false }
+
+  return selectedRules
 }
 
 let requestCounter = 0
 
-export async function runNextRouting(
+function normalizeIndex(request: Request, prefix: string) {
+  const currentURL = new URL(request.url)
+  const { pathname } = currentURL
+  if (pathname === '/') {
+    const destURL = new URL('/index', currentURL)
+    console.log(prefix, 'Normalizing "/" to "/index" for routing purposes')
+    return new Request(destURL, request)
+  }
+  return request
+}
+
+// eslint-disable-next-line max-params
+async function match(
   request: Request,
   context: Context,
   routingRules: RoutingRule[],
   outputs: NetlifyAdapterContext['preparedOutputs'],
+  prefix: string,
 ) {
-  if (request.headers.has('x-ntl-routing')) {
-    // don't route multiple times for same request
-    return
-  }
-
-  const prefix = `[${
-    request.headers.get('x-nf-request-id') ??
-    // for ntl serve, we use a combination of timestamp and pid to have a unique id per request as we don't have x-nf-request-id header then
-    // eslint-disable-next-line no-plusplus
-    `${Date.now()} - #${process.pid}:${++requestCounter}`
-  }]`
-
-  console.log(prefix, 'Incoming request for routing:', request.url)
-
-  let currentRequest = new Request(request)
-  currentRequest.headers.set('x-ntl-routing', '1')
+  let currentRequest = normalizeIndex(request, prefix)
   let maybeResponse: Response | undefined
 
+  const currentURL = new URL(currentRequest.url)
+  let { pathname } = currentURL
+
   for (const rule of routingRules) {
     console.log(prefix, 'Evaluating rule:', rule.description ?? JSON.stringify(rule))
     if ('match' in rule) {
-      const currentURL = new URL(currentRequest.url)
-      const { pathname } = currentURL
-
       if ('type' in rule.match) {
-        const pathnameToMatch = pathname === '/' ? '/index' : pathname
-
         if (rule.match.type === 'static-asset-or-function') {
-          let matchedType: 'static-asset' | 'function' | null = null
+          let matchedType: 'static-asset' | 'function' | 'static-asset-alias' | null = null
 
-          if (outputs.staticAssets.includes(pathnameToMatch)) {
+          // below assumes no overlap between static assets (files and aliases) and functions
+          if (outputs.staticAssets.includes(pathname)) {
             matchedType = 'static-asset'
-          } else if (outputs.endpoints.includes(pathnameToMatch)) {
+          } else if (outputs.endpoints.includes(pathname)) {
             matchedType = 'function'
+          } else {
+            const staticAlias = outputs.staticAssetsAliases[pathname]
+            if (staticAlias) {
+              matchedType = 'static-asset-alias'
+              currentRequest = new Request(new URL(staticAlias, currentRequest.url), currentRequest)
+              pathname = staticAlias
+            }
           }
 
           if (matchedType) {
-            console.log(prefix, `Matched static asset or function (${matchedType}):`)
+            console.log(
+              prefix,
+              `Matched static asset or function (${matchedType}): ${pathname} -> ${currentRequest.url}`,
+            )
             maybeResponse = await context.next(currentRequest)
           }
         } else if (rule.match.type === 'image-cdn' && pathname.startsWith('/.netlify/image/')) {
@@ -146,9 +159,18 @@ export async function runNextRouting(
           const replaced = pathname.replace(sourceRegexp, rule.apply.destination)
 
           if (rule.apply.type === 'rewrite') {
-            console.log(prefix, `Rewriting ${pathname} to ${replaced}`)
             const destURL = new URL(replaced, currentURL)
             currentRequest = new Request(destURL, currentRequest)
+
+            if (rule.apply.rerunRoutingPhases) {
+              maybeResponse = await match(
+                currentRequest,
+                context,
+                selectRoutingPhasesRules(routingRules, rule.apply.rerunRoutingPhases),
+                outputs,
+                prefix,
+              )
+            }
           } else {
             console.log(prefix, `Redirecting ${pathname} to ${replaced}`)
             maybeResponse = new Response(null, {
@@ -163,10 +185,44 @@ export async function runNextRouting(
     }
 
     if (maybeResponse) {
-      // for debugging add log prefixes to response headers to make it easy to find logs for a given request
-      maybeResponse.headers.set('x-ntl-log-prefix', prefix)
-      console.log(prefix, 'Serving response', maybeResponse.status)
       return maybeResponse
     }
   }
 }
+
+export async function runNextRouting(
+  request: Request,
+  context: Context,
+  routingRules: RoutingRule[],
+  outputs: NetlifyAdapterContext['preparedOutputs'],
+) {
+  if (request.headers.has('x-ntl-routing')) {
+    // don't route multiple times for same request
+    return
+  }
+
+  const prefix = `[${
+    request.headers.get('x-nf-request-id') ??
+    // for ntl serve, we use a combination of timestamp and pid to have a unique id per request as we don't have x-nf-request-id header then
+    // eslint-disable-next-line no-plusplus
+    `${Date.now()} - #${process.pid}:${++requestCounter}`
+  }]`
+
+  console.log(prefix, 'Incoming request for routing:', request.url)
+
+  const currentRequest = new Request(request)
+  currentRequest.headers.set('x-ntl-routing', '1')
+
+  let maybeResponse = await match(currentRequest, context, routingRules, outputs, prefix)
+
+  if (!maybeResponse) {
+    console.log(prefix, 'No route matched - 404ing')
+    maybeResponse = new Response('Not Found', { status: 404 })
+  }
+
+  // for debugging add log prefixes to response headers to make it easy to find logs for a given request
+  maybeResponse.headers.set('x-ntl-log-prefix', prefix)
+  console.log(prefix, 'Serving response', maybeResponse.status)
+
+  return maybeResponse
+}

From c4651fe3f07b0c665de86adf5462e554d6084695 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Sun, 2 Nov 2025 21:19:16 +0100
Subject: [PATCH 58/75] fix middleware

---
 src/adapter/build/middleware.ts | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/adapter/build/middleware.ts b/src/adapter/build/middleware.ts
index cc556978d0..ca635e71a0 100644
--- a/src/adapter/build/middleware.ts
+++ b/src/adapter/build/middleware.ts
@@ -188,13 +188,13 @@ const writeHandlerFile = async (
     cache: undefined,
     generator: GENERATOR,
     name: DISPLAY_NAME_MIDDLEWARE,
-    pattern: augmentMatchers(middleware, nextConfig).map((matcher) => matcher.regexp),
+    pattern: augmentMatchers(middleware, nextConfig).map((matcher) => matcher.sourceRegex),
   } satisfies IntegrationsConfig
 
   // Writing the function entry file. It wraps the middleware code with the
   // compatibility layer mentioned above.
   await writeFile(
-    join(MIDDLEWARE_FUNCTION_DIR, `middleware.js`),
+    join(MIDDLEWARE_FUNCTION_DIR, `${MIDDLEWARE_FUNCTION_INTERNAL_NAME}.js`),
     /* javascript */ `
     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';

From 6392918f84d4b7fe38c3dcd07d4538cbaced04c5 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Sun, 2 Nov 2025 21:20:46 +0100
Subject: [PATCH 59/75] normalize index at build time

---
 src/adapter/build/pages-and-app-handlers.ts | 39 +++++++++++++++------
 src/adapter/run/routing.ts                  | 16 ++-------
 2 files changed, 32 insertions(+), 23 deletions(-)

diff --git a/src/adapter/build/pages-and-app-handlers.ts b/src/adapter/build/pages-and-app-handlers.ts
index 77b2072afe..b7ddfdba5b 100644
--- a/src/adapter/build/pages-and-app-handlers.ts
+++ b/src/adapter/build/pages-and-app-handlers.ts
@@ -21,7 +21,20 @@ const PAGES_AND_APP_FUNCTION_DIR = join(
   PAGES_AND_APP_FUNCTION_INTERNAL_NAME,
 )
 
-// const abc: OneOfThePaths = 'asa(/abc/)dsa'
+// there is some inconsistency with pathnames sometimes being '/' and sometimes being '/index',
+// but handler seems to expect '/'
+function normalizeIndex(path: string): string {
+  if (path === '/index') {
+    return '/'
+  }
+
+  return path.replace(
+    // if Index is getServerSideProps weird things happen:
+    // /_next/data//.json is produced instead of /_next/data//index.json
+    /^\/_next\/data\/(?[^/]+)\/\.json$/,
+    '/_next/data/$/index.json',
+  )
+}
 
 export async function onBuildComplete(
   nextAdapterContext: OnBuildCompleteContext,
@@ -46,24 +59,30 @@ export async function onBuildComplete(
       }
 
       requiredFiles.add(output.filePath)
-      pathnameToEntry[output.pathname] = relative(nextAdapterContext.repoRoot, output.filePath)
+      pathnameToEntry[normalizeIndex(output.pathname)] = relative(
+        nextAdapterContext.repoRoot,
+        output.filePath,
+      )
     }
   }
 
   for (const prerender of nextAdapterContext.outputs.prerenders) {
-    if (prerender.pathname in pathnameToEntry) {
-      console.log('Skipping prerender, already have route:', prerender.pathname)
-    } else if (prerender.parentOutputId in pathnameToEntry) {
+    const normalizedPathname = normalizeIndex(prerender.pathname)
+    const normalizedParentOutputId = normalizeIndex(prerender.parentOutputId)
+
+    if (normalizedPathname in pathnameToEntry) {
+      console.log('Skipping prerender, already have route:', normalizedPathname)
+    } else if (normalizedParentOutputId in pathnameToEntry) {
       // if we don't have routing for this route yet, add it
       console.log('prerender mapping', {
-        from: prerender.pathname,
-        to: prerender.parentOutputId,
+        from: normalizedPathname,
+        to: normalizedParentOutputId,
       })
-      pathnameToEntry[prerender.pathname] = pathnameToEntry[prerender.parentOutputId]
+      pathnameToEntry[normalizedPathname] = pathnameToEntry[normalizedParentOutputId]
     } else {
       console.warn('Could not find parent output for prerender:', {
-        pathname: prerender,
-        parentOutputId: prerender.parentOutputId,
+        pathname: normalizedPathname,
+        parentOutputId: normalizedParentOutputId,
       })
     }
   }
diff --git a/src/adapter/run/routing.ts b/src/adapter/run/routing.ts
index a6bfaed180..ec40963e21 100644
--- a/src/adapter/run/routing.ts
+++ b/src/adapter/run/routing.ts
@@ -80,17 +80,6 @@ function selectRoutingPhasesRules(routingRules: RoutingRule[], phases: RoutingPh
 
 let requestCounter = 0
 
-function normalizeIndex(request: Request, prefix: string) {
-  const currentURL = new URL(request.url)
-  const { pathname } = currentURL
-  if (pathname === '/') {
-    const destURL = new URL('/index', currentURL)
-    console.log(prefix, 'Normalizing "/" to "/index" for routing purposes')
-    return new Request(destURL, request)
-  }
-  return request
-}
-
 // eslint-disable-next-line max-params
 async function match(
   request: Request,
@@ -99,7 +88,7 @@ async function match(
   outputs: NetlifyAdapterContext['preparedOutputs'],
   prefix: string,
 ) {
-  let currentRequest = normalizeIndex(request, prefix)
+  let currentRequest = request
   let maybeResponse: Response | undefined
 
   const currentURL = new URL(currentRequest.url)
@@ -112,7 +101,8 @@ async function match(
         if (rule.match.type === 'static-asset-or-function') {
           let matchedType: 'static-asset' | 'function' | 'static-asset-alias' | null = null
 
-          // below assumes no overlap between static assets (files and aliases) and functions
+          // below assumes no overlap between static assets (files and aliases) and functions so order of checks "doesn't matter"
+          // unclear what should be precedence if there would ever be overlap
           if (outputs.staticAssets.includes(pathname)) {
             matchedType = 'static-asset'
           } else if (outputs.endpoints.includes(pathname)) {

From 7e71d20aa9353aec25e9adb717c1ad8e057f5aa3 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Sun, 2 Nov 2025 22:49:17 +0100
Subject: [PATCH 60/75] rsc start

---
 src/adapter/build/routing.ts |  94 ++++++++++++++++++++-
 src/adapter/run/routing.ts   | 157 ++++++++++++++++++++++++++---------
 2 files changed, 209 insertions(+), 42 deletions(-)

diff --git a/src/adapter/build/routing.ts b/src/adapter/build/routing.ts
index a59c3fc211..f6da896abb 100644
--- a/src/adapter/build/routing.ts
+++ b/src/adapter/build/routing.ts
@@ -88,7 +88,12 @@ export async function generateRoutingRules(
   netlifyAdapterContext: NetlifyAdapterContext,
 ) {
   const hasMiddleware = Boolean(nextAdapterContext.outputs.middleware)
-  const hasPages = nextAdapterContext.outputs.pages.length !== 0
+  const hasPages =
+    nextAdapterContext.outputs.pages.length !== 0 ||
+    nextAdapterContext.outputs.pagesApi.length !== 0
+  const hasApp =
+    nextAdapterContext.outputs.appPages.length !== 0 ||
+    nextAdapterContext.outputs.appRoutes.length !== 0
   const shouldDenormalizeJsonDataForMiddleware =
     hasMiddleware && hasPages && nextAdapterContext.config.skipMiddlewareUrlNormalize
 
@@ -275,6 +280,51 @@ export async function generateRoutingRules(
     // non-segment prefetch rsc request rewriting
 
     // full rsc request rewriting
+    ...(hasApp
+      ? [
+          {
+            description: 'Normalize RSC requests (index)',
+            match: {
+              path: `^${join('/', nextAdapterContext.config.basePath, '/?$')}`,
+              has: [
+                {
+                  type: 'header',
+                  key: 'rsc',
+                  value: '1',
+                },
+              ],
+            },
+            apply: {
+              type: 'rewrite',
+              destination: `${join('/', nextAdapterContext.config.basePath, '/index.rsc')}`,
+              headers: {
+                vary: 'RSC, Next-Router-State-Tree, Next-Router-Prefetch',
+              },
+            },
+          } satisfies RoutingRuleRewrite,
+          {
+            description: 'Normalize RSC requests',
+            match: {
+              path: `^${join('/', nextAdapterContext.config.basePath, '/((?!.+\\.rsc).+?)(?:/)?$')}`,
+              has: [
+                {
+                  type: 'header',
+                  key: 'rsc',
+                  value: '1',
+                },
+              ],
+            },
+            apply: {
+              type: 'rewrite',
+              destination: `${join('/', nextAdapterContext.config.basePath, '/$1.rsc')}`,
+              headers: {
+                vary: 'RSC, Next-Router-State-Tree, Next-Router-Prefetch',
+              },
+            },
+          } satisfies RoutingRuleRewrite,
+        ]
+      : []),
+
     {
       // originally: { handle: 'filesystem' },
       // this is no-op on its own, it's just marker to be able to run subset of routing rules
@@ -304,11 +354,49 @@ export async function generateRoutingRules(
 
     ...normalizeNextData, // originally: // normalize _next/data if middleware + pages
 
-    // normalize /index.rsc to just /
+    ...(hasApp
+      ? [
+          {
+            // originally: normalize /index.rsc to just /
+            description: 'Normalize index.rsc to just /',
+            match: {
+              path: join('/', nextAdapterContext.config.basePath, '/index(\\.action|\\.rsc)'),
+            },
+            apply: {
+              type: 'rewrite',
+              destination: join('/', nextAdapterContext.config.basePath),
+            },
+          } satisfies RoutingRuleRewrite,
+        ]
+      : []),
 
     // ...convertedRewrites.afterFiles,
 
-    // ensure bad rewrites with /.rsc are fixed
+    ...(hasApp
+      ? [
+          // originally: // ensure bad rewrites with /.rsc are fixed
+          {
+            description: 'Ensure index /.rsc is mapped to /index.rsc',
+            match: {
+              path: join('/', nextAdapterContext.config.basePath, '/\\.rsc$'),
+            },
+            apply: {
+              type: 'rewrite',
+              destination: join('/', nextAdapterContext.config.basePath, `/index.rsc`),
+            },
+          } satisfies RoutingRuleRewrite,
+          {
+            description: 'Ensure index /.rsc is mapped to .rsc',
+            match: {
+              path: join('/', nextAdapterContext.config.basePath, '(.+)/\\.rsc$'),
+            },
+            apply: {
+              type: 'rewrite',
+              destination: join('/', nextAdapterContext.config.basePath, `$1.rsc`),
+            },
+          } satisfies RoutingRuleRewrite,
+        ]
+      : []),
 
     {
       // originally: { handle: 'resource' },
diff --git a/src/adapter/run/routing.ts b/src/adapter/run/routing.ts
index ec40963e21..ffa89dd3b4 100644
--- a/src/adapter/run/routing.ts
+++ b/src/adapter/run/routing.ts
@@ -21,6 +21,7 @@ type Match = {
   has?: {
     type: 'header'
     key: string
+    value?: string
   }[]
 }
 
@@ -45,6 +46,8 @@ export type RoutingRuleRewrite = RoutingRuleBase & {
     statusCode?: 200 | 404 | 500
     /** Phases to re-run after matching this rewrite */
     rerunRoutingPhases?: RoutingPhase[]
+    /** Headers to include in the response */
+    headers?: Record
   }
 }
 
@@ -80,22 +83,33 @@ function selectRoutingPhasesRules(routingRules: RoutingRule[], phases: RoutingPh
 
 let requestCounter = 0
 
+const NOT_A_FETCH_RESPONSE = Symbol('Not a Fetch Response')
+type MaybeResponse = {
+  response?: Response | undefined
+  status?: number | undefined
+  headers?: HeadersInit | undefined
+  [NOT_A_FETCH_RESPONSE]: true
+}
+
 // eslint-disable-next-line max-params
 async function match(
   request: Request,
   context: Context,
   routingRules: RoutingRule[],
   outputs: NetlifyAdapterContext['preparedOutputs'],
-  prefix: string,
-) {
+  prefix: string | undefined,
+  initialResponse: MaybeResponse,
+): Promise {
   let currentRequest = request
-  let maybeResponse: Response | undefined
+  let maybeResponse: MaybeResponse = initialResponse
 
   const currentURL = new URL(currentRequest.url)
   let { pathname } = currentURL
 
   for (const rule of routingRules) {
-    console.log(prefix, 'Evaluating rule:', rule.description ?? JSON.stringify(rule))
+    if (prefix) {
+      console.log(prefix, 'Evaluating rule:', rule.description ?? JSON.stringify(rule))
+    }
     if ('match' in rule) {
       if ('type' in rule.match) {
         if (rule.match.type === 'static-asset-or-function') {
@@ -117,16 +131,26 @@ async function match(
           }
 
           if (matchedType) {
-            console.log(
-              prefix,
-              `Matched static asset or function (${matchedType}): ${pathname} -> ${currentRequest.url}`,
-            )
-            maybeResponse = await context.next(currentRequest)
+            if (prefix) {
+              console.log(
+                prefix,
+                `Matched static asset or function (${matchedType}): ${pathname} -> ${currentRequest.url}`,
+              )
+            }
+            maybeResponse = {
+              ...maybeResponse,
+              response: await context.next(currentRequest),
+            }
           }
         } else if (rule.match.type === 'image-cdn' && pathname.startsWith('/.netlify/image/')) {
-          console.log(prefix, 'Matched image cdn:', pathname)
+          if (prefix) {
+            console.log(prefix, 'Matched image cdn:', pathname)
+          }
 
-          maybeResponse = await context.next(currentRequest)
+          maybeResponse = {
+            ...maybeResponse,
+            response: await context.next(currentRequest),
+          }
         }
       } else if ('apply' in rule) {
         const sourceRegexp = new RegExp(rule.match.path)
@@ -135,9 +159,16 @@ async function match(
           if (rule.match.has) {
             let hasAllMatch = true
             for (const condition of rule.match.has) {
-              if (condition.type === 'header' && !currentRequest.headers.has(condition.key)) {
-                hasAllMatch = false
-                break
+              if (condition.type === 'header') {
+                if (typeof condition.value === 'undefined') {
+                  if (!currentRequest.headers.has(condition.key)) {
+                    hasAllMatch = false
+                    break
+                  }
+                } else if (currentRequest.headers.get(condition.key) !== condition.value) {
+                  hasAllMatch = false
+                  break
+                }
               }
             }
 
@@ -146,12 +177,33 @@ async function match(
             }
           }
 
+          if (prefix) {
+            console.log(prefix, 'Matched rule', pathname, rule)
+          }
+
           const replaced = pathname.replace(sourceRegexp, rule.apply.destination)
 
           if (rule.apply.type === 'rewrite') {
             const destURL = new URL(replaced, currentURL)
             currentRequest = new Request(destURL, currentRequest)
 
+            if (rule.apply.headers) {
+              maybeResponse = {
+                ...maybeResponse,
+                headers: {
+                  ...maybeResponse.headers,
+                  ...rule.apply.headers,
+                },
+              }
+            }
+
+            if (rule.apply.statusCode) {
+              maybeResponse = {
+                ...maybeResponse,
+                status: rule.apply.statusCode,
+              }
+            }
+
             if (rule.apply.rerunRoutingPhases) {
               maybeResponse = await match(
                 currentRequest,
@@ -159,25 +211,35 @@ async function match(
                 selectRoutingPhasesRules(routingRules, rule.apply.rerunRoutingPhases),
                 outputs,
                 prefix,
+                maybeResponse,
               )
             }
           } else {
-            console.log(prefix, `Redirecting ${pathname} to ${replaced}`)
-            maybeResponse = new Response(null, {
-              status: rule.apply.statusCode ?? 307,
-              headers: {
-                Location: replaced,
-              },
-            })
+            if (prefix) {
+              console.log(prefix, `Redirecting ${pathname} to ${replaced}`)
+            }
+            const status = rule.apply.statusCode ?? 307
+            maybeResponse = {
+              ...maybeResponse,
+              status,
+              response: new Response(null, {
+                status,
+                headers: {
+                  Location: replaced,
+                },
+              }),
+            }
           }
         }
       }
     }
 
-    if (maybeResponse) {
+    if (maybeResponse?.response) {
+      // once hit a response short circuit
       return maybeResponse
     }
   }
+  return maybeResponse
 }
 
 export async function runNextRouting(
@@ -191,28 +253,45 @@ export async function runNextRouting(
     return
   }
 
-  const prefix = `[${
-    request.headers.get('x-nf-request-id') ??
-    // for ntl serve, we use a combination of timestamp and pid to have a unique id per request as we don't have x-nf-request-id header then
-    // eslint-disable-next-line no-plusplus
-    `${Date.now()} - #${process.pid}:${++requestCounter}`
-  }]`
-
-  console.log(prefix, 'Incoming request for routing:', request.url)
+  const prefix = request.url.includes('_next/static')
+    ? undefined
+    : `[${
+        request.headers.get('x-nf-request-id') ??
+        // for ntl serve, we use a combination of timestamp and pid to have a unique id per request as we don't have x-nf-request-id header then
+        // eslint-disable-next-line no-plusplus
+        `${Date.now()} - #${process.pid}:${++requestCounter}`
+      }]`
+
+  if (prefix) {
+    console.log(prefix, 'Incoming request for routing:', request.url)
+  }
 
   const currentRequest = new Request(request)
   currentRequest.headers.set('x-ntl-routing', '1')
 
-  let maybeResponse = await match(currentRequest, context, routingRules, outputs, prefix)
-
-  if (!maybeResponse) {
-    console.log(prefix, 'No route matched - 404ing')
-    maybeResponse = new Response('Not Found', { status: 404 })
-  }
+  const maybeResponse = await match(currentRequest, context, routingRules, outputs, prefix, {
+    [NOT_A_FETCH_RESPONSE]: true,
+  })
+
+  const response = maybeResponse.response
+    ? new Response(maybeResponse.response.body, {
+        ...maybeResponse.response,
+        headers: {
+          ...maybeResponse.response.headers,
+          ...maybeResponse.headers,
+        },
+        status: maybeResponse.status ?? maybeResponse.response.status ?? 200,
+      })
+    : new Response('Not Found', {
+        status: maybeResponse?.status ?? 404,
+        headers: maybeResponse?.headers,
+      })
 
   // for debugging add log prefixes to response headers to make it easy to find logs for a given request
-  maybeResponse.headers.set('x-ntl-log-prefix', prefix)
-  console.log(prefix, 'Serving response', maybeResponse.status)
+  if (prefix) {
+    response.headers.set('x-ntl-log-prefix', prefix)
+    console.log(prefix, 'Serving response', response.status)
+  }
 
-  return maybeResponse
+  return response
 }

From f44bd0120b08ddd491ea887b90f0735d8a799e09 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Mon, 3 Nov 2025 10:30:45 +0100
Subject: [PATCH 61/75] add hit routing rules

---
 src/adapter/build/routing.ts |  94 +++++++++++--------
 src/adapter/run/routing.ts   | 177 ++++++++++++++++++++++++++---------
 2 files changed, 191 insertions(+), 80 deletions(-)

diff --git a/src/adapter/build/routing.ts b/src/adapter/build/routing.ts
index f6da896abb..bccd9d4426 100644
--- a/src/adapter/build/routing.ts
+++ b/src/adapter/build/routing.ts
@@ -55,7 +55,7 @@ export function convertRedirectToRoutingRule(
     },
     apply: {
       type: 'redirect',
-      destination: fixDestinationGroupReplacements(redirect.destination, redirect.sourceRegex),
+      destination: redirect.destination,
     },
   } satisfies RoutingRuleRedirect
 }
@@ -74,10 +74,7 @@ export function convertDynamicRouteToRoutingRule(
     },
     apply: {
       type: 'rewrite',
-      destination: fixDestinationGroupReplacements(
-        dynamicRoute.destination,
-        dynamicRoute.sourceRegex,
-      ),
+      destination: dynamicRoute.destination,
       rerunRoutingPhases: ['filesystem', 'rewrite'], // this is attempt to mimic Vercel's check: true
     },
   } satisfies RoutingRuleRewrite
@@ -438,41 +435,62 @@ export async function generateRoutingRules(
     // apply x-nextjs-matched-path header and __next_data_catchall rewrite
     // if middleware + pages
 
-    // { handle: 'hit' },
+    {
+      // originally: handle: 'hit' },
+      // this is no-op on its own, it's just marker to be able to run subset of routing rules
+      description: "'hit' routing phase marker",
+      routingPhase: 'hit',
+      continue: true,
+    },
 
     // Before we handle static files we need to set proper caching headers
-    // {
-    //   // This ensures we only match known emitted-by-Next.js files and not
-    //   // user-emitted files which may be missing a hash in their filename.
-    //   src: path.posix.join(
-    //     '/',
-    //     config.basePath,
-    //     `_next/static/(?:[^/]+/pages|pages|chunks|runtime|css|image|media)/.+`,
-    //   ),
-    //   // Next.js assets contain a hash or entropy in their filenames, so they
-    //   // are guaranteed to be unique and cacheable indefinitely.
-    //   headers: {
-    //     'cache-control': `public,max-age=${MAX_AGE_ONE_YEAR},immutable`,
-    //   },
-    //   continue: true,
-    //   important: true,
-    // },
-    // {
-    //   src: path.posix.join('/', config.basePath, '/index(?:/)?'),
-    //   headers: {
-    //     'x-matched-path': '/',
-    //   },
-    //   continue: true,
-    //   important: true,
-    // },
-    // {
-    //   src: path.posix.join('/', config.basePath, `/((?!index$).*?)(?:/)?`),
-    //   headers: {
-    //     'x-matched-path': '/$1',
-    //   },
-    //   continue: true,
-    //   important: true,
-    // },
+    {
+      // This ensures we only match known emitted-by-Next.js files and not
+      // user-emitted files which may be missing a hash in their filename.
+      description: 'Ensure static files caching headers',
+      match: {
+        path: join(
+          '/',
+          nextAdapterContext.config.basePath || '',
+          `_next/static/(?:[^/]+/pages|pages|chunks|runtime|css|image|media|${nextAdapterContext.buildId})/.+`,
+        ),
+      },
+      apply: {
+        type: 'apply',
+        // Next.js assets contain a hash or entropy in their filenames, so they
+        // are guaranteed to be unique and cacheable indefinitely.
+        headers: {
+          'cache-control': 'public,max-age=31536000,immutable',
+        },
+      },
+      continue: true,
+    },
+    {
+      description: 'Apply x-matched-path header if index',
+      match: {
+        path: join('^/', nextAdapterContext.config.basePath, '/index(?:/)?$'),
+      },
+      apply: {
+        type: 'apply',
+        headers: {
+          'x-matched-path': '/',
+        },
+      },
+      continue: true,
+    },
+    {
+      description: 'Apply x-matched-path header if not index',
+      match: {
+        path: join('^/', nextAdapterContext.config.basePath, '/((?!index$).*?)(?:/)?$'),
+      },
+      apply: {
+        type: 'apply',
+        headers: {
+          'x-matched-path': '/$1',
+        },
+      },
+      continue: true,
+    },
 
     // { handle: 'error' },
 
diff --git a/src/adapter/run/routing.ts b/src/adapter/run/routing.ts
index ffa89dd3b4..27ec6839a9 100644
--- a/src/adapter/run/routing.ts
+++ b/src/adapter/run/routing.ts
@@ -4,13 +4,20 @@ import type { Context } from '@netlify/edge-functions'
 
 import type { NetlifyAdapterContext } from '../build/types.js'
 
-export type RoutingPhase = 'entry' | 'filesystem' | 'rewrite'
+const routingPhases = ['entry', 'filesystem', 'rewrite', 'hit', 'error'] as const
+const routingPhasesWithoutHitOrError = routingPhases.filter(
+  (phase) => phase !== 'hit' && phase !== 'error',
+)
+
+export type RoutingPhase = (typeof routingPhases)[number]
 
 type RoutingRuleBase = {
   /**
    * Human readable description of the rule (for debugging purposes only)
    */
   description: string
+  /** if we should keep going even if we already have potential response */
+  continue?: true
 }
 
 type Match = {
@@ -25,9 +32,21 @@ type Match = {
   }[]
 }
 
+type CommonApply = {
+  /** Headers to include in the response */
+  headers?: Record
+}
+
+export type RoutingRuleApply = RoutingRuleBase & {
+  match: Match
+  apply: CommonApply & {
+    type: 'apply'
+  }
+}
+
 export type RoutingRuleRedirect = RoutingRuleBase & {
   match: Match
-  apply: {
+  apply: CommonApply & {
     type: 'redirect'
     /** Can use capture groups from match.path */
     destination: string
@@ -38,7 +57,7 @@ export type RoutingRuleRedirect = RoutingRuleBase & {
 
 export type RoutingRuleRewrite = RoutingRuleBase & {
   match: Match
-  apply: {
+  apply: CommonApply & {
     type: 'rewrite'
     /** Can use capture groups from match.path */
     destination: string
@@ -46,8 +65,6 @@ export type RoutingRuleRewrite = RoutingRuleBase & {
     statusCode?: 200 | 404 | 500
     /** Phases to re-run after matching this rewrite */
     rerunRoutingPhases?: RoutingPhase[]
-    /** Headers to include in the response */
-    headers?: Record
   }
 }
 
@@ -62,6 +79,7 @@ export type RoutingPhaseRule = RoutingRuleBase & {
 }
 
 export type RoutingRule =
+  | RoutingRuleApply
   | RoutingRuleRedirect
   | RoutingRuleRewrite
   | RoutingPhaseRule
@@ -83,6 +101,7 @@ function selectRoutingPhasesRules(routingRules: RoutingRule[], phases: RoutingPh
 
 let requestCounter = 0
 
+// this is so typescript doesn't think this is fetch response object and rather a builder for a final response
 const NOT_A_FETCH_RESPONSE = Symbol('Not a Fetch Response')
 type MaybeResponse = {
   response?: Response | undefined
@@ -91,15 +110,26 @@ type MaybeResponse = {
   [NOT_A_FETCH_RESPONSE]: true
 }
 
+function replaceGroupReferences(input: string, replacements: Record) {
+  let output = input
+  for (const [key, value] of Object.entries(replacements)) {
+    output = output.replaceAll(key, value)
+  }
+  return output
+}
+
 // eslint-disable-next-line max-params
 async function match(
   request: Request,
   context: Context,
+  /** Filtered rules to match in this call */
   routingRules: RoutingRule[],
+  /** All rules */
+  allRoutingRules: RoutingRule[],
   outputs: NetlifyAdapterContext['preparedOutputs'],
   prefix: string | undefined,
   initialResponse: MaybeResponse,
-): Promise {
+): Promise<{ maybeResponse: MaybeResponse; currentRequest: Request }> {
   let currentRequest = request
   let maybeResponse: MaybeResponse = initialResponse
 
@@ -154,7 +184,8 @@ async function match(
         }
       } else if ('apply' in rule) {
         const sourceRegexp = new RegExp(rule.match.path)
-        if (sourceRegexp.test(pathname)) {
+        const sourceMatch = pathname.match(sourceRegexp)
+        if (sourceMatch) {
           // check additional conditions
           if (rule.match.has) {
             let hasAllMatch = true
@@ -177,26 +208,41 @@ async function match(
             }
           }
 
+          const replacements: Record = {}
+          if (sourceMatch.groups) {
+            for (const [key, value] of Object.entries(sourceMatch.groups)) {
+              replacements[`$${key}`] = value
+            }
+          }
+          for (const [index, element] of sourceMatch.entries()) {
+            replacements[`$${index}`] = element ?? ''
+          }
+
           if (prefix) {
-            console.log(prefix, 'Matched rule', pathname, rule)
+            console.log(prefix, 'Matched rule', pathname, rule, sourceMatch, replacements)
           }
 
-          const replaced = pathname.replace(sourceRegexp, rule.apply.destination)
+          if (rule.apply.headers) {
+            maybeResponse = {
+              ...maybeResponse,
+              headers: {
+                ...maybeResponse.headers,
+                ...Object.fromEntries(
+                  Object.entries(rule.apply.headers).map(([key, value]) => {
+                    return [key, replaceGroupReferences(value, replacements)]
+                  }),
+                ),
+              },
+            }
+          }
 
           if (rule.apply.type === 'rewrite') {
+            const replaced = replaceGroupReferences(rule.apply.destination, replacements)
+
+            // pathname.replace(sourceRegexp, rule.apply.destination)
             const destURL = new URL(replaced, currentURL)
             currentRequest = new Request(destURL, currentRequest)
 
-            if (rule.apply.headers) {
-              maybeResponse = {
-                ...maybeResponse,
-                headers: {
-                  ...maybeResponse.headers,
-                  ...rule.apply.headers,
-                },
-              }
-            }
-
             if (rule.apply.statusCode) {
               maybeResponse = {
                 ...maybeResponse,
@@ -205,16 +251,19 @@ async function match(
             }
 
             if (rule.apply.rerunRoutingPhases) {
-              maybeResponse = await match(
+              const { maybeResponse: updatedMaybeResponse } = await match(
                 currentRequest,
                 context,
                 selectRoutingPhasesRules(routingRules, rule.apply.rerunRoutingPhases),
+                allRoutingRules,
                 outputs,
                 prefix,
                 maybeResponse,
               )
+              maybeResponse = updatedMaybeResponse
             }
-          } else {
+          } else if (rule.apply.type === 'redirect') {
+            const replaced = pathname.replace(sourceRegexp, rule.apply.destination)
             if (prefix) {
               console.log(prefix, `Redirecting ${pathname} to ${replaced}`)
             }
@@ -234,12 +283,12 @@ async function match(
       }
     }
 
-    if (maybeResponse?.response) {
+    if (maybeResponse?.response && !rule.continue) {
       // once hit a response short circuit
-      return maybeResponse
+      return { maybeResponse, currentRequest }
     }
   }
-  return maybeResponse
+  return { maybeResponse, currentRequest }
 }
 
 export async function runNextRouting(
@@ -266,26 +315,70 @@ export async function runNextRouting(
     console.log(prefix, 'Incoming request for routing:', request.url)
   }
 
-  const currentRequest = new Request(request)
+  let currentRequest = new Request(request)
   currentRequest.headers.set('x-ntl-routing', '1')
 
-  const maybeResponse = await match(currentRequest, context, routingRules, outputs, prefix, {
-    [NOT_A_FETCH_RESPONSE]: true,
-  })
-
-  const response = maybeResponse.response
-    ? new Response(maybeResponse.response.body, {
-        ...maybeResponse.response,
-        headers: {
-          ...maybeResponse.response.headers,
-          ...maybeResponse.headers,
-        },
-        status: maybeResponse.status ?? maybeResponse.response.status ?? 200,
-      })
-    : new Response('Not Found', {
-        status: maybeResponse?.status ?? 404,
-        headers: maybeResponse?.headers,
-      })
+  let { maybeResponse, currentRequest: updatedCurrentRequest } = await match(
+    currentRequest,
+    context,
+    selectRoutingPhasesRules(routingRules, routingPhasesWithoutHitOrError),
+    routingRules,
+    outputs,
+    prefix,
+    {
+      [NOT_A_FETCH_RESPONSE]: true,
+    },
+  )
+  currentRequest = updatedCurrentRequest
+
+  let response: Response
+
+  if (maybeResponse.response) {
+    const initialResponse = maybeResponse.response
+    const { maybeResponse: updatedMaybeResponse } = await match(
+      currentRequest,
+      context,
+      selectRoutingPhasesRules(routingRules, ['hit']),
+      routingRules,
+      outputs,
+      prefix,
+      maybeResponse,
+    )
+    maybeResponse = updatedMaybeResponse
+
+    const finalResponse = maybeResponse.response ?? initialResponse
+
+    response = new Response(finalResponse.body, {
+      ...finalResponse,
+      headers: {
+        ...finalResponse.headers,
+        ...maybeResponse.headers,
+      },
+      status: maybeResponse.status ?? finalResponse.status ?? 200,
+    })
+  } else {
+    const { maybeResponse: updatedMaybeResponse } = await match(
+      currentRequest,
+      context,
+      selectRoutingPhasesRules(routingRules, ['error']),
+      routingRules,
+      outputs,
+      prefix,
+      { ...maybeResponse, status: 404 },
+    )
+    maybeResponse = updatedMaybeResponse
+
+    const finalResponse = maybeResponse.response ?? new Response('Not Found', { status: 404 })
+
+    response = new Response(finalResponse.body, {
+      ...finalResponse,
+      headers: {
+        ...finalResponse.headers,
+        ...maybeResponse.headers,
+      },
+      status: maybeResponse.status ?? finalResponse.status ?? 200,
+    })
+  }
 
   // for debugging add log prefixes to response headers to make it easy to find logs for a given request
   if (prefix) {

From a37aa12ae91b8d1add4a3d9e992fe1f6be669fa5 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Mon, 3 Nov 2025 12:31:51 +0100
Subject: [PATCH 62/75] allow non-next js routes to match

---
 src/adapter/run/routing.ts | 10 +++++++++-
 1 file changed, 9 insertions(+), 1 deletion(-)

diff --git a/src/adapter/run/routing.ts b/src/adapter/run/routing.ts
index 27ec6839a9..ae643cfb42 100644
--- a/src/adapter/run/routing.ts
+++ b/src/adapter/run/routing.ts
@@ -331,9 +331,17 @@ export async function runNextRouting(
   )
   currentRequest = updatedCurrentRequest
 
+  if (!maybeResponse.response) {
+    // check other things
+    maybeResponse = {
+      ...maybeResponse,
+      response: await context.next(currentRequest),
+    }
+  }
+
   let response: Response
 
-  if (maybeResponse.response) {
+  if (maybeResponse.response && (maybeResponse.status ?? maybeResponse.response?.status !== 404)) {
     const initialResponse = maybeResponse.response
     const { maybeResponse: updatedMaybeResponse } = await match(
       currentRequest,

From da090ce0b742fffe09cb0c5a78e3695472b4975f Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Mon, 3 Nov 2025 13:07:00 +0100
Subject: [PATCH 63/75] fix response header and applied header merging (fixes
 redirects that were missing location header)

---
 src/adapter/run/routing.ts | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/adapter/run/routing.ts b/src/adapter/run/routing.ts
index ae643cfb42..1882e8938d 100644
--- a/src/adapter/run/routing.ts
+++ b/src/adapter/run/routing.ts
@@ -359,7 +359,7 @@ export async function runNextRouting(
     response = new Response(finalResponse.body, {
       ...finalResponse,
       headers: {
-        ...finalResponse.headers,
+        ...Object.fromEntries(finalResponse.headers.entries()),
         ...maybeResponse.headers,
       },
       status: maybeResponse.status ?? finalResponse.status ?? 200,
@@ -381,7 +381,7 @@ export async function runNextRouting(
     response = new Response(finalResponse.body, {
       ...finalResponse,
       headers: {
-        ...finalResponse.headers,
+        ...Object.fromEntries(finalResponse.headers.entries()),
         ...maybeResponse.headers,
       },
       status: maybeResponse.status ?? finalResponse.status ?? 200,

From 236530162611649cc2609798367ccb392e772082 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Mon, 3 Nov 2025 19:04:35 +0100
Subject: [PATCH 64/75] use buildId from adapter context and don't read on our
 own

---
 src/adapter/build/netlify-adapter-context.ts | 15 +----
 src/adapter/build/routing.ts                 | 59 ++++++++++++++++++--
 src/adapter/build/static-assets.ts           | 16 +++++-
 3 files changed, 71 insertions(+), 19 deletions(-)

diff --git a/src/adapter/build/netlify-adapter-context.ts b/src/adapter/build/netlify-adapter-context.ts
index a649169001..a5005c78a1 100644
--- a/src/adapter/build/netlify-adapter-context.ts
+++ b/src/adapter/build/netlify-adapter-context.ts
@@ -1,18 +1,7 @@
-import { readFile } from 'fs/promises'
-import { join } from 'path/posix'
-
-import type { FrameworksAPIConfig, OnBuildCompleteContext } from './types.js'
-
-export function createNetlifyAdapterContext(nextAdapterContext: OnBuildCompleteContext) {
-  let buildId: string | undefined
+import type { FrameworksAPIConfig } from './types.js'
 
+export function createNetlifyAdapterContext() {
   return {
-    async getBuildId() {
-      if (!buildId) {
-        buildId = await readFile(join(nextAdapterContext.distDir, 'BUILD_ID'), 'utf-8')
-      }
-      return buildId
-    },
     frameworksAPIConfig: undefined as FrameworksAPIConfig | undefined,
     preparedOutputs: {
       staticAssets: [] as string[],
diff --git a/src/adapter/build/routing.ts b/src/adapter/build/routing.ts
index bccd9d4426..d9e8c00c38 100644
--- a/src/adapter/build/routing.ts
+++ b/src/adapter/build/routing.ts
@@ -3,7 +3,12 @@ import { join } from 'node:path/posix'
 
 import { glob } from 'fast-glob'
 
-import type { RoutingRule, RoutingRuleRedirect, RoutingRuleRewrite } from '../run/routing.js'
+import type {
+  RoutingRule,
+  RoutingRuleApply,
+  RoutingRuleRedirect,
+  RoutingRuleRewrite,
+} from '../run/routing.js'
 
 import {
   DISPLAY_NAME_ROUTING,
@@ -80,10 +85,18 @@ export function convertDynamicRouteToRoutingRule(
   } satisfies RoutingRuleRewrite
 }
 
+const matchOperatorsRegex = /[|\\{}()[\]^$+*?.-]/g
+
+export function escapeStringRegexp(str: string): string {
+  return str.replace(matchOperatorsRegex, '\\$&')
+}
+
 export async function generateRoutingRules(
   nextAdapterContext: OnBuildCompleteContext,
   netlifyAdapterContext: NetlifyAdapterContext,
 ) {
+  const escapedBuildId = escapeStringRegexp(nextAdapterContext.buildId)
+
   const hasMiddleware = Boolean(nextAdapterContext.outputs.middleware)
   const hasPages =
     nextAdapterContext.outputs.pages.length !== 0 ||
@@ -148,7 +161,7 @@ export async function generateRoutingRules(
         {
           description: 'Normalize _next/data',
           match: {
-            path: `^${nextAdapterContext.config.basePath}/_next/data/${await netlifyAdapterContext.getBuildId()}/(.*)\\.json`,
+            path: `^${nextAdapterContext.config.basePath}/_next/data/${escapedBuildId}/(.*)\\.json`,
             has: [
               {
                 type: 'header',
@@ -211,7 +224,7 @@ export async function generateRoutingRules(
           },
           apply: {
             type: 'rewrite',
-            destination: `${nextAdapterContext.config.basePath}/_next/data/${await netlifyAdapterContext.getBuildId()}/$1.json`,
+            destination: `${nextAdapterContext.config.basePath}/_next/data/${nextAdapterContext.buildId}/$1.json`,
           },
         },
       ]
@@ -432,8 +445,44 @@ export async function generateRoutingRules(
     // apply normal dynamic routes
     ...dynamicRoutes, // originally: ...convertedDynamicRoutes,
 
-    // apply x-nextjs-matched-path header and __next_data_catchall rewrite
-    // if middleware + pages
+    ...(hasMiddleware && hasPages
+      ? [
+          // apply x-nextjs-matched-path header
+          // if middleware + pages
+          {
+            description: 'Apply x-nextjs-matched-path header if middleware + pages',
+            match: {
+              path: `^${join(
+                '/',
+                nextAdapterContext.config.basePath,
+                '/_next/data/',
+                escapedBuildId,
+                '/(.*).json',
+              )}`,
+            },
+            apply: {
+              type: 'apply',
+              headers: {
+                'x-nextjs-matched-path': '/$1',
+              },
+            },
+            continue: true,
+          } satisfies RoutingRuleApply,
+          {
+            // apply __next_data_catchall rewrite
+            // if middleware + pages
+            description: 'Apply __next_data_catchall rewrite if middleware + pages',
+            match: {
+              path: ``,
+            },
+            apply: {
+              type: 'rewrite',
+              destination: '__next_data_catchall',
+              statusCode: 200,
+            },
+          } satisfies RoutingRule,
+        ]
+      : []),
 
     {
       // originally: handle: 'hit' },
diff --git a/src/adapter/build/static-assets.ts b/src/adapter/build/static-assets.ts
index 1318d78d88..a66abbf182 100644
--- a/src/adapter/build/static-assets.ts
+++ b/src/adapter/build/static-assets.ts
@@ -19,7 +19,7 @@ export async function onBuildComplete(
           NEXT_RUNTIME_STATIC_ASSETS,
           '_next',
           'data',
-          await netlifyAdapterContext.getBuildId(),
+          nextAdapterContext.buildId,
           // eslint-disable-next-line unicorn/no-nested-ternary
           `${distPathname === '/' ? 'index' : distPathname.endsWith('/') ? distPathname.slice(0, -1) : distPathname}.json`,
         )
@@ -65,5 +65,19 @@ export async function onBuildComplete(
     await cp('public', NEXT_RUNTIME_STATIC_ASSETS, {
       recursive: true,
     })
+    // TODO: glob things to add to preparedOutputs.staticAssets
+  }
+
+  const hasMiddleware = Boolean(nextAdapterContext.outputs.middleware)
+  const hasPages =
+    nextAdapterContext.outputs.pages.length !== 0 ||
+    nextAdapterContext.outputs.pagesApi.length !== 0
+
+  if (hasMiddleware && hasPages) {
+    // create empty __next_data_catchall json file used for fully static pages
+    await writeFile(join(NEXT_RUNTIME_STATIC_ASSETS, '__next_data_catchall.json'), '{}')
+    netlifyAdapterContext.preparedOutputs.staticAssets.push('__next_data_catchall.json')
+    netlifyAdapterContext.preparedOutputs.staticAssetsAliases.__next_data_catchall =
+      '__next_data_catchall.json'
   }
 }

From d4d4e590375b54c84ef51076f70de21c8716f6a5 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Mon, 3 Nov 2025 19:05:02 +0100
Subject: [PATCH 65/75] fix: lowercase nf paths

---
 src/adapter/build/pages-and-app-handlers.ts | 2 +-
 src/adapter/run/routing.ts                  | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/adapter/build/pages-and-app-handlers.ts b/src/adapter/build/pages-and-app-handlers.ts
index b7ddfdba5b..a4ef5245a3 100644
--- a/src/adapter/build/pages-and-app-handlers.ts
+++ b/src/adapter/build/pages-and-app-handlers.ts
@@ -104,7 +104,7 @@ export async function onBuildComplete(
   await copyRuntime(join(PAGES_AND_APP_FUNCTION_DIR, RUNTIME_DIR))
 
   const functionConfig = {
-    path: Object.keys(pathnameToEntry),
+    path: Object.keys(pathnameToEntry).map((pathname) => pathname.toLowerCase()),
     nodeBundler: 'none',
     includedFiles: ['**'],
     generator: GENERATOR,
diff --git a/src/adapter/run/routing.ts b/src/adapter/run/routing.ts
index 1882e8938d..9596027075 100644
--- a/src/adapter/run/routing.ts
+++ b/src/adapter/run/routing.ts
@@ -149,7 +149,7 @@ async function match(
           // unclear what should be precedence if there would ever be overlap
           if (outputs.staticAssets.includes(pathname)) {
             matchedType = 'static-asset'
-          } else if (outputs.endpoints.includes(pathname)) {
+          } else if (outputs.endpoints.includes(pathname.toLowerCase())) {
             matchedType = 'function'
           } else {
             const staticAlias = outputs.staticAssetsAliases[pathname]

From ac23f0d72d1137572c4393f550aab666fd1af089 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Mon, 3 Nov 2025 20:11:21 +0100
Subject: [PATCH 66/75] i18n rewrite to default locale if path is not locale
 prefixed

---
 src/adapter/build/routing.ts | 110 ++++++++++++++++++++++++++---------
 1 file changed, 82 insertions(+), 28 deletions(-)

diff --git a/src/adapter/build/routing.ts b/src/adapter/build/routing.ts
index d9e8c00c38..746dc95182 100644
--- a/src/adapter/build/routing.ts
+++ b/src/adapter/build/routing.ts
@@ -18,34 +18,6 @@ import {
 } from './constants.js'
 import type { NetlifyAdapterContext, OnBuildCompleteContext } from './types.js'
 
-function fixDestinationGroupReplacements(destination: string, sourceRegex: string): string {
-  // convert $nxtPslug to $ etc
-
-  // find all capturing groups in sourceRegex
-  const segments = [...sourceRegex.matchAll(/\(\?<(?[^>]+)>/g)]
-
-  let adjustedDestination = destination
-  for (const segment of segments) {
-    if (segment.groups?.segment_name) {
-      adjustedDestination = adjustedDestination.replaceAll(
-        `$${segment.groups.segment_name}`,
-        `$<${segment.groups.segment_name}>`,
-      )
-    }
-  }
-
-  if (adjustedDestination !== destination) {
-    console.log('fixing named captured group replacement', {
-      sourceRegex,
-      segments,
-      destination,
-      adjustedDestination,
-    })
-  }
-
-  return adjustedDestination
-}
-
 export function convertRedirectToRoutingRule(
   redirect: Pick<
     OnBuildCompleteContext['routes']['redirects'][number],
@@ -256,6 +228,88 @@ export async function generateRoutingRules(
     ...normalizeNextData, // originally: // normalize _next/data if middleware + pages
 
     // i18n prefixing routes
+    ...(nextAdapterContext.config.i18n
+      ? [
+          // i18n domain handling - not implementing for now
+          // Handle auto-adding current default locale to path based on $wildcard
+          // This is split into two rules to avoid matching the `/index` route as it causes issues with trailing slash redirect
+          // {
+          //   description: 'stuff1',
+          //   match: {
+          //     path: `^${join(
+          //       '/',
+          //       nextAdapterContext.config.basePath,
+          //       '/',
+          //     )}(?!(?:_next/.*|${nextAdapterContext.config.i18n.locales
+          //       .map((locale) => escapeStringRegexp(locale))
+          //       .join('|')})(?:/.*|$))$`,
+          //   },
+          //   apply: {
+          //     type: 'rewrite',
+          //     // we aren't able to ensure trailing slash mode here
+          //     // so ensure this comes after the trailing slash redirect
+          //     destination: `${
+          //       nextAdapterContext.config.basePath && nextAdapterContext.config.basePath !== '/'
+          //         ? join('/', nextAdapterContext.config.basePath)
+          //         : ''
+          //     }$wildcard${nextAdapterContext.config.trailingSlash ? '/' : ''}`,
+          //   },
+          // } satisfies RoutingRuleRewrite,
+
+          // Handle redirecting to locale paths based on NEXT_LOCALE cookie or Accept-Language header
+          // eslint-disable-next-line no-negated-condition
+          ...(nextAdapterContext.config.i18n.localeDetection !== false
+            ? [
+                // TODO: implement locale detection
+                // {
+                //   description: 'Detect locale on root path, redirect and set cookie',
+                //   match: {
+                //     path: '/',
+                //   },
+                //   apply: {
+                //     type: 'apply',
+                //   },
+                // } satisfies RoutingRuleApply,
+              ]
+            : []),
+
+          {
+            description: 'Prefix default locale to index',
+            match: {
+              path: `^${join('/', nextAdapterContext.config.basePath)}$`,
+            },
+            apply: {
+              type: 'rewrite',
+              destination: join(
+                '/',
+                nextAdapterContext.config.basePath,
+                nextAdapterContext.config.i18n.defaultLocale,
+              ),
+            },
+          } satisfies RoutingRuleRewrite,
+          {
+            description: 'Auto-prefix non-locale path with default locale',
+            match: {
+              path: `^${join(
+                '/',
+                nextAdapterContext.config.basePath,
+                '/',
+              )}(?!(?:_next/.*|${nextAdapterContext.config.i18n.locales
+                .map((locale) => escapeStringRegexp(locale))
+                .join('|')})(?:/.*|$))(.*)$`,
+            },
+            apply: {
+              type: 'rewrite',
+              destination: join(
+                '/',
+                nextAdapterContext.config.basePath,
+                nextAdapterContext.config.i18n.defaultLocale,
+                '$1',
+              ),
+            },
+          } satisfies RoutingRuleRewrite,
+        ]
+      : []),
 
     // ...convertedHeaders,
 

From 867f7eff0740bbec48012f5992365225ea48d552 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Mon, 3 Nov 2025 20:14:52 +0100
Subject: [PATCH 67/75] missing src path for __next_data_catchall

---
 src/adapter/build/routing.ts | 8 +++++++-
 1 file changed, 7 insertions(+), 1 deletion(-)

diff --git a/src/adapter/build/routing.ts b/src/adapter/build/routing.ts
index 746dc95182..d8b2a412d2 100644
--- a/src/adapter/build/routing.ts
+++ b/src/adapter/build/routing.ts
@@ -527,7 +527,13 @@ export async function generateRoutingRules(
             // if middleware + pages
             description: 'Apply __next_data_catchall rewrite if middleware + pages',
             match: {
-              path: ``,
+              path: `^${join(
+                '/',
+                nextAdapterContext.config.basePath,
+                '/_next/data/',
+                escapedBuildId,
+                '/(.*).json',
+              )}`,
             },
             apply: {
               type: 'rewrite',

From 2e15e26f6c96531b89b25d59c6f81df215d0a99c Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Tue, 4 Nov 2025 09:54:10 +0100
Subject: [PATCH 68/75] adjust catchall

---
 src/adapter/build/routing.ts       | 2 +-
 src/adapter/build/static-assets.ts | 4 +---
 2 files changed, 2 insertions(+), 4 deletions(-)

diff --git a/src/adapter/build/routing.ts b/src/adapter/build/routing.ts
index d8b2a412d2..99187450fb 100644
--- a/src/adapter/build/routing.ts
+++ b/src/adapter/build/routing.ts
@@ -537,7 +537,7 @@ export async function generateRoutingRules(
             },
             apply: {
               type: 'rewrite',
-              destination: '__next_data_catchall',
+              destination: '/__next_data_catchall.json',
               statusCode: 200,
             },
           } satisfies RoutingRule,
diff --git a/src/adapter/build/static-assets.ts b/src/adapter/build/static-assets.ts
index a66abbf182..bb1acf0e9c 100644
--- a/src/adapter/build/static-assets.ts
+++ b/src/adapter/build/static-assets.ts
@@ -76,8 +76,6 @@ export async function onBuildComplete(
   if (hasMiddleware && hasPages) {
     // create empty __next_data_catchall json file used for fully static pages
     await writeFile(join(NEXT_RUNTIME_STATIC_ASSETS, '__next_data_catchall.json'), '{}')
-    netlifyAdapterContext.preparedOutputs.staticAssets.push('__next_data_catchall.json')
-    netlifyAdapterContext.preparedOutputs.staticAssetsAliases.__next_data_catchall =
-      '__next_data_catchall.json'
+    netlifyAdapterContext.preparedOutputs.staticAssets.push('/__next_data_catchall.json')
   }
 }

From 0438780651b4d6c7bb919ad676fcbe598411c71f Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Tue, 4 Nov 2025 09:56:40 +0100
Subject: [PATCH 69/75] fix rule evaluation continuation

---
 src/adapter/run/routing.ts | 176 ++++++++++++++++++++-----------------
 1 file changed, 93 insertions(+), 83 deletions(-)

diff --git a/src/adapter/run/routing.ts b/src/adapter/run/routing.ts
index 9596027075..5996b65800 100644
--- a/src/adapter/run/routing.ts
+++ b/src/adapter/run/routing.ts
@@ -22,7 +22,7 @@ type RoutingRuleBase = {
 
 type Match = {
   /** Regex */
-  path: string
+  path?: string
 
   /** additional conditions */
   has?: {
@@ -140,7 +140,10 @@ async function match(
     if (prefix) {
       console.log(prefix, 'Evaluating rule:', rule.description ?? JSON.stringify(rule))
     }
+
     if ('match' in rule) {
+      let matched = false
+
       if ('type' in rule.match) {
         if (rule.match.type === 'static-asset-or-function') {
           let matchedType: 'static-asset' | 'function' | 'static-asset-alias' | null = null
@@ -171,6 +174,7 @@ async function match(
               ...maybeResponse,
               response: await context.next(currentRequest),
             }
+            matched = true
           }
         } else if (rule.match.type === 'image-cdn' && pathname.startsWith('/.netlify/image/')) {
           if (prefix) {
@@ -181,111 +185,117 @@ async function match(
             ...maybeResponse,
             response: await context.next(currentRequest),
           }
+          matched = true
         }
       } else if ('apply' in rule) {
-        const sourceRegexp = new RegExp(rule.match.path)
-        const sourceMatch = pathname.match(sourceRegexp)
-        if (sourceMatch) {
-          // check additional conditions
-          if (rule.match.has) {
-            let hasAllMatch = true
-            for (const condition of rule.match.has) {
-              if (condition.type === 'header') {
-                if (typeof condition.value === 'undefined') {
-                  if (!currentRequest.headers.has(condition.key)) {
-                    hasAllMatch = false
-                    break
-                  }
-                } else if (currentRequest.headers.get(condition.key) !== condition.value) {
-                  hasAllMatch = false
-                  break
-                }
+        const replacements: Record = {}
+
+        if (rule.match.path) {
+          const sourceRegexp = new RegExp(rule.match.path)
+          const sourceMatch = pathname.match(sourceRegexp)
+          if (sourceMatch) {
+            if (sourceMatch.groups) {
+              for (const [key, value] of Object.entries(sourceMatch.groups)) {
+                replacements[`$${key}`] = value
               }
             }
-
-            if (!hasAllMatch) {
-              continue
+            for (const [index, element] of sourceMatch.entries()) {
+              replacements[`$${index}`] = element ?? ''
             }
+          } else {
+            continue
           }
+        }
 
-          const replacements: Record = {}
-          if (sourceMatch.groups) {
-            for (const [key, value] of Object.entries(sourceMatch.groups)) {
-              replacements[`$${key}`] = value
+        if (rule.match.has) {
+          let hasAllMatch = true
+          for (const condition of rule.match.has) {
+            if (condition.type === 'header') {
+              if (typeof condition.value === 'undefined') {
+                if (!currentRequest.headers.has(condition.key)) {
+                  hasAllMatch = false
+                  break
+                }
+              } else if (currentRequest.headers.get(condition.key) !== condition.value) {
+                hasAllMatch = false
+                break
+              }
             }
           }
-          for (const [index, element] of sourceMatch.entries()) {
-            replacements[`$${index}`] = element ?? ''
-          }
 
-          if (prefix) {
-            console.log(prefix, 'Matched rule', pathname, rule, sourceMatch, replacements)
+          if (!hasAllMatch) {
+            continue
           }
+        }
 
-          if (rule.apply.headers) {
-            maybeResponse = {
-              ...maybeResponse,
-              headers: {
-                ...maybeResponse.headers,
-                ...Object.fromEntries(
-                  Object.entries(rule.apply.headers).map(([key, value]) => {
-                    return [key, replaceGroupReferences(value, replacements)]
-                  }),
-                ),
-              },
-            }
-          }
+        if (prefix) {
+          console.log(prefix, 'Matched rule', pathname, rule, replacements)
+        }
 
-          if (rule.apply.type === 'rewrite') {
-            const replaced = replaceGroupReferences(rule.apply.destination, replacements)
+        if (rule.apply.headers) {
+          maybeResponse = {
+            ...maybeResponse,
+            headers: {
+              ...maybeResponse.headers,
+              ...Object.fromEntries(
+                Object.entries(rule.apply.headers).map(([key, value]) => {
+                  return [key, replaceGroupReferences(value, replacements)]
+                }),
+              ),
+            },
+          }
+        }
 
-            // pathname.replace(sourceRegexp, rule.apply.destination)
-            const destURL = new URL(replaced, currentURL)
-            currentRequest = new Request(destURL, currentRequest)
+        if (rule.apply.type === 'rewrite') {
+          const replaced = replaceGroupReferences(rule.apply.destination, replacements)
 
-            if (rule.apply.statusCode) {
-              maybeResponse = {
-                ...maybeResponse,
-                status: rule.apply.statusCode,
-              }
-            }
+          // pathname.replace(sourceRegexp, rule.apply.destination)
+          const destURL = new URL(replaced, currentURL)
+          currentRequest = new Request(destURL, currentRequest)
 
-            if (rule.apply.rerunRoutingPhases) {
-              const { maybeResponse: updatedMaybeResponse } = await match(
-                currentRequest,
-                context,
-                selectRoutingPhasesRules(routingRules, rule.apply.rerunRoutingPhases),
-                allRoutingRules,
-                outputs,
-                prefix,
-                maybeResponse,
-              )
-              maybeResponse = updatedMaybeResponse
-            }
-          } else if (rule.apply.type === 'redirect') {
-            const replaced = pathname.replace(sourceRegexp, rule.apply.destination)
-            if (prefix) {
-              console.log(prefix, `Redirecting ${pathname} to ${replaced}`)
-            }
-            const status = rule.apply.statusCode ?? 307
+          if (rule.apply.statusCode) {
             maybeResponse = {
               ...maybeResponse,
-              status,
-              response: new Response(null, {
-                status,
-                headers: {
-                  Location: replaced,
-                },
-              }),
+              status: rule.apply.statusCode,
             }
           }
+
+          if (rule.apply.rerunRoutingPhases) {
+            const { maybeResponse: updatedMaybeResponse } = await match(
+              currentRequest,
+              context,
+              selectRoutingPhasesRules(routingRules, rule.apply.rerunRoutingPhases),
+              allRoutingRules,
+              outputs,
+              prefix,
+              maybeResponse,
+            )
+            maybeResponse = updatedMaybeResponse
+          }
+        } else if (rule.apply.type === 'redirect') {
+          const replaced = replaceGroupReferences(rule.apply.destination, replacements)
+          if (prefix) {
+            console.log(prefix, `Redirecting ${pathname} to ${replaced}`)
+          }
+          const status = rule.apply.statusCode ?? 307
+          maybeResponse = {
+            ...maybeResponse,
+            status,
+            response: new Response(null, {
+              status,
+              headers: {
+                Location: replaced,
+              },
+            }),
+          }
         }
+        matched = true
       }
-    }
 
-    if (maybeResponse?.response && !rule.continue) {
-      // once hit a response short circuit
-      return { maybeResponse, currentRequest }
+      if (matched && !rule.continue) {
+        // once hit a match short circuit
+        return { maybeResponse, currentRequest }
+      }
     }
   }
   return { maybeResponse, currentRequest }

From d0556999cf9998bc9574bee3733c327e535a7e95 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Thu, 6 Nov 2025 11:45:07 +0100
Subject: [PATCH 70/75] middleware simplification and unification with routing
 EF

---
 edge-runtime/lib/middleware.ts              |  38 -
 edge-runtime/lib/next-request.ts            | 140 +--
 edge-runtime/lib/response.ts                | 353 +++-----
 edge-runtime/lib/routing.ts                 | 922 ++++++++++----------
 edge-runtime/lib/types.ts                   |   7 +
 edge-runtime/lib/util.ts                    | 150 ++--
 edge-runtime/middleware.ts                  |  58 +-
 src/adapter/build/middleware.ts             | 132 ++-
 src/adapter/build/pages-and-app-handlers.ts |  18 +-
 src/adapter/build/routing.ts                |  54 +-
 src/adapter/run/routing.ts                  | 711 +++++++++------
 11 files changed, 1219 insertions(+), 1364 deletions(-)
 create mode 100644 edge-runtime/lib/types.ts

diff --git a/edge-runtime/lib/middleware.ts b/edge-runtime/lib/middleware.ts
index f2ed78e861..5055108933 100644
--- a/edge-runtime/lib/middleware.ts
+++ b/edge-runtime/lib/middleware.ts
@@ -1,43 +1,5 @@
-import type { Context } from '@netlify/edge-functions'
-
-import type { ElementHandlers } from '../vendor/deno.land/x/htmlrewriter@v1.0.0/src/index.ts'
 import { getCookies } from '../vendor/deno.land/std@0.175.0/http/cookie.ts'
 
-type NextDataTransform = (data: T) => T
-
-interface ResponseCookies {
-  // This is non-standard that Next.js adds.
-  // https://github.com/vercel/next.js/blob/de08f8b3d31ef45131dad97a7d0e95fa01001167/packages/next/src/compiled/@edge-runtime/cookies/index.js#L158
-  readonly _headers: Headers
-}
-
-interface MiddlewareResponse extends Response {
-  originResponse: Response
-  dataTransforms: NextDataTransform[]
-  elementHandlers: Array<[selector: string, handlers: ElementHandlers]>
-  get cookies(): ResponseCookies
-}
-
-interface MiddlewareRequest {
-  request: Request
-  context: Context
-  originalRequest: Request
-  next(): Promise
-  rewrite(destination: string | URL, init?: ResponseInit): Response
-}
-
-export function isMiddlewareRequest(
-  response: Response | MiddlewareRequest,
-): response is MiddlewareRequest {
-  return 'originalRequest' in response
-}
-
-export function isMiddlewareResponse(
-  response: Response | MiddlewareResponse,
-): response is MiddlewareResponse {
-  return 'dataTransforms' in response
-}
-
 export const addMiddlewareHeaders = async (
   originResponse: Promise | Response,
   middlewareResponse: Response,
diff --git a/edge-runtime/lib/next-request.ts b/edge-runtime/lib/next-request.ts
index e8e1624c72..4e28401310 100644
--- a/edge-runtime/lib/next-request.ts
+++ b/edge-runtime/lib/next-request.ts
@@ -1,132 +1,32 @@
 import type { Context } from '@netlify/edge-functions'
 
-import {
-  addBasePath,
-  addLocale,
-  addTrailingSlash,
-  normalizeDataUrl,
-  normalizeLocalePath,
-  removeBasePath,
-} from './util.ts'
+import type { RequestData } from './types'
 
-interface I18NConfig {
-  defaultLocale: string
-  localeDetection?: false
-  locales: string[]
-}
-
-export interface RequestData {
-  geo?: {
-    city?: string
-    country?: string
-    region?: string
-    latitude?: string
-    longitude?: string
-    timezone?: string
-  }
-  headers: Record
-  ip?: string
-  method: string
-  nextConfig?: {
-    basePath?: string
-    i18n?: I18NConfig | null
-    trailingSlash?: boolean
-    skipMiddlewareUrlNormalize?: boolean
-  }
-  page?: {
-    name?: string
-    params?: { [key: string]: string }
-  }
-  url: string
-  body?: ReadableStream
-  detectedLocale?: string
-}
-
-const normalizeRequestURL = (
-  originalURL: string,
-  nextConfig?: RequestData['nextConfig'],
-): { url: string; detectedLocale?: string } => {
-  const url = new URL(originalURL)
-
-  let pathname = removeBasePath(url.pathname, nextConfig?.basePath)
-
-  // If it exists, remove the locale from the URL and store it
-  const { detectedLocale } = normalizeLocalePath(pathname, nextConfig?.i18n?.locales)
-
-  if (!nextConfig?.skipMiddlewareUrlNormalize) {
-    // We want to run middleware for data requests and expose the URL of the
-    // corresponding pages, so we have to normalize the URLs before running
-    // the handler.
-    pathname = normalizeDataUrl(pathname)
-
-    // Normalizing the trailing slash based on the `trailingSlash` configuration
-    // property from the Next.js config.
-    if (nextConfig?.trailingSlash) {
-      pathname = addTrailingSlash(pathname)
-    }
-  }
-
-  url.pathname = addBasePath(pathname, nextConfig?.basePath)
-
-  return {
-    url: url.toString(),
-    detectedLocale,
-  }
-}
-
-export const localizeRequest = (
-  url: URL,
-  nextConfig?: {
-    basePath?: string
-    i18n?: I18NConfig | null
-  },
-): { localizedUrl: URL; locale?: string } => {
-  const localizedUrl = new URL(url)
-  localizedUrl.pathname = removeBasePath(localizedUrl.pathname, nextConfig?.basePath)
-
-  // Detect the locale from the URL
-  const { detectedLocale } = normalizeLocalePath(localizedUrl.pathname, nextConfig?.i18n?.locales)
-
-  // Add the locale to the URL if not already present
-  localizedUrl.pathname = addLocale(
-    localizedUrl.pathname,
-    detectedLocale ?? nextConfig?.i18n?.defaultLocale,
-  )
-
-  localizedUrl.pathname = addBasePath(localizedUrl.pathname, nextConfig?.basePath)
-
-  return {
-    localizedUrl,
-    locale: detectedLocale,
-  }
-}
-
-export const buildNextRequest = (
-  request: Request,
-  context: Context,
-  nextConfig?: RequestData['nextConfig'],
-): RequestData => {
+export const buildNextRequest = (request: Request): RequestData => {
   const { url, method, body, headers } = request
-  const { country, subdivision, city, latitude, longitude, timezone } = context.geo
-  const geo: RequestData['geo'] = {
-    city,
-    country: country?.code,
-    region: subdivision?.code,
-    latitude: latitude?.toString(),
-    longitude: longitude?.toString(),
-    timezone,
-  }
 
-  const { detectedLocale, url: normalizedUrl } = normalizeRequestURL(url, nextConfig)
+  // we don't really use it but Next.js expects a signal
+  const abortController = new AbortController()
 
   return {
     headers: Object.fromEntries(headers.entries()),
-    geo,
-    url: normalizedUrl,
     method,
-    ip: context.ip,
+    // nextConfig?: {
+    //     basePath?: string;
+    //     i18n?: I18NConfig | null;
+    //     trailingSlash?: boolean;
+    //     experimental?: Pick;
+    // };
+    // page?: {
+    //     name?: string;
+    //     params?: {
+    //         [key: string]: string | string[] | undefined;
+    //     };
+    // };
+    url,
     body: body ?? undefined,
-    nextConfig,
-    detectedLocale,
+    signal: abortController.signal,
+    /** passed in when running in edge runtime sandbox */
+    // waitUntil?: (promise: Promise) => void;
   }
 }
diff --git a/edge-runtime/lib/response.ts b/edge-runtime/lib/response.ts
index fa000a3842..ca34b3cc80 100644
--- a/edge-runtime/lib/response.ts
+++ b/edge-runtime/lib/response.ts
@@ -1,27 +1,10 @@
 import type { Context } from '@netlify/edge-functions'
-import {
-  HTMLRewriter,
-  type TextChunk,
-} from '../vendor/deno.land/x/htmlrewriter@v1.0.0/src/index.ts'
 
 import { updateModifiedHeaders } from './headers.ts'
 import type { StructuredLogger } from './logging.ts'
-import {
-  addMiddlewareHeaders,
-  isMiddlewareRequest,
-  isMiddlewareResponse,
-  mergeMiddlewareCookies,
-} from './middleware.ts'
-import { RequestData } from './next-request.ts'
-import {
-  addBasePath,
-  normalizeDataUrl,
-  normalizeLocalePath,
-  normalizeTrailingSlash,
-  relativizeURL,
-  removeBasePath,
-  rewriteDataPath,
-} from './util.ts'
+import { addMiddlewareHeaders, mergeMiddlewareCookies } from './middleware.ts'
+
+import { relativizeURL } from './util.ts'
 
 export interface FetchEventResult {
   response: Response
@@ -29,19 +12,15 @@ export interface FetchEventResult {
 }
 
 interface BuildResponseOptions {
-  context: Context
   logger: StructuredLogger
   request: Request
   result: FetchEventResult
-  nextConfig?: RequestData['nextConfig']
 }
 
 export const buildResponse = async ({
-  context,
   logger,
   request,
   result,
-  nextConfig,
 }: BuildResponseOptions): Promise => {
   logger
     .withFields({ is_nextresponse_next: result.response.headers.has('x-middleware-next') })
@@ -49,231 +28,115 @@ export const buildResponse = async ({
 
   updateModifiedHeaders(request.headers, result.response.headers)
 
-  // They've returned the MiddlewareRequest directly, so we'll call `next()` for them.
-  if (isMiddlewareRequest(result.response)) {
-    result.response = await result.response.next()
-  }
-
-  if (isMiddlewareResponse(result.response)) {
-    const { response } = result
-    if (request.method === 'HEAD' || request.method === 'OPTIONS') {
-      return response.originResponse
-    }
-
-    // NextResponse doesn't set cookies onto the originResponse, so we need to copy them over
-    // In some cases, it's possible there are no headers set. See https://github.com/netlify/pod-ecosystem-frameworks/issues/475
-    if (response.cookies._headers?.has('set-cookie')) {
-      response.originResponse.headers.set(
-        'set-cookie',
-        response.cookies._headers.get('set-cookie')!,
-      )
-    }
-
-    // If it's JSON we don't need to use the rewriter, we can just parse it
-    if (response.originResponse.headers.get('content-type')?.includes('application/json')) {
-      const props = await response.originResponse.json()
-      const transformed = response.dataTransforms.reduce((prev, transform) => {
-        return transform(prev)
-      }, props)
-      const body = JSON.stringify(transformed)
-      const headers = new Headers(response.headers)
-      headers.set('content-length', String(body.length))
-
-      return Response.json(transformed, { ...response, headers })
-    }
-
-    // This var will hold the contents of the script tag
-    let buffer = ''
-    // Create an HTMLRewriter that matches the Next data script tag
-    const rewriter = new HTMLRewriter()
-
-    if (response.dataTransforms.length > 0) {
-      rewriter.on('script[id="__NEXT_DATA__"]', {
-        text(textChunk: TextChunk) {
-          // Grab all the chunks in the Next data script tag
-          buffer += textChunk.text
-          if (textChunk.lastInTextNode) {
-            try {
-              // When we have all the data, try to parse it as JSON
-              const data = JSON.parse(buffer.trim())
-              // Apply all of the transforms to the props
-              const props = response.dataTransforms.reduce(
-                (prev, transform) => transform(prev),
-                data.props,
-              )
-              // Replace the data with the transformed props
-              // With `html: true` the input is treated as raw HTML
-              // @see https://developers.cloudflare.com/workers/runtime-apis/html-rewriter/#global-types
-              textChunk.replace(JSON.stringify({ ...data, props }), { html: true })
-            } catch (err) {
-              console.log('Could not parse', err)
-            }
-          } else {
-            // Remove the chunk after we've appended it to the buffer
-            textChunk.remove()
-          }
-        },
-      })
-    }
-
-    if (response.elementHandlers.length > 0) {
-      response.elementHandlers.forEach(([selector, handlers]) => rewriter.on(selector, handlers))
-    }
-    return rewriter.transform(response.originResponse)
-  }
-
   const edgeResponse = new Response(result.response.body, result.response)
-  request.headers.set('x-nf-next-middleware', 'skip')
-
-  let rewrite = edgeResponse.headers.get('x-middleware-rewrite')
-  let redirect = edgeResponse.headers.get('location')
-  let nextRedirect = edgeResponse.headers.get('x-nextjs-redirect')
-
-  // Data requests (i.e. requests for /_next/data ) need special handling
-  const isDataReq = request.headers.has('x-nextjs-data')
-  // Data requests need to be normalized to the route path
-  if (isDataReq && !redirect && !rewrite && !nextRedirect) {
-    const requestUrl = new URL(request.url)
-    const normalizedDataUrl = normalizeDataUrl(requestUrl.pathname)
-    // Don't rewrite unless the URL has changed
-    if (normalizedDataUrl !== requestUrl.pathname) {
-      rewrite = `${normalizedDataUrl}${requestUrl.search}`
-      logger.withFields({ rewrite_url: rewrite }).debug('Rewritten data URL')
-    }
-  }
-
-  if (rewrite) {
-    logger.withFields({ rewrite_url: rewrite }).debug('Found middleware rewrite')
-
-    const rewriteUrl = new URL(rewrite, request.url)
-    const baseUrl = new URL(request.url)
-    if (rewriteUrl.toString() === baseUrl.toString()) {
-      logger.withFields({ rewrite_url: rewrite }).debug('Rewrite url is same as original url')
-      return
-    }
-
-    const relativeUrl = relativizeURL(rewrite, request.url)
-
-    if (isDataReq) {
-      // Data requests might be rewritten to an external URL
-      // This header tells the client router the redirect target, and if it's external then it will do a full navigation
-
-      edgeResponse.headers.set('x-nextjs-rewrite', relativeUrl)
-    }
-
-    if (rewriteUrl.origin !== baseUrl.origin) {
-      logger.withFields({ rewrite_url: rewrite }).debug('Rewriting to external url')
-      const proxyRequest = await cloneRequest(rewriteUrl, request)
-
-      // Remove Netlify internal headers
-      for (const key of request.headers.keys()) {
-        if (key.startsWith('x-nf-')) {
-          proxyRequest.headers.delete(key)
-        }
-      }
-
-      return addMiddlewareHeaders(fetch(proxyRequest, { redirect: 'manual' }), edgeResponse)
-    }
-
-    if (isDataReq) {
-      rewriteUrl.pathname = rewriteDataPath({
-        dataUrl: new URL(request.url).pathname,
-        newRoute: removeBasePath(rewriteUrl.pathname, nextConfig?.basePath),
-        basePath: nextConfig?.basePath,
-      })
-    } else {
-      // respect trailing slash rules to prevent 308s
-      rewriteUrl.pathname = normalizeTrailingSlash(rewriteUrl.pathname, nextConfig?.trailingSlash)
-    }
-
-    const target = normalizeLocalizedTarget({ target: rewriteUrl.toString(), request, nextConfig })
-    if (target === request.url) {
-      logger.withFields({ rewrite_url: rewrite }).debug('Rewrite url is same as original url')
-      return
-    }
-    edgeResponse.headers.set('x-middleware-rewrite', relativeUrl)
-    request.headers.set('x-middleware-rewrite', target)
-
-    // coookies set in middleware need to be available during the lambda request
-    const newRequest = await cloneRequest(target, request)
-    const newRequestCookies = mergeMiddlewareCookies(edgeResponse, newRequest)
-    if (newRequestCookies) {
-      newRequest.headers.set('Cookie', newRequestCookies)
-    }
-
-    return addMiddlewareHeaders(context.next(newRequest), edgeResponse)
-  }
-
-  if (redirect) {
-    redirect = normalizeLocalizedTarget({ target: redirect, request, nextConfig })
-    if (redirect === request.url) {
-      logger.withFields({ redirect_url: redirect }).debug('Redirect url is same as original url')
-      return
-    }
-    edgeResponse.headers.set('location', relativizeURL(redirect, request.url))
-  }
-
-  // Data requests shouldn't automatically redirect in the browser (they might be HTML pages): they're handled by the router
-  if (redirect && isDataReq) {
-    edgeResponse.headers.delete('location')
-    edgeResponse.headers.set('x-nextjs-redirect', relativizeURL(redirect, request.url))
-  }
-
-  nextRedirect = edgeResponse.headers.get('x-nextjs-redirect')
-
-  if (nextRedirect && isDataReq) {
-    edgeResponse.headers.set('x-nextjs-redirect', normalizeDataUrl(nextRedirect))
-  }
-
-  if (edgeResponse.headers.get('x-middleware-next') === '1') {
-    edgeResponse.headers.delete('x-middleware-next')
-
-    // coookies set in middleware need to be available during the lambda request
-    const newRequest = await cloneRequest(request.url, request)
-    const newRequestCookies = mergeMiddlewareCookies(edgeResponse, newRequest)
-    if (newRequestCookies) {
-      newRequest.headers.set('Cookie', newRequestCookies)
-    }
-
-    return addMiddlewareHeaders(context.next(newRequest), edgeResponse)
-  }
-
   return edgeResponse
+  // request.headers.set('x-nf-next-middleware', 'skip')
+
+  // let rewrite = edgeResponse.headers.get('x-middleware-rewrite')
+  // let redirect = edgeResponse.headers.get('location')
+  // let nextRedirect = edgeResponse.headers.get('x-nextjs-redirect')
+
+  // // Data requests (i.e. requests for /_next/data ) need special handling
+  // const isDataReq = request.headers.has('x-nextjs-data')
+  // // Data requests need to be normalized to the route path
+  // if (isDataReq && !redirect && !rewrite && !nextRedirect) {
+  //   const requestUrl = new URL(request.url)
+  //   const normalizedDataUrl = normalizeDataUrl(requestUrl.pathname)
+  //   // Don't rewrite unless the URL has changed
+  //   if (normalizedDataUrl !== requestUrl.pathname) {
+  //     rewrite = `${normalizedDataUrl}${requestUrl.search}`
+  //     logger.withFields({ rewrite_url: rewrite }).debug('Rewritten data URL')
+  //   }
+  // }
+
+  // if (rewrite) {
+  //   logger.withFields({ rewrite_url: rewrite }).debug('Found middleware rewrite')
+
+  //   const rewriteUrl = new URL(rewrite, request.url)
+  //   const baseUrl = new URL(request.url)
+  //   if (rewriteUrl.toString() === baseUrl.toString()) {
+  //     logger.withFields({ rewrite_url: rewrite }).debug('Rewrite url is same as original url')
+  //     return
+  //   }
+
+  //   const relativeUrl = relativizeURL(rewrite, request.url)
+
+  //   if (isDataReq) {
+  //     // Data requests might be rewritten to an external URL
+  //     // This header tells the client router the redirect target, and if it's external then it will do a full navigation
+
+  //     edgeResponse.headers.set('x-nextjs-rewrite', relativeUrl)
+  //   }
+
+  //   if (rewriteUrl.origin !== baseUrl.origin) {
+  //     logger.withFields({ rewrite_url: rewrite }).debug('Rewriting to external url')
+  //     const proxyRequest = await cloneRequest(rewriteUrl, request)
+
+  //     // Remove Netlify internal headers
+  //     for (const key of request.headers.keys()) {
+  //       if (key.startsWith('x-nf-')) {
+  //         proxyRequest.headers.delete(key)
+  //       }
+  //     }
+
+  //     return addMiddlewareHeaders(fetch(proxyRequest, { redirect: 'manual' }), edgeResponse)
+  //   }
+
+  //   const target = rewriteUrl.toString()
+  //   if (target === request.url) {
+  //     logger.withFields({ rewrite_url: rewrite }).debug('Rewrite url is same as original url')
+  //     return
+  //   }
+  //   edgeResponse.headers.set('x-middleware-rewrite', relativeUrl)
+  //   request.headers.set('x-middleware-rewrite', target)
+
+  //   // coookies set in middleware need to be available during the lambda request
+  //   const newRequest = await cloneRequest(target, request)
+  //   const newRequestCookies = mergeMiddlewareCookies(edgeResponse, newRequest)
+  //   if (newRequestCookies) {
+  //     newRequest.headers.set('Cookie', newRequestCookies)
+  //   }
+
+  //   return addMiddlewareHeaders(context.next(newRequest), edgeResponse)
+  // }
+
+  // if (redirect) {
+  //   if (redirect === request.url) {
+  //     logger.withFields({ redirect_url: redirect }).debug('Redirect url is same as original url')
+  //     return
+  //   }
+  //   edgeResponse.headers.set('location', relativizeURL(redirect, request.url))
+  // }
+
+  // // Data requests shouldn't automatically redirect in the browser (they might be HTML pages): they're handled by the router
+  // if (redirect && isDataReq) {
+  //   edgeResponse.headers.delete('location')
+  //   edgeResponse.headers.set('x-nextjs-redirect', relativizeURL(redirect, request.url))
+  // }
+
+  // nextRedirect = edgeResponse.headers.get('x-nextjs-redirect')
+
+  // if (nextRedirect && isDataReq) {
+  //   edgeResponse.headers.set('x-nextjs-redirect', normalizeDataUrl(nextRedirect))
+  // }
+
+  // if (edgeResponse.headers.get('x-middleware-next') === '1') {
+  //   edgeResponse.headers.delete('x-middleware-next')
+
+  //   // coookies set in middleware need to be available during the lambda request
+  //   const newRequest = await cloneRequest(request.url, request)
+  //   const newRequestCookies = mergeMiddlewareCookies(edgeResponse, newRequest)
+  //   if (newRequestCookies) {
+  //     newRequest.headers.set('Cookie', newRequestCookies)
+  //   }
+
+  //   return addMiddlewareHeaders(context.next(newRequest), edgeResponse)
+  // }
+
+  // return edgeResponse
 }
 
-/**
- * Normalizes the locale in a URL.
- */
-function normalizeLocalizedTarget({
-  target,
-  request,
-  nextConfig,
-}: {
-  target: string
-  request: Request
-  nextConfig?: RequestData['nextConfig']
-}): string {
-  const targetUrl = new URL(target, request.url)
-
-  const normalizedTarget = normalizeLocalePath(targetUrl.pathname, nextConfig?.i18n?.locales)
-
-  if (
-    normalizedTarget.detectedLocale &&
-    !normalizedTarget.pathname.startsWith(`/api/`) &&
-    !normalizedTarget.pathname.startsWith(`/_next/static/`)
-  ) {
-    targetUrl.pathname =
-      addBasePath(
-        `/${normalizedTarget.detectedLocale}${normalizedTarget.pathname}`,
-        nextConfig?.basePath,
-      ) || `/`
-  } else {
-    targetUrl.pathname = addBasePath(normalizedTarget.pathname, nextConfig?.basePath) || `/`
-  }
-  return targetUrl.toString()
-}
-
-async function cloneRequest(url, request: Request) {
+async function cloneRequest(url: URL | string, request: Request) {
   // This is not ideal, but streaming to an external URL doesn't work
   const body = request.body && !request.bodyUsed ? await request.arrayBuffer() : undefined
   return new Request(url, {
diff --git a/edge-runtime/lib/routing.ts b/edge-runtime/lib/routing.ts
index e9fbaf137c..aa7b327535 100644
--- a/edge-runtime/lib/routing.ts
+++ b/edge-runtime/lib/routing.ts
@@ -1,461 +1,461 @@
-/**
- * Various router utils ported to Deno from Next.js source
- * Licence: https://github.com/vercel/next.js/blob/7280c3ced186bb9a7ae3d7012613ef93f20b0fa9/license.md
- *
- * Some types have been re-implemented to be more compatible with Deno or avoid chains of dependent files
- */
-
-import type { Key } from '../vendor/deno.land/x/path_to_regexp@v6.2.1/index.ts'
-
-import { compile, pathToRegexp } from '../vendor/deno.land/x/path_to_regexp@v6.2.1/index.ts'
-import { getCookies } from '../vendor/deno.land/std@0.175.0/http/cookie.ts'
-
-/*
-  ┌─────────────────────────────────────────────────────────────────────────┐
-  │ Inlined/re-implemented types                                            │
-  └─────────────────────────────────────────────────────────────────────────┘
- */
-export interface ParsedUrlQuery {
-  [key: string]: string | string[]
-}
-
-export interface Params {
-  [param: string]: any
-}
-
-export type RouteHas =
-  | {
-      type: 'header' | 'query' | 'cookie'
-      key: string
-      value?: string
-    }
-  | {
-      type: 'host'
-      key?: undefined
-      value: string
-    }
-
-export type Rewrite = {
-  source: string
-  destination: string
-  basePath?: false
-  locale?: false
-  has?: RouteHas[]
-  missing?: RouteHas[]
-  regex: string
-}
-
-export type Header = {
-  source: string
-  basePath?: false
-  locale?: false
-  headers: Array<{ key: string; value: string }>
-  has?: RouteHas[]
-  missing?: RouteHas[]
-  regex: string
-}
-
-export type Redirect = {
-  source: string
-  destination: string
-  basePath?: false
-  locale?: false
-  has?: RouteHas[]
-  missing?: RouteHas[]
-  statusCode?: number
-  permanent?: boolean
-  regex: string
-}
-
-export type DynamicRoute = {
-  page: string
-  regex: string
-  namedRegex?: string
-  routeKeys?: { [key: string]: string }
-}
-
-export type RoutesManifest = {
-  basePath: string
-  redirects: Redirect[]
-  headers: Header[]
-  rewrites: {
-    beforeFiles: Rewrite[]
-    afterFiles: Rewrite[]
-    fallback: Rewrite[]
-  }
-  dynamicRoutes: DynamicRoute[]
-}
-
-/*
-  ┌─────────────────────────────────────────────────────────────────────────┐
-  │ packages/next/src/shared/lib/escape-regexp.ts                           │
-  └─────────────────────────────────────────────────────────────────────────┘
- */
-// regexp is based on https://github.com/sindresorhus/escape-string-regexp
-const reHasRegExp = /[|\\{}()[\]^$+*?.-]/
-const reReplaceRegExp = /[|\\{}()[\]^$+*?.-]/g
-
-export function escapeStringRegexp(str: string) {
-  // see also: https://github.com/lodash/lodash/blob/2da024c3b4f9947a48517639de7560457cd4ec6c/escapeRegExp.js#L23
-  if (reHasRegExp.test(str)) {
-    return str.replace(reReplaceRegExp, '\\$&')
-  }
-
-  return str
-}
-
-/*
-  ┌─────────────────────────────────────────────────────────────────────────┐
-  │ packages/next/src/shared/lib/router/utils/querystring.ts                │
-  └─────────────────────────────────────────────────────────────────────────┘
- */
-export function searchParamsToUrlQuery(searchParams: URLSearchParams): ParsedUrlQuery {
-  const query: ParsedUrlQuery = {}
-
-  searchParams.forEach((value, key) => {
-    if (typeof query[key] === 'undefined') {
-      query[key] = value
-    } else if (Array.isArray(query[key])) {
-      ;(query[key] as string[]).push(value)
-    } else {
-      query[key] = [query[key] as string, value]
-    }
-  })
-
-  return query
-}
-
-/*
-  ┌─────────────────────────────────────────────────────────────────────────┐
-  │ packages/next/src/shared/lib/router/utils/parse-url.ts                  │
-  └─────────────────────────────────────────────────────────────────────────┘
- */
-interface ParsedUrl {
-  hash: string
-  hostname?: string | null
-  href: string
-  pathname: string
-  port?: string | null
-  protocol?: string | null
-  query: ParsedUrlQuery
-  search: string
-}
-
-export function parseUrl(url: string): ParsedUrl {
-  const parsedURL = url.startsWith('/') ? new URL(url, 'http://n') : new URL(url)
-
-  return {
-    hash: parsedURL.hash,
-    hostname: parsedURL.hostname,
-    href: parsedURL.href,
-    pathname: parsedURL.pathname,
-    port: parsedURL.port,
-    protocol: parsedURL.protocol,
-    query: searchParamsToUrlQuery(parsedURL.searchParams),
-    search: parsedURL.search,
-  }
-}
-
-/*
-  ┌─────────────────────────────────────────────────────────────────────────┐
-  │ packages/next/src/shared/lib/router/utils/prepare-destination.ts        │
-  │ — Changed to use WHATWG Fetch `Request` instead of                      │
-  │ `http.IncomingMessage`.                                                 │
-  └─────────────────────────────────────────────────────────────────────────┘
- */
-export function matchHas(
-  req: Pick,
-  query: Params,
-  has: RouteHas[] = [],
-  missing: RouteHas[] = [],
-): false | Params {
-  const params: Params = {}
-  const cookies = getCookies(req.headers)
-  const url = new URL(req.url)
-  const hasMatch = (hasItem: RouteHas) => {
-    let value: undefined | string | null
-    let key = hasItem.key
-
-    switch (hasItem.type) {
-      case 'header': {
-        key = hasItem.key.toLowerCase()
-        value = req.headers.get(key)
-        break
-      }
-      case 'cookie': {
-        value = cookies[hasItem.key]
-        break
-      }
-      case 'query': {
-        value = query[hasItem.key]
-        break
-      }
-      case 'host': {
-        value = url.hostname
-        break
-      }
-      default: {
-        break
-      }
-    }
-    if (!hasItem.value && value && key) {
-      params[getSafeParamName(key)] = value
-
-      return true
-    } else if (value) {
-      const matcher = new RegExp(`^${hasItem.value}$`)
-      const matches = Array.isArray(value)
-        ? value.slice(-1)[0].match(matcher)
-        : value.match(matcher)
-
-      if (matches) {
-        if (Array.isArray(matches)) {
-          if (matches.groups) {
-            Object.keys(matches.groups).forEach((groupKey) => {
-              params[groupKey] = matches.groups![groupKey]
-            })
-          } else if (hasItem.type === 'host' && matches[0]) {
-            params.host = matches[0]
-          }
-        }
-        return true
-      }
-    }
-    return false
-  }
-
-  const allMatch = has.every((item) => hasMatch(item)) && !missing.some((item) => hasMatch(item))
-
-  if (allMatch) {
-    return params
-  }
-  return false
-}
-
-export function compileNonPath(value: string, params: Params): string {
-  if (!value.includes(':')) {
-    return value
-  }
-
-  for (const key of Object.keys(params)) {
-    if (value.includes(`:${key}`)) {
-      value = value
-        .replace(new RegExp(`:${key}\\*`, 'g'), `:${key}--ESCAPED_PARAM_ASTERISKS`)
-        .replace(new RegExp(`:${key}\\?`, 'g'), `:${key}--ESCAPED_PARAM_QUESTION`)
-        .replace(new RegExp(`:${key}\\+`, 'g'), `:${key}--ESCAPED_PARAM_PLUS`)
-        .replace(new RegExp(`:${key}(?!\\w)`, 'g'), `--ESCAPED_PARAM_COLON${key}`)
-    }
-  }
-  value = value
-    .replace(/(:|\*|\?|\+|\(|\)|\{|\})/g, '\\$1')
-    .replace(/--ESCAPED_PARAM_PLUS/g, '+')
-    .replace(/--ESCAPED_PARAM_COLON/g, ':')
-    .replace(/--ESCAPED_PARAM_QUESTION/g, '?')
-    .replace(/--ESCAPED_PARAM_ASTERISKS/g, '*')
-  // the value needs to start with a forward-slash to be compiled
-  // correctly
-  return compile(`/${value}`, { validate: false })(params).slice(1)
-}
-
-export function prepareDestination(args: {
-  appendParamsToQuery: boolean
-  destination: string
-  params: Params
-  query: ParsedUrlQuery
-}) {
-  const query = Object.assign({}, args.query)
-  delete query.__nextLocale
-  delete query.__nextDefaultLocale
-  delete query.__nextDataReq
-
-  let escapedDestination = args.destination
-
-  for (const param of Object.keys({ ...args.params, ...query })) {
-    escapedDestination = escapeSegment(escapedDestination, param)
-  }
-
-  const parsedDestination: ParsedUrl = parseUrl(escapedDestination)
-  const destQuery = parsedDestination.query
-  const destPath = unescapeSegments(`${parsedDestination.pathname!}${parsedDestination.hash || ''}`)
-  const destHostname = unescapeSegments(parsedDestination.hostname || '')
-  const destPathParamKeys: Key[] = []
-  const destHostnameParamKeys: Key[] = []
-  pathToRegexp(destPath, destPathParamKeys)
-  pathToRegexp(destHostname, destHostnameParamKeys)
-
-  const destParams: (string | number)[] = []
-
-  destPathParamKeys.forEach((key) => destParams.push(key.name))
-  destHostnameParamKeys.forEach((key) => destParams.push(key.name))
-
-  const destPathCompiler = compile(
-    destPath,
-    // we don't validate while compiling the destination since we should
-    // have already validated before we got to this point and validating
-    // breaks compiling destinations with named pattern params from the source
-    // e.g. /something:hello(.*) -> /another/:hello is broken with validation
-    // since compile validation is meant for reversing and not for inserting
-    // params from a separate path-regex into another
-    { validate: false },
-  )
-
-  const destHostnameCompiler = compile(destHostname, { validate: false })
-
-  // update any params in query values
-  for (const [key, strOrArray] of Object.entries(destQuery)) {
-    // the value needs to start with a forward-slash to be compiled
-    // correctly
-    if (Array.isArray(strOrArray)) {
-      destQuery[key] = strOrArray.map((value) =>
-        compileNonPath(unescapeSegments(value), args.params),
-      )
-    } else {
-      destQuery[key] = compileNonPath(unescapeSegments(strOrArray), args.params)
-    }
-  }
-
-  // add path params to query if it's not a redirect and not
-  // already defined in destination query or path
-  const paramKeys = Object.keys(args.params).filter((name) => name !== 'nextInternalLocale')
-
-  if (args.appendParamsToQuery && !paramKeys.some((key) => destParams.includes(key))) {
-    for (const key of paramKeys) {
-      if (!(key in destQuery)) {
-        destQuery[key] = args.params[key]
-      }
-    }
-  }
-
-  let newUrl
-
-  try {
-    newUrl = destPathCompiler(args.params)
-
-    const [pathname, hash] = newUrl.split('#')
-    parsedDestination.hostname = destHostnameCompiler(args.params)
-    parsedDestination.pathname = pathname
-    parsedDestination.hash = `${hash ? '#' : ''}${hash || ''}`
-    delete (parsedDestination as any).search
-  } catch (err: any) {
-    if (err.message.match(/Expected .*? to not repeat, but got an array/)) {
-      throw new Error(
-        `To use a multi-match in the destination you must add \`*\` at the end of the param name to signify it should repeat. https://nextjs.org/docs/messages/invalid-multi-match`,
-      )
-    }
-    throw err
-  }
-
-  // Query merge order lowest priority to highest
-  // 1. initial URL query values
-  // 2. path segment values
-  // 3. destination specified query values
-  parsedDestination.query = {
-    ...query,
-    ...parsedDestination.query,
-  }
-
-  return {
-    newUrl,
-    destQuery,
-    parsedDestination,
-  }
-}
-
-/**
- * Ensure only a-zA-Z are used for param names for proper interpolating
- * with path-to-regexp
- */
-function getSafeParamName(paramName: string) {
-  let newParamName = ''
-
-  for (let i = 0; i < paramName.length; i++) {
-    const charCode = paramName.charCodeAt(i)
-
-    if (
-      (charCode > 64 && charCode < 91) || // A-Z
-      (charCode > 96 && charCode < 123) // a-z
-    ) {
-      newParamName += paramName[i]
-    }
-  }
-  return newParamName
-}
-
-function escapeSegment(str: string, segmentName: string) {
-  return str.replace(
-    new RegExp(`:${escapeStringRegexp(segmentName)}`, 'g'),
-    `__ESC_COLON_${segmentName}`,
-  )
-}
-
-function unescapeSegments(str: string) {
-  return str.replace(/__ESC_COLON_/gi, ':')
-}
-
-/*
-  ┌─────────────────────────────────────────────────────────────────────────┐
-  │ packages/next/src/shared/lib/router/utils/is-dynamic.ts                 │
-  └─────────────────────────────────────────────────────────────────────────┘
- */
-// Identify /[param]/ in route string
-const TEST_ROUTE = /\/\[[^/]+?\](?=\/|$)/
-
-export function isDynamicRoute(route: string): boolean {
-  return TEST_ROUTE.test(route)
-}
-
-/*
-  ┌─────────────────────────────────────────────────────────────────────────┐
-  │ packages/next/shared/lib/router/utils/middleware-route-matcher.ts       │
-  └─────────────────────────────────────────────────────────────────────────┘
- */
-export interface MiddlewareRouteMatch {
-  (
-    pathname: string | null | undefined,
-    request: Pick,
-    query: Params,
-  ): boolean
-}
-
-export interface MiddlewareMatcher {
-  regexp: string
-  locale?: false
-  has?: RouteHas[]
-  missing?: RouteHas[]
-}
-
-const decodeMaybeEncodedPath = (path: string): string => {
-  try {
-    return decodeURIComponent(path)
-  } catch {
-    return path
-  }
-}
-
-export function getMiddlewareRouteMatcher(matchers: MiddlewareMatcher[]): MiddlewareRouteMatch {
-  return (
-    unsafePathname: string | null | undefined,
-    req: Pick,
-    query: Params,
-  ) => {
-    const pathname = decodeMaybeEncodedPath(unsafePathname ?? '')
-
-    for (const matcher of matchers) {
-      const routeMatch = new RegExp(matcher.regexp).exec(pathname)
-      if (!routeMatch) {
-        continue
-      }
-
-      if (matcher.has || matcher.missing) {
-        const hasParams = matchHas(req, query, matcher.has, matcher.missing)
-        if (!hasParams) {
-          continue
-        }
-      }
-
-      return true
-    }
-
-    return false
-  }
-}
+// /**
+//  * Various router utils ported to Deno from Next.js source
+//  * Licence: https://github.com/vercel/next.js/blob/7280c3ced186bb9a7ae3d7012613ef93f20b0fa9/license.md
+//  *
+//  * Some types have been re-implemented to be more compatible with Deno or avoid chains of dependent files
+//  */
+
+// import type { Key } from '../vendor/deno.land/x/path_to_regexp@v6.2.1/index.ts'
+
+// import { compile, pathToRegexp } from '../vendor/deno.land/x/path_to_regexp@v6.2.1/index.ts'
+// import { getCookies } from '../vendor/deno.land/std@0.175.0/http/cookie.ts'
+
+// /*
+//   ┌─────────────────────────────────────────────────────────────────────────┐
+//   │ Inlined/re-implemented types                                            │
+//   └─────────────────────────────────────────────────────────────────────────┘
+//  */
+// export interface ParsedUrlQuery {
+//   [key: string]: string | string[]
+// }
+
+// export interface Params {
+//   [param: string]: any
+// }
+
+// export type RouteHas =
+//   | {
+//       type: 'header' | 'query' | 'cookie'
+//       key: string
+//       value?: string
+//     }
+//   | {
+//       type: 'host'
+//       key?: undefined
+//       value: string
+//     }
+
+// export type Rewrite = {
+//   source: string
+//   destination: string
+//   basePath?: false
+//   locale?: false
+//   has?: RouteHas[]
+//   missing?: RouteHas[]
+//   regex: string
+// }
+
+// export type Header = {
+//   source: string
+//   basePath?: false
+//   locale?: false
+//   headers: Array<{ key: string; value: string }>
+//   has?: RouteHas[]
+//   missing?: RouteHas[]
+//   regex: string
+// }
+
+// export type Redirect = {
+//   source: string
+//   destination: string
+//   basePath?: false
+//   locale?: false
+//   has?: RouteHas[]
+//   missing?: RouteHas[]
+//   statusCode?: number
+//   permanent?: boolean
+//   regex: string
+// }
+
+// export type DynamicRoute = {
+//   page: string
+//   regex: string
+//   namedRegex?: string
+//   routeKeys?: { [key: string]: string }
+// }
+
+// export type RoutesManifest = {
+//   basePath: string
+//   redirects: Redirect[]
+//   headers: Header[]
+//   rewrites: {
+//     beforeFiles: Rewrite[]
+//     afterFiles: Rewrite[]
+//     fallback: Rewrite[]
+//   }
+//   dynamicRoutes: DynamicRoute[]
+// }
+
+// /*
+//   ┌─────────────────────────────────────────────────────────────────────────┐
+//   │ packages/next/src/shared/lib/escape-regexp.ts                           │
+//   └─────────────────────────────────────────────────────────────────────────┘
+//  */
+// // regexp is based on https://github.com/sindresorhus/escape-string-regexp
+// const reHasRegExp = /[|\\{}()[\]^$+*?.-]/
+// const reReplaceRegExp = /[|\\{}()[\]^$+*?.-]/g
+
+// export function escapeStringRegexp(str: string) {
+//   // see also: https://github.com/lodash/lodash/blob/2da024c3b4f9947a48517639de7560457cd4ec6c/escapeRegExp.js#L23
+//   if (reHasRegExp.test(str)) {
+//     return str.replace(reReplaceRegExp, '\\$&')
+//   }
+
+//   return str
+// }
+
+// /*
+//   ┌─────────────────────────────────────────────────────────────────────────┐
+//   │ packages/next/src/shared/lib/router/utils/querystring.ts                │
+//   └─────────────────────────────────────────────────────────────────────────┘
+//  */
+// export function searchParamsToUrlQuery(searchParams: URLSearchParams): ParsedUrlQuery {
+//   const query: ParsedUrlQuery = {}
+
+//   searchParams.forEach((value, key) => {
+//     if (typeof query[key] === 'undefined') {
+//       query[key] = value
+//     } else if (Array.isArray(query[key])) {
+//       ;(query[key] as string[]).push(value)
+//     } else {
+//       query[key] = [query[key] as string, value]
+//     }
+//   })
+
+//   return query
+// }
+
+// /*
+//   ┌─────────────────────────────────────────────────────────────────────────┐
+//   │ packages/next/src/shared/lib/router/utils/parse-url.ts                  │
+//   └─────────────────────────────────────────────────────────────────────────┘
+//  */
+// interface ParsedUrl {
+//   hash: string
+//   hostname?: string | null
+//   href: string
+//   pathname: string
+//   port?: string | null
+//   protocol?: string | null
+//   query: ParsedUrlQuery
+//   search: string
+// }
+
+// export function parseUrl(url: string): ParsedUrl {
+//   const parsedURL = url.startsWith('/') ? new URL(url, 'http://n') : new URL(url)
+
+//   return {
+//     hash: parsedURL.hash,
+//     hostname: parsedURL.hostname,
+//     href: parsedURL.href,
+//     pathname: parsedURL.pathname,
+//     port: parsedURL.port,
+//     protocol: parsedURL.protocol,
+//     query: searchParamsToUrlQuery(parsedURL.searchParams),
+//     search: parsedURL.search,
+//   }
+// }
+
+// /*
+//   ┌─────────────────────────────────────────────────────────────────────────┐
+//   │ packages/next/src/shared/lib/router/utils/prepare-destination.ts        │
+//   │ — Changed to use WHATWG Fetch `Request` instead of                      │
+//   │ `http.IncomingMessage`.                                                 │
+//   └─────────────────────────────────────────────────────────────────────────┘
+//  */
+// export function matchHas(
+//   req: Pick,
+//   query: Params,
+//   has: RouteHas[] = [],
+//   missing: RouteHas[] = [],
+// ): false | Params {
+//   const params: Params = {}
+//   const cookies = getCookies(req.headers)
+//   const url = new URL(req.url)
+//   const hasMatch = (hasItem: RouteHas) => {
+//     let value: undefined | string | null
+//     let key = hasItem.key
+
+//     switch (hasItem.type) {
+//       case 'header': {
+//         key = hasItem.key.toLowerCase()
+//         value = req.headers.get(key)
+//         break
+//       }
+//       case 'cookie': {
+//         value = cookies[hasItem.key]
+//         break
+//       }
+//       case 'query': {
+//         value = query[hasItem.key]
+//         break
+//       }
+//       case 'host': {
+//         value = url.hostname
+//         break
+//       }
+//       default: {
+//         break
+//       }
+//     }
+//     if (!hasItem.value && value && key) {
+//       params[getSafeParamName(key)] = value
+
+//       return true
+//     } else if (value) {
+//       const matcher = new RegExp(`^${hasItem.value}$`)
+//       const matches = Array.isArray(value)
+//         ? value.slice(-1)[0].match(matcher)
+//         : value.match(matcher)
+
+//       if (matches) {
+//         if (Array.isArray(matches)) {
+//           if (matches.groups) {
+//             Object.keys(matches.groups).forEach((groupKey) => {
+//               params[groupKey] = matches.groups![groupKey]
+//             })
+//           } else if (hasItem.type === 'host' && matches[0]) {
+//             params.host = matches[0]
+//           }
+//         }
+//         return true
+//       }
+//     }
+//     return false
+//   }
+
+//   const allMatch = has.every((item) => hasMatch(item)) && !missing.some((item) => hasMatch(item))
+
+//   if (allMatch) {
+//     return params
+//   }
+//   return false
+// }
+
+// export function compileNonPath(value: string, params: Params): string {
+//   if (!value.includes(':')) {
+//     return value
+//   }
+
+//   for (const key of Object.keys(params)) {
+//     if (value.includes(`:${key}`)) {
+//       value = value
+//         .replace(new RegExp(`:${key}\\*`, 'g'), `:${key}--ESCAPED_PARAM_ASTERISKS`)
+//         .replace(new RegExp(`:${key}\\?`, 'g'), `:${key}--ESCAPED_PARAM_QUESTION`)
+//         .replace(new RegExp(`:${key}\\+`, 'g'), `:${key}--ESCAPED_PARAM_PLUS`)
+//         .replace(new RegExp(`:${key}(?!\\w)`, 'g'), `--ESCAPED_PARAM_COLON${key}`)
+//     }
+//   }
+//   value = value
+//     .replace(/(:|\*|\?|\+|\(|\)|\{|\})/g, '\\$1')
+//     .replace(/--ESCAPED_PARAM_PLUS/g, '+')
+//     .replace(/--ESCAPED_PARAM_COLON/g, ':')
+//     .replace(/--ESCAPED_PARAM_QUESTION/g, '?')
+//     .replace(/--ESCAPED_PARAM_ASTERISKS/g, '*')
+//   // the value needs to start with a forward-slash to be compiled
+//   // correctly
+//   return compile(`/${value}`, { validate: false })(params).slice(1)
+// }
+
+// export function prepareDestination(args: {
+//   appendParamsToQuery: boolean
+//   destination: string
+//   params: Params
+//   query: ParsedUrlQuery
+// }) {
+//   const query = Object.assign({}, args.query)
+//   delete query.__nextLocale
+//   delete query.__nextDefaultLocale
+//   delete query.__nextDataReq
+
+//   let escapedDestination = args.destination
+
+//   for (const param of Object.keys({ ...args.params, ...query })) {
+//     escapedDestination = escapeSegment(escapedDestination, param)
+//   }
+
+//   const parsedDestination: ParsedUrl = parseUrl(escapedDestination)
+//   const destQuery = parsedDestination.query
+//   const destPath = unescapeSegments(`${parsedDestination.pathname!}${parsedDestination.hash || ''}`)
+//   const destHostname = unescapeSegments(parsedDestination.hostname || '')
+//   const destPathParamKeys: Key[] = []
+//   const destHostnameParamKeys: Key[] = []
+//   pathToRegexp(destPath, destPathParamKeys)
+//   pathToRegexp(destHostname, destHostnameParamKeys)
+
+//   const destParams: (string | number)[] = []
+
+//   destPathParamKeys.forEach((key) => destParams.push(key.name))
+//   destHostnameParamKeys.forEach((key) => destParams.push(key.name))
+
+//   const destPathCompiler = compile(
+//     destPath,
+//     // we don't validate while compiling the destination since we should
+//     // have already validated before we got to this point and validating
+//     // breaks compiling destinations with named pattern params from the source
+//     // e.g. /something:hello(.*) -> /another/:hello is broken with validation
+//     // since compile validation is meant for reversing and not for inserting
+//     // params from a separate path-regex into another
+//     { validate: false },
+//   )
+
+//   const destHostnameCompiler = compile(destHostname, { validate: false })
+
+//   // update any params in query values
+//   for (const [key, strOrArray] of Object.entries(destQuery)) {
+//     // the value needs to start with a forward-slash to be compiled
+//     // correctly
+//     if (Array.isArray(strOrArray)) {
+//       destQuery[key] = strOrArray.map((value) =>
+//         compileNonPath(unescapeSegments(value), args.params),
+//       )
+//     } else {
+//       destQuery[key] = compileNonPath(unescapeSegments(strOrArray), args.params)
+//     }
+//   }
+
+//   // add path params to query if it's not a redirect and not
+//   // already defined in destination query or path
+//   const paramKeys = Object.keys(args.params).filter((name) => name !== 'nextInternalLocale')
+
+//   if (args.appendParamsToQuery && !paramKeys.some((key) => destParams.includes(key))) {
+//     for (const key of paramKeys) {
+//       if (!(key in destQuery)) {
+//         destQuery[key] = args.params[key]
+//       }
+//     }
+//   }
+
+//   let newUrl
+
+//   try {
+//     newUrl = destPathCompiler(args.params)
+
+//     const [pathname, hash] = newUrl.split('#')
+//     parsedDestination.hostname = destHostnameCompiler(args.params)
+//     parsedDestination.pathname = pathname
+//     parsedDestination.hash = `${hash ? '#' : ''}${hash || ''}`
+//     delete (parsedDestination as any).search
+//   } catch (err: any) {
+//     if (err.message.match(/Expected .*? to not repeat, but got an array/)) {
+//       throw new Error(
+//         `To use a multi-match in the destination you must add \`*\` at the end of the param name to signify it should repeat. https://nextjs.org/docs/messages/invalid-multi-match`,
+//       )
+//     }
+//     throw err
+//   }
+
+//   // Query merge order lowest priority to highest
+//   // 1. initial URL query values
+//   // 2. path segment values
+//   // 3. destination specified query values
+//   parsedDestination.query = {
+//     ...query,
+//     ...parsedDestination.query,
+//   }
+
+//   return {
+//     newUrl,
+//     destQuery,
+//     parsedDestination,
+//   }
+// }
+
+// /**
+//  * Ensure only a-zA-Z are used for param names for proper interpolating
+//  * with path-to-regexp
+//  */
+// function getSafeParamName(paramName: string) {
+//   let newParamName = ''
+
+//   for (let i = 0; i < paramName.length; i++) {
+//     const charCode = paramName.charCodeAt(i)
+
+//     if (
+//       (charCode > 64 && charCode < 91) || // A-Z
+//       (charCode > 96 && charCode < 123) // a-z
+//     ) {
+//       newParamName += paramName[i]
+//     }
+//   }
+//   return newParamName
+// }
+
+// function escapeSegment(str: string, segmentName: string) {
+//   return str.replace(
+//     new RegExp(`:${escapeStringRegexp(segmentName)}`, 'g'),
+//     `__ESC_COLON_${segmentName}`,
+//   )
+// }
+
+// function unescapeSegments(str: string) {
+//   return str.replace(/__ESC_COLON_/gi, ':')
+// }
+
+// /*
+//   ┌─────────────────────────────────────────────────────────────────────────┐
+//   │ packages/next/src/shared/lib/router/utils/is-dynamic.ts                 │
+//   └─────────────────────────────────────────────────────────────────────────┘
+//  */
+// // Identify /[param]/ in route string
+// const TEST_ROUTE = /\/\[[^/]+?\](?=\/|$)/
+
+// export function isDynamicRoute(route: string): boolean {
+//   return TEST_ROUTE.test(route)
+// }
+
+// /*
+//   ┌─────────────────────────────────────────────────────────────────────────┐
+//   │ packages/next/shared/lib/router/utils/middleware-route-matcher.ts       │
+//   └─────────────────────────────────────────────────────────────────────────┘
+//  */
+// export interface MiddlewareRouteMatch {
+//   (
+//     pathname: string | null | undefined,
+//     request: Pick,
+//     query: Params,
+//   ): boolean
+// }
+
+// export interface MiddlewareMatcher {
+//   regexp: string
+//   locale?: false
+//   has?: RouteHas[]
+//   missing?: RouteHas[]
+// }
+
+// const decodeMaybeEncodedPath = (path: string): string => {
+//   try {
+//     return decodeURIComponent(path)
+//   } catch {
+//     return path
+//   }
+// }
+
+// export function getMiddlewareRouteMatcher(matchers: MiddlewareMatcher[]): MiddlewareRouteMatch {
+//   return (
+//     unsafePathname: string | null | undefined,
+//     req: Pick,
+//     query: Params,
+//   ) => {
+//     const pathname = decodeMaybeEncodedPath(unsafePathname ?? '')
+
+//     for (const matcher of matchers) {
+//       const routeMatch = new RegExp(matcher.regexp).exec(pathname)
+//       if (!routeMatch) {
+//         continue
+//       }
+
+//       if (matcher.has || matcher.missing) {
+//         const hasParams = matchHas(req, query, matcher.has, matcher.missing)
+//         if (!hasParams) {
+//           continue
+//         }
+//       }
+
+//       return true
+//     }
+
+//     return false
+//   }
+// }
diff --git a/edge-runtime/lib/types.ts b/edge-runtime/lib/types.ts
new file mode 100644
index 0000000000..eb9eb9c42c
--- /dev/null
+++ b/edge-runtime/lib/types.ts
@@ -0,0 +1,7 @@
+import type NextHandlerFunc from 'next-with-adapters/dist/build/templates/middleware'
+
+type NextHandler = typeof NextHandlerFunc
+
+type RequestData = Parameters[0]['request']
+
+export type { NextHandler, RequestData }
diff --git a/edge-runtime/lib/util.ts b/edge-runtime/lib/util.ts
index 26677b47d1..30a37af3a1 100644
--- a/edge-runtime/lib/util.ts
+++ b/edge-runtime/lib/util.ts
@@ -2,83 +2,35 @@
  * Normalize a data URL into a route path.
  * @see https://github.com/vercel/next.js/blob/25e0988e7c9033cb1503cbe0c62ba5de2e97849c/packages/next/src/shared/lib/router/utils/get-next-pathname-info.ts#L69-L76
  */
-export function normalizeDataUrl(urlPath: string) {
-  if (urlPath.startsWith('/_next/data/') && urlPath.includes('.json')) {
-    const paths = urlPath
-      .replace(/^\/_next\/data\//, '')
-      .replace(/\.json/, '')
-      .split('/')
-
-    urlPath = paths[1] !== 'index' ? `/${paths.slice(1).join('/')}` : '/'
-  }
-
-  return urlPath
-}
-
-export const removeBasePath = (path: string, basePath?: string) => {
-  if (basePath && path.startsWith(basePath)) {
-    return path.replace(basePath, '')
-  }
-  return path
-}
-
-export const addBasePath = (path: string, basePath?: string) => {
-  if (basePath && !path.startsWith(basePath)) {
-    return `${basePath}${path}`
-  }
-  return path
-}
-
-// add locale prefix if not present, allowing for locale fallbacks
-export const addLocale = (path: string, locale?: string) => {
-  if (
-    locale &&
-    path.toLowerCase() !== `/${locale.toLowerCase()}` &&
-    !path.toLowerCase().startsWith(`/${locale.toLowerCase()}/`) &&
-    !path.startsWith(`/api/`) &&
-    !path.startsWith(`/_next/`)
-  ) {
-    return `/${locale}${path}`
-  }
-  return path
-}
+// export function normalizeDataUrl(urlPath: string) {
+//   if (urlPath.startsWith('/_next/data/') && urlPath.includes('.json')) {
+//     const paths = urlPath
+//       .replace(/^\/_next\/data\//, '')
+//       .replace(/\.json/, '')
+//       .split('/')
+
+//     urlPath = paths[1] !== 'index' ? `/${paths.slice(1).join('/')}` : '/'
+//   }
+
+//   return urlPath
+// }
+
+// export const removeBasePath = (path: string, basePath?: string) => {
+//   if (basePath && path.startsWith(basePath)) {
+//     return path.replace(basePath, '')
+//   }
+//   return path
+// }
+
+// export const addBasePath = (path: string, basePath?: string) => {
+//   if (basePath && !path.startsWith(basePath)) {
+//     return `${basePath}${path}`
+//   }
+//   return path
+// }
 
 // https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/i18n/normalize-locale-path.ts
 
-export interface PathLocale {
-  detectedLocale?: string
-  pathname: string
-}
-
-/**
- * For a pathname that may include a locale from a list of locales, it
- * removes the locale from the pathname returning it alongside with the
- * detected locale.
- *
- * @param pathname A pathname that may include a locale.
- * @param locales A list of locales.
- * @returns The detected locale and pathname without locale
- */
-export function normalizeLocalePath(pathname: string, locales?: string[]): PathLocale {
-  let detectedLocale: string | undefined
-  // first item will be empty string from splitting at first char
-  const pathnameParts = pathname.split('/')
-
-  ;(locales || []).some((locale) => {
-    if (pathnameParts[1] && pathnameParts[1].toLowerCase() === locale.toLowerCase()) {
-      detectedLocale = locale
-      pathnameParts.splice(1, 1)
-      pathname = pathnameParts.join('/')
-      return true
-    }
-    return false
-  })
-  return {
-    pathname,
-    detectedLocale,
-  }
-}
-
 /**
  * This is how Next handles rewritten URLs.
  */
@@ -91,35 +43,35 @@ export function relativizeURL(url: string | string, base: string | URL) {
     : relative.toString()
 }
 
-export const normalizeIndex = (path: string) => (path === '/' ? '/index' : path)
+// export const normalizeIndex = (path: string) => (path === '/' ? '/index' : path)
 
-export const normalizeTrailingSlash = (path: string, trailingSlash?: boolean) =>
-  trailingSlash ? addTrailingSlash(path) : stripTrailingSlash(path)
+// export const normalizeTrailingSlash = (path: string, trailingSlash?: boolean) =>
+//   trailingSlash ? addTrailingSlash(path) : stripTrailingSlash(path)
 
-export const stripTrailingSlash = (path: string) =>
-  path !== '/' && path.endsWith('/') ? path.slice(0, -1) : path
+// export const stripTrailingSlash = (path: string) =>
+//   path !== '/' && path.endsWith('/') ? path.slice(0, -1) : path
 
-export const addTrailingSlash = (path: string) => (path.endsWith('/') ? path : `${path}/`)
+// export const addTrailingSlash = (path: string) => (path.endsWith('/') ? path : `${path}/`)
 
 /**
  * Modify a data url to point to a new page route.
  */
-export function rewriteDataPath({
-  dataUrl,
-  newRoute,
-  basePath,
-}: {
-  dataUrl: string
-  newRoute: string
-  basePath?: string
-}) {
-  const normalizedDataUrl = normalizeDataUrl(removeBasePath(dataUrl, basePath))
-
-  return addBasePath(
-    dataUrl.replace(
-      normalizeIndex(normalizedDataUrl),
-      stripTrailingSlash(normalizeIndex(newRoute)),
-    ),
-    basePath,
-  )
-}
+// export function rewriteDataPath({
+//   dataUrl,
+//   newRoute,
+//   basePath,
+// }: {
+//   dataUrl: string
+//   newRoute: string
+//   basePath?: string
+// }) {
+//   const normalizedDataUrl = normalizeDataUrl(removeBasePath(dataUrl, basePath))
+
+//   return addBasePath(
+//     dataUrl.replace(
+//       normalizeIndex(normalizedDataUrl),
+//       stripTrailingSlash(normalizeIndex(newRoute)),
+//     ),
+//     basePath,
+//   )
+// }
diff --git a/edge-runtime/middleware.ts b/edge-runtime/middleware.ts
index 8a9452f649..46e844f633 100644
--- a/edge-runtime/middleware.ts
+++ b/edge-runtime/middleware.ts
@@ -1,21 +1,10 @@
-import type { Context } from '@netlify/edge-functions'
-
-import matchers from './matchers.json' with { type: 'json' }
-import nextConfig from './next.config.json' with { type: 'json' }
+// import type { Context } from '@netlify/edge-functions'
 
 import { InternalHeaders } from './lib/headers.ts'
 import { logger, LogLevel } from './lib/logging.ts'
-import { buildNextRequest, localizeRequest, RequestData } from './lib/next-request.ts'
-import { buildResponse, FetchEventResult } from './lib/response.ts'
-import {
-  getMiddlewareRouteMatcher,
-  searchParamsToUrlQuery,
-  type MiddlewareRouteMatch,
-} from './lib/routing.ts'
-
-type NextHandler = (params: { request: RequestData }) => Promise
-
-const matchesMiddleware: MiddlewareRouteMatch = getMiddlewareRouteMatcher(matchers || [])
+import { buildNextRequest } from './lib/next-request.ts'
+import type { NextHandler } from './lib/types.ts'
+// import { buildResponse } from './lib/response.ts'
 
 /**
  * Runs a Next.js middleware as a Netlify Edge Function. It translates a web
@@ -26,11 +15,7 @@ const matchesMiddleware: MiddlewareRouteMatch = getMiddlewareRouteMatcher(matche
  * @param context Netlify-specific context object
  * @param nextHandler Next.js middleware handler
  */
-export async function handleMiddleware(
-  request: Request,
-  context: Context,
-  nextHandler: NextHandler,
-) {
+export async function handleMiddleware(request: Request, nextHandler: NextHandler) {
   const url = new URL(request.url)
 
   const reqLogger = logger
@@ -40,34 +25,21 @@ export async function handleMiddleware(
     .withFields({ url_path: url.pathname })
     .withRequestID(request.headers.get(InternalHeaders.NFRequestID))
 
-  const { localizedUrl } = localizeRequest(url, nextConfig)
-  // While we have already checked the path when mapping to the edge function,
-  // Next.js supports extra rules that we need to check here too, because we
-  // might be running an edge function for a path we should not. If we find
-  // that's the case, short-circuit the execution.
-  if (
-    !matchesMiddleware(localizedUrl.pathname, request, searchParamsToUrlQuery(url.searchParams))
-  ) {
-    reqLogger.debug('Aborting middleware due to runtime rules')
-
-    return
-  }
-
-  const nextRequest = buildNextRequest(request, context, nextConfig)
+  const nextRequest = buildNextRequest(request)
   try {
     const result = await nextHandler({ request: nextRequest })
-    const response = await buildResponse({
-      context,
-      logger: reqLogger,
-      request,
-      result,
-      nextConfig,
-    })
 
-    return response
+    return result.response
+    // const response = await buildResponse({
+    //   logger: reqLogger,
+    //   request,
+    //   result,
+    // })
+
+    // return response
   } catch (error) {
     console.error(error)
 
-    return new Response(error.message, { status: 500 })
+    return new Response(error instanceof Error ? error.message : String(error), { status: 500 })
   }
 }
diff --git a/src/adapter/build/middleware.ts b/src/adapter/build/middleware.ts
index ca635e71a0..f9793bf7bd 100644
--- a/src/adapter/build/middleware.ts
+++ b/src/adapter/build/middleware.ts
@@ -15,10 +15,7 @@ import type { NetlifyAdapterContext, NextConfigComplete, OnBuildCompleteContext
 
 const MIDDLEWARE_FUNCTION_INTERNAL_NAME = 'next_middleware'
 
-const MIDDLEWARE_FUNCTION_DIR = join(
-  NETLIFY_FRAMEWORKS_API_EDGE_FUNCTIONS,
-  MIDDLEWARE_FUNCTION_INTERNAL_NAME,
-)
+const MIDDLEWARE_FUNCTION_DIR = join(NETLIFY_FRAMEWORKS_API_EDGE_FUNCTIONS, 'next_routing')
 
 export async function onBuildComplete(
   nextAdapterContext: OnBuildCompleteContext,
@@ -149,7 +146,7 @@ const writeHandlerFile = async (
   middleware: Required['middleware'],
   nextConfig: NextConfigComplete,
 ) => {
-  const handlerRuntimeDirectory = join(MIDDLEWARE_FUNCTION_DIR, 'edge-runtime')
+  // 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.
@@ -157,56 +154,49 @@ const writeHandlerFile = async (
 
   // 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 ?? []),
-  )
+  // 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',
-    ),
-  )
+  // const minimalNextConfig = {
+  //   basePath: nextConfig.basePath,
+  //   i18n: nextConfig.i18n,
+  //   trailingSlash: nextConfig.trailingSlash,
+  //   skipMiddlewareUrlNormalize: nextConfig.skipMiddlewareUrlNormalize,
+  // }
 
-  const functionConfig = {
-    cache: undefined,
-    generator: GENERATOR,
-    name: DISPLAY_NAME_MIDDLEWARE,
-    pattern: augmentMatchers(middleware, nextConfig).map((matcher) => matcher.sourceRegex),
-  } satisfies IntegrationsConfig
+  // 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',
+  //   ),
+  // )
+
+  // const functionConfig = {
+  //   cache: undefined,
+  //   generator: GENERATOR,
+  //   name: DISPLAY_NAME_MIDDLEWARE,
+  //   pattern: augmentMatchers(middleware, nextConfig).map((matcher) => matcher.sourceRegex),
+  // } satisfies IntegrationsConfig
 
   // Writing the function entry file. It wraps the middleware code with the
   // compatibility layer mentioned above.
   await writeFile(
     join(MIDDLEWARE_FUNCTION_DIR, `${MIDDLEWARE_FUNCTION_INTERNAL_NAME}.js`),
     /* javascript */ `
-    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(functionConfig, null, 2)}
+    export default (req) => handleMiddleware(req, handler);
     `,
   )
 }
@@ -230,33 +220,33 @@ const copyRuntime = async (handlerDirectory: string): Promise => {
  * 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
-  })
-}
+// 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/adapter/build/pages-and-app-handlers.ts b/src/adapter/build/pages-and-app-handlers.ts
index a4ef5245a3..203ba8f92a 100644
--- a/src/adapter/build/pages-and-app-handlers.ts
+++ b/src/adapter/build/pages-and-app-handlers.ts
@@ -71,19 +71,19 @@ export async function onBuildComplete(
     const normalizedParentOutputId = normalizeIndex(prerender.parentOutputId)
 
     if (normalizedPathname in pathnameToEntry) {
-      console.log('Skipping prerender, already have route:', normalizedPathname)
+      // console.log('Skipping prerender, already have route:', normalizedPathname)
     } else if (normalizedParentOutputId in pathnameToEntry) {
       // if we don't have routing for this route yet, add it
-      console.log('prerender mapping', {
-        from: normalizedPathname,
-        to: normalizedParentOutputId,
-      })
+      // console.log('prerender mapping', {
+      //   from: normalizedPathname,
+      //   to: normalizedParentOutputId,
+      // })
       pathnameToEntry[normalizedPathname] = pathnameToEntry[normalizedParentOutputId]
     } else {
-      console.warn('Could not find parent output for prerender:', {
-        pathname: normalizedPathname,
-        parentOutputId: normalizedParentOutputId,
-      })
+      // console.warn('Could not find parent output for prerender:', {
+      //   pathname: normalizedPathname,
+      //   parentOutputId: normalizedParentOutputId,
+      // })
     }
   }
 
diff --git a/src/adapter/build/routing.ts b/src/adapter/build/routing.ts
index 99187450fb..f02a74cefc 100644
--- a/src/adapter/build/routing.ts
+++ b/src/adapter/build/routing.ts
@@ -3,12 +3,7 @@ import { join } from 'node:path/posix'
 
 import { glob } from 'fast-glob'
 
-import type {
-  RoutingRule,
-  RoutingRuleApply,
-  RoutingRuleRedirect,
-  RoutingRuleRewrite,
-} from '../run/routing.js'
+import type { RoutingRule, RoutingRuleApply } from '../run/routing.js'
 
 import {
   DISPLAY_NAME_ROUTING,
@@ -24,7 +19,7 @@ export function convertRedirectToRoutingRule(
     'sourceRegex' | 'destination' | 'priority'
   >,
   description: string,
-): RoutingRuleRedirect {
+): RoutingRuleApply {
   return {
     description,
     match: {
@@ -34,7 +29,7 @@ export function convertRedirectToRoutingRule(
       type: 'redirect',
       destination: redirect.destination,
     },
-  } satisfies RoutingRuleRedirect
+  } satisfies RoutingRuleApply
 }
 
 export function convertDynamicRouteToRoutingRule(
@@ -43,7 +38,7 @@ export function convertDynamicRouteToRoutingRule(
     'sourceRegex' | 'destination'
   >,
   description: string,
-): RoutingRuleRewrite {
+): RoutingRuleApply {
   return {
     description,
     match: {
@@ -54,7 +49,7 @@ export function convertDynamicRouteToRoutingRule(
       destination: dynamicRoute.destination,
       rerunRoutingPhases: ['filesystem', 'rewrite'], // this is attempt to mimic Vercel's check: true
     },
-  } satisfies RoutingRuleRewrite
+  } satisfies RoutingRuleApply
 }
 
 const matchOperatorsRegex = /[|\\{}()[\]^$+*?.-]/g
@@ -80,8 +75,8 @@ export async function generateRoutingRules(
     hasMiddleware && hasPages && nextAdapterContext.config.skipMiddlewareUrlNormalize
 
   // group redirects by priority, as it impact ordering of routing rules
-  const priorityRedirects: RoutingRuleRedirect[] = []
-  const redirects: RoutingRuleRedirect[] = []
+  const priorityRedirects: RoutingRuleApply[] = []
+  const redirects: RoutingRuleApply[] = []
   for (const redirect of nextAdapterContext.routes.redirects) {
     if (redirect.priority) {
       priorityRedirects.push(
@@ -100,7 +95,7 @@ export async function generateRoutingRules(
     }
   }
 
-  const dynamicRoutes: RoutingRuleRewrite[] = []
+  const dynamicRoutes: RoutingRuleApply[] = []
 
   for (const dynamicRoute of nextAdapterContext.routes.dynamicRoutes) {
     const isNextData = dynamicRoute.sourceRegex.includes('_next/data')
@@ -128,7 +123,7 @@ export async function generateRoutingRules(
     )
   }
 
-  const normalizeNextData: RoutingRuleRewrite[] = shouldDenormalizeJsonDataForMiddleware
+  const normalizeNextData: RoutingRuleApply[] = shouldDenormalizeJsonDataForMiddleware
     ? [
         {
           description: 'Normalize _next/data',
@@ -165,7 +160,7 @@ export async function generateRoutingRules(
       ]
     : []
 
-  const denormalizeNextData: RoutingRuleRewrite[] = shouldDenormalizeJsonDataForMiddleware
+  const denormalizeNextData: RoutingRuleApply[] = shouldDenormalizeJsonDataForMiddleware
     ? [
         {
           description: 'Fix _next/data index denormalization',
@@ -254,7 +249,7 @@ export async function generateRoutingRules(
           //         : ''
           //     }$wildcard${nextAdapterContext.config.trailingSlash ? '/' : ''}`,
           //   },
-          // } satisfies RoutingRuleRewrite,
+          // } satisfies RoutingRuleApply,
 
           // Handle redirecting to locale paths based on NEXT_LOCALE cookie or Accept-Language header
           // eslint-disable-next-line no-negated-condition
@@ -286,7 +281,7 @@ export async function generateRoutingRules(
                 nextAdapterContext.config.i18n.defaultLocale,
               ),
             },
-          } satisfies RoutingRuleRewrite,
+          } satisfies RoutingRuleApply,
           {
             description: 'Auto-prefix non-locale path with default locale',
             match: {
@@ -307,7 +302,7 @@ export async function generateRoutingRules(
                 '$1',
               ),
             },
-          } satisfies RoutingRuleRewrite,
+          } satisfies RoutingRuleApply,
         ]
       : []),
 
@@ -324,7 +319,10 @@ export async function generateRoutingRules(
           {
             // originally: middleware route
             description: 'Middleware',
-            match: { type: 'middleware' },
+            // match: {
+            //   path: 'wat',
+            // },
+            apply: { type: 'middleware' },
           } as const,
         ]
       : []),
@@ -365,7 +363,7 @@ export async function generateRoutingRules(
                 vary: 'RSC, Next-Router-State-Tree, Next-Router-Prefetch',
               },
             },
-          } satisfies RoutingRuleRewrite,
+          } satisfies RoutingRuleApply,
           {
             description: 'Normalize RSC requests',
             match: {
@@ -385,7 +383,7 @@ export async function generateRoutingRules(
                 vary: 'RSC, Next-Router-State-Tree, Next-Router-Prefetch',
               },
             },
-          } satisfies RoutingRuleRewrite,
+          } satisfies RoutingRuleApply,
         ]
       : []),
 
@@ -400,7 +398,7 @@ export async function generateRoutingRules(
       // originally: { handle: 'filesystem' },
       // this is to actual match on things 'filesystem' should match on
       description: 'Static assets or Functions (no dynamic paths for functions)',
-      match: { type: 'static-asset-or-function' },
+      type: 'static-asset-or-function',
     },
 
     // TODO(pieh): do we need this given our next/image url loader/generator?
@@ -430,7 +428,7 @@ export async function generateRoutingRules(
               type: 'rewrite',
               destination: join('/', nextAdapterContext.config.basePath),
             },
-          } satisfies RoutingRuleRewrite,
+          } satisfies RoutingRuleApply,
         ]
       : []),
 
@@ -448,7 +446,7 @@ export async function generateRoutingRules(
               type: 'rewrite',
               destination: join('/', nextAdapterContext.config.basePath, `/index.rsc`),
             },
-          } satisfies RoutingRuleRewrite,
+          } satisfies RoutingRuleApply,
           {
             description: 'Ensure index /.rsc is mapped to .rsc',
             match: {
@@ -458,14 +456,14 @@ export async function generateRoutingRules(
               type: 'rewrite',
               destination: join('/', nextAdapterContext.config.basePath, `$1.rsc`),
             },
-          } satisfies RoutingRuleRewrite,
+          } satisfies RoutingRuleApply,
         ]
       : []),
 
     {
       // originally: { handle: 'resource' },
       description: 'Image CDN',
-      match: { type: 'image-cdn' },
+      type: 'image-cdn',
     },
 
     // ...convertedRewrites.fallback,
@@ -640,8 +638,10 @@ export async function onBuildComplete(
     const routingRules = ${JSON.stringify(routing, null, 2)}
     const outputs = ${JSON.stringify(netlifyAdapterContext.preparedOutputs, null, 2)}
 
+    const asyncLoadMiddleware = outputs.middleware ? () => import('./next_middleware.js').then(mod => mod.default) : () => Promise.reject(new Error('No middleware output'));
+
     export default async function handler(request, context) {
-      return runNextRouting(request, context, routingRules, outputs)
+      return runNextRouting(request, context, routingRules, outputs, asyncLoadMiddleware)
     }
 
     export const config = ${JSON.stringify({
diff --git a/src/adapter/run/routing.ts b/src/adapter/run/routing.ts
index 5996b65800..df2a6e5314 100644
--- a/src/adapter/run/routing.ts
+++ b/src/adapter/run/routing.ts
@@ -1,6 +1,9 @@
 import process from 'node:process'
+import { format } from 'node:util'
 
 import type { Context } from '@netlify/edge-functions'
+import { type Span, SpanStatusCode, trace } from '@opentelemetry/api'
+import { SugaredTracer } from '@opentelemetry/api/experimental'
 
 import type { NetlifyAdapterContext } from '../build/types.js'
 
@@ -30,6 +33,9 @@ type Match = {
     key: string
     value?: string
   }[]
+
+  /** Locale detection */
+  // detectLocale?: { locales: string[]; localeCookie: string }
 }
 
 type CommonApply = {
@@ -37,56 +43,47 @@ type CommonApply = {
   headers?: Record
 }
 
-export type RoutingRuleApply = RoutingRuleBase & {
-  match: Match
-  apply: CommonApply & {
-    type: 'apply'
-  }
-}
-
-export type RoutingRuleRedirect = RoutingRuleBase & {
-  match: Match
-  apply: CommonApply & {
-    type: 'redirect'
-    /** Can use capture groups from match.path */
-    destination: string
-    /** Allowed redirect status code, defaults to 307 if not defined */
-    statusCode?: 301 | 302 | 307 | 308
-  }
-}
-
-export type RoutingRuleRewrite = RoutingRuleBase & {
-  match: Match
-  apply: CommonApply & {
-    type: 'rewrite'
-    /** Can use capture groups from match.path */
-    destination: string
-    /** Forced status code for response, if not defined rewrite response status code will be used */
-    statusCode?: 200 | 404 | 500
-    /** Phases to re-run after matching this rewrite */
-    rerunRoutingPhases?: RoutingPhase[]
-  }
-}
-
 export type RoutingRuleMatchPrimitive = RoutingRuleBase & {
-  match: {
-    type: 'static-asset-or-function' | 'middleware' | 'image-cdn'
-  }
+  type: 'static-asset-or-function' | 'image-cdn'
 }
 
 export type RoutingPhaseRule = RoutingRuleBase & {
   routingPhase: RoutingPhase
 }
 
-export type RoutingRule =
-  | RoutingRuleApply
-  | RoutingRuleRedirect
-  | RoutingRuleRewrite
-  | RoutingPhaseRule
-  | RoutingRuleMatchPrimitive
+export type RoutingRuleApply = RoutingRuleBase & {
+  match?: Match
+  apply:
+    | (CommonApply & {
+        type: 'apply'
+      })
+    | {
+        type: 'middleware'
+      }
+    | (CommonApply & {
+        type: 'rewrite'
+        /** Can use capture groups from match.path */
+        destination: string
+        /** Forced status code for response, if not defined rewrite response status code will be used */
+        statusCode?: 200 | 404 | 500
+        /** Phases to re-run after matching this rewrite */
+        rerunRoutingPhases?: RoutingPhase[]
+      })
+    | (CommonApply & {
+        type: 'redirect'
+        /** Can use capture groups from match.path */
+        destination: string
+        /** Allowed redirect status code, defaults to 307 if not defined */
+        statusCode?: 301 | 302 | 307 | 308
+      })
+}
+
+export type RoutingRule = RoutingRuleApply | RoutingPhaseRule | RoutingRuleMatchPrimitive
+
+export type RoutingRuleWithoutPhase = Exclude
 
 function selectRoutingPhasesRules(routingRules: RoutingRule[], phases: RoutingPhase[]) {
-  const selectedRules: RoutingRule[] = []
+  const selectedRules: RoutingRuleWithoutPhase[] = []
   let currentPhase: RoutingPhase | undefined
   for (const rule of routingRules) {
     if ('routingPhase' in rule) {
@@ -118,17 +115,29 @@ function replaceGroupReferences(input: string, replacements: Record void,
   initialResponse: MaybeResponse,
+  asyncLoadMiddleware: () => Promise<(req: Request) => Promise>,
+  tracer: SugaredTracer,
+  spanName: string,
 ): Promise<{ maybeResponse: MaybeResponse; currentRequest: Request }> {
   let currentRequest = request
   let maybeResponse: MaybeResponse = initialResponse
@@ -136,273 +145,473 @@ async function match(
   const currentURL = new URL(currentRequest.url)
   let { pathname } = currentURL
 
-  for (const rule of routingRules) {
-    if (prefix) {
-      console.log(prefix, 'Evaluating rule:', rule.description ?? JSON.stringify(rule))
-    }
-
-    if ('match' in rule) {
-      let matched = false
-
-      if ('type' in rule.match) {
-        if (rule.match.type === 'static-asset-or-function') {
-          let matchedType: 'static-asset' | 'function' | 'static-asset-alias' | null = null
-
-          // below assumes no overlap between static assets (files and aliases) and functions so order of checks "doesn't matter"
-          // unclear what should be precedence if there would ever be overlap
-          if (outputs.staticAssets.includes(pathname)) {
-            matchedType = 'static-asset'
-          } else if (outputs.endpoints.includes(pathname.toLowerCase())) {
-            matchedType = 'function'
-          } else {
-            const staticAlias = outputs.staticAssetsAliases[pathname]
-            if (staticAlias) {
-              matchedType = 'static-asset-alias'
-              currentRequest = new Request(new URL(staticAlias, currentRequest.url), currentRequest)
-              pathname = staticAlias
+  return tracer.withActiveSpan(spanName, async (span) => {
+    for (const rule of routingRules) {
+      const desc = rule.description ?? JSON.stringify(rule)
+      // eslint-disable-next-line no-loop-func
+      const result = await tracer.withActiveSpan(desc, async (span) => {
+        log('Evaluating rule:', desc)
+
+        let matched = false
+
+        if ('type' in rule) {
+          if (rule.type === 'static-asset-or-function') {
+            let matchedType: 'static-asset' | 'function' | 'static-asset-alias' | null = null
+
+            // below assumes no overlap between static assets (files and aliases) and functions so order of checks "doesn't matter"
+            // unclear what should be precedence if there would ever be overlap
+            if (outputs.staticAssets.includes(pathname)) {
+              matchedType = 'static-asset'
+            } else if (outputs.endpoints.includes(pathname.toLowerCase())) {
+              matchedType = 'function'
+            } else {
+              const staticAlias = outputs.staticAssetsAliases[pathname]
+              if (staticAlias) {
+                matchedType = 'static-asset-alias'
+                currentRequest = new Request(
+                  new URL(staticAlias, currentRequest.url),
+                  currentRequest,
+                )
+                pathname = staticAlias
+              }
             }
-          }
 
-          if (matchedType) {
-            if (prefix) {
-              console.log(
-                prefix,
+            if (matchedType) {
+              log(
                 `Matched static asset or function (${matchedType}): ${pathname} -> ${currentRequest.url}`,
               )
+
+              maybeResponse = {
+                ...maybeResponse,
+                response: await context.next(currentRequest),
+              }
+              matched = true
             }
+          } else if (rule.type === 'image-cdn' && pathname.startsWith('/.netlify/image/')) {
+            log('Matched image cdn:', pathname)
+
             maybeResponse = {
               ...maybeResponse,
               response: await context.next(currentRequest),
             }
             matched = true
           }
-        } else if (rule.match.type === 'image-cdn' && pathname.startsWith('/.netlify/image/')) {
-          if (prefix) {
-            console.log(prefix, 'Matched image cdn:', pathname)
-          }
-
-          maybeResponse = {
-            ...maybeResponse,
-            response: await context.next(currentRequest),
-          }
-          matched = true
-        }
-      } else if ('apply' in rule) {
-        const replacements: Record = {}
-
-        if (rule.match.path) {
-          const sourceRegexp = new RegExp(rule.match.path)
-          const sourceMatch = pathname.match(sourceRegexp)
-          if (sourceMatch) {
-            if (sourceMatch.groups) {
-              for (const [key, value] of Object.entries(sourceMatch.groups)) {
-                replacements[`$${key}`] = value
+        } else {
+          const replacements: Record = {}
+
+          if (rule.match?.path) {
+            const sourceRegexp = new RegExp(rule.match.path)
+            const sourceMatch = pathname.match(sourceRegexp)
+            if (sourceMatch) {
+              if (sourceMatch.groups) {
+                for (const [key, value] of Object.entries(sourceMatch.groups)) {
+                  replacements[`$${key}`] = value
+                }
               }
+              for (const [index, element] of sourceMatch.entries()) {
+                replacements[`$${index}`] = element ?? ''
+              }
+            } else {
+              span.setStatus({ code: SpanStatusCode.ERROR, message: 'Miss' })
+              return
             }
-            for (const [index, element] of sourceMatch.entries()) {
-              replacements[`$${index}`] = element ?? ''
-            }
-          } else {
-            continue
           }
-        }
 
-        if (rule.match.has) {
-          let hasAllMatch = true
-          for (const condition of rule.match.has) {
-            if (condition.type === 'header') {
-              if (typeof condition.value === 'undefined') {
-                if (!currentRequest.headers.has(condition.key)) {
+          if (rule.match?.has) {
+            let hasAllMatch = true
+            for (const condition of rule.match.has) {
+              if (condition.type === 'header') {
+                if (typeof condition.value === 'undefined') {
+                  if (!currentRequest.headers.has(condition.key)) {
+                    hasAllMatch = false
+                    break
+                  }
+                } else if (currentRequest.headers.get(condition.key) !== condition.value) {
                   hasAllMatch = false
                   break
                 }
-              } else if (currentRequest.headers.get(condition.key) !== condition.value) {
-                hasAllMatch = false
-                break
               }
             }
-          }
 
-          if (!hasAllMatch) {
-            continue
+            if (!hasAllMatch) {
+              span.setStatus({ code: SpanStatusCode.ERROR, message: 'Miss' })
+              return
+            }
           }
-        }
 
-        if (prefix) {
-          console.log(prefix, 'Matched rule', pathname, rule, replacements)
-        }
+          matched = true
 
-        if (rule.apply.headers) {
-          maybeResponse = {
-            ...maybeResponse,
-            headers: {
-              ...maybeResponse.headers,
-              ...Object.fromEntries(
-                Object.entries(rule.apply.headers).map(([key, value]) => {
-                  return [key, replaceGroupReferences(value, replacements)]
-                }),
-              ),
-            },
-          }
-        }
+          log('Matched rule', pathname, rule, replacements)
 
-        if (rule.apply.type === 'rewrite') {
-          const replaced = replaceGroupReferences(rule.apply.destination, replacements)
+          if (rule.apply.type === 'middleware') {
+            if (outputs.middleware) {
+              const runMiddleware = await asyncLoadMiddleware()
 
-          // pathname.replace(sourceRegexp, rule.apply.destination)
-          const destURL = new URL(replaced, currentURL)
-          currentRequest = new Request(destURL, currentRequest)
+              const middlewareResponse = await runMiddleware(currentRequest)
 
-          if (rule.apply.statusCode) {
-            maybeResponse = {
-              ...maybeResponse,
-              status: rule.apply.statusCode,
+              // we do get response, but sometimes response might want to rewrite, so we need to process that response and convert to routing setup
+
+              // const rewrite = middlewareResponse.headers.get('x-middleware-rewrite')
+              const redirect = middlewareResponse.headers.get('location')
+              const nextRedirect = middlewareResponse.headers.get('x-nextjs-redirect')
+              const isNext = middlewareResponse.headers.get('x-middleware-next')
+
+              const requestHeaders = new Headers(currentRequest.headers)
+
+              const overriddenHeaders = middlewareResponse.headers.get(
+                'x-middleware-override-headers',
+              )
+              if (overriddenHeaders) {
+                const headersToUpdate = new Set(
+                  overriddenHeaders.split(',').map((header) => header.trim()),
+                )
+                middlewareResponse.headers.delete('x-middleware-override-headers')
+
+                // Delete headers.
+                // eslint-disable-next-line unicorn/no-useless-spread
+                for (const key of [...requestHeaders.keys()]) {
+                  if (!headersToUpdate.has(key)) {
+                    requestHeaders.delete(key)
+                  }
+                }
+
+                // Update or add headers.
+                for (const header of headersToUpdate) {
+                  const oldHeaderKey = `x-middleware-request-${header}`
+                  const headerValue = middlewareResponse.headers.get(oldHeaderKey) || ''
+
+                  const oldValue = requestHeaders.get(header) || ''
+
+                  if (oldValue !== headerValue) {
+                    if (headerValue) {
+                      requestHeaders.set(header, headerValue)
+                    } else {
+                      requestHeaders.delete(header)
+                    }
+                  }
+                  middlewareResponse.headers.delete(oldHeaderKey)
+                }
+              }
+
+              if (
+                !middlewareResponse.headers.has('x-middleware-rewrite') &&
+                !middlewareResponse.headers.has('x-middleware-next') &&
+                !middlewareResponse.headers.has('location')
+              ) {
+                middlewareResponse.headers.set('x-middleware-refresh', '1')
+              }
+              middlewareResponse.headers.delete('x-middleware-next')
+
+              for (const [key, value] of middlewareResponse.headers.entries()) {
+                if (
+                  [
+                    'content-length',
+                    'x-middleware-rewrite',
+                    'x-middleware-redirect',
+                    'x-middleware-refresh',
+                    'accept-encoding',
+                    'keepalive',
+                    'keep-alive',
+                    'content-encoding',
+                    'transfer-encoding',
+                    // https://github.com/nodejs/undici/issues/1470
+                    'connection',
+                    // marked as unsupported by undici: https://github.com/nodejs/undici/blob/c83b084879fa0bb8e0469d31ec61428ac68160d5/lib/core/request.js#L354
+                    'expect',
+                  ].includes(key)
+                ) {
+                  continue
+                }
+
+                // for set-cookie, the header shouldn't be added to the response
+                // as it's only needed for the request to the middleware function.
+                if (key === 'x-middleware-set-cookie') {
+                  requestHeaders.set(key, value)
+                  continue
+                }
+
+                if (key === 'location') {
+                  maybeResponse = {
+                    ...maybeResponse,
+                    headers: {
+                      ...maybeResponse.headers,
+                      [key]: relativizeURL(value, currentRequest.url),
+                    },
+                  }
+                  // relativizeURL(value, currentRequest.url)
+                }
+
+                if (value) {
+                  requestHeaders.set(key, value)
+
+                  maybeResponse = {
+                    ...maybeResponse,
+                    headers: {
+                      ...maybeResponse.headers,
+                      [key]: value,
+                    },
+                  }
+                }
+              }
+
+              currentRequest = new Request(currentRequest.url, {
+                ...currentRequest,
+                headers: requestHeaders,
+              })
+
+              const rewrite = middlewareResponse.headers.get('x-middleware-rewrite')
+              console.log('Middleware response', {
+                status: middlewareResponse.status,
+                rewrite,
+                redirect,
+                nextRedirect,
+                overriddenHeaders,
+                isNext,
+                // requestHeaders,
+              })
+
+              if (rewrite) {
+                log('Middleware rewrite to', rewrite)
+                const rewriteUrl = new URL(rewrite, currentRequest.url)
+                const baseUrl = new URL(currentRequest.url)
+                if (rewriteUrl.toString() === baseUrl.toString()) {
+                  log('Rewrite url is same as original url')
+                }
+                currentRequest = new Request(
+                  new URL(rewriteUrl, currentRequest.url),
+                  currentRequest,
+                )
+              } else if (nextRedirect) {
+                // just continue
+                // } else if (redirect) {
+                //   relativizeURL(redirect, currentRequest.url)
+              } else if (isNext) {
+                // just continue
+              } else {
+                // this includes redirect case
+                maybeResponse = {
+                  ...maybeResponse,
+                  response: middlewareResponse,
+                }
+              }
+            }
+          } else {
+            if (rule.apply.headers) {
+              maybeResponse = {
+                ...maybeResponse,
+                headers: {
+                  ...maybeResponse.headers,
+                  ...Object.fromEntries(
+                    Object.entries(rule.apply.headers).map(([key, value]) => {
+                      return [key, replaceGroupReferences(value, replacements)]
+                    }),
+                  ),
+                },
+              }
             }
-          }
 
-          if (rule.apply.rerunRoutingPhases) {
-            const { maybeResponse: updatedMaybeResponse } = await match(
-              currentRequest,
-              context,
-              selectRoutingPhasesRules(routingRules, rule.apply.rerunRoutingPhases),
-              allRoutingRules,
-              outputs,
-              prefix,
-              maybeResponse,
-            )
-            maybeResponse = updatedMaybeResponse
-          }
-        } else if (rule.apply.type === 'redirect') {
-          const replaced = replaceGroupReferences(rule.apply.destination, replacements)
-          if (prefix) {
-            console.log(prefix, `Redirecting ${pathname} to ${replaced}`)
-          }
-          const status = rule.apply.statusCode ?? 307
-          maybeResponse = {
-            ...maybeResponse,
-            status,
-            response: new Response(null, {
-              status,
-              headers: {
-                Location: replaced,
-              },
-            }),
+            if (rule.apply.type === 'rewrite') {
+              const replaced = replaceGroupReferences(rule.apply.destination, replacements)
+
+              const destURL = new URL(replaced, currentURL)
+              currentRequest = new Request(destURL, currentRequest)
+
+              if (rule.apply.statusCode) {
+                maybeResponse = {
+                  ...maybeResponse,
+                  status: rule.apply.statusCode,
+                }
+              }
+
+              if (rule.apply.rerunRoutingPhases) {
+                const { maybeResponse: updatedMaybeResponse } = await match(
+                  currentRequest,
+                  context,
+                  selectRoutingPhasesRules(routingRules, rule.apply.rerunRoutingPhases),
+                  allRoutingRules,
+                  outputs,
+                  log,
+                  maybeResponse,
+                  asyncLoadMiddleware,
+                  tracer,
+                  `Running phases: ${rule.apply.rerunRoutingPhases.join(', ')}`,
+                )
+                maybeResponse = updatedMaybeResponse
+              }
+            } else if (rule.apply.type === 'redirect') {
+              const replaced = replaceGroupReferences(rule.apply.destination, replacements)
+
+              log(`Redirecting ${pathname} to ${replaced}`)
+
+              const status = rule.apply.statusCode ?? 307
+              maybeResponse = {
+                ...maybeResponse,
+                status,
+                response: new Response(null, {
+                  status,
+                  headers: {
+                    Location: replaced,
+                  },
+                }),
+              }
+            }
           }
         }
-        matched = true
-      }
 
-      if (matched && !rule.continue) {
-        // once hit a match short circuit
-        return { maybeResponse, currentRequest }
+        if (matched && !rule.continue) {
+          // once hit a match short circuit, unless we should continue
+          return { maybeResponse, currentRequest }
+        }
+
+        if (!matched) {
+          span.setStatus({ code: SpanStatusCode.ERROR, message: 'Miss' })
+        }
+      })
+
+      if (result) {
+        return result
       }
     }
-  }
-  return { maybeResponse, currentRequest }
+    return { maybeResponse, currentRequest }
+  })
 }
 
+// eslint-disable-next-line max-params
 export async function runNextRouting(
   request: Request,
   context: Context,
   routingRules: RoutingRule[],
   outputs: NetlifyAdapterContext['preparedOutputs'],
+  asyncLoadMiddleware: () => Promise<(req: Request) => Promise>,
 ) {
   if (request.headers.has('x-ntl-routing')) {
     // don't route multiple times for same request
     return
   }
 
-  const prefix = request.url.includes('_next/static')
-    ? undefined
-    : `[${
-        request.headers.get('x-nf-request-id') ??
-        // for ntl serve, we use a combination of timestamp and pid to have a unique id per request as we don't have x-nf-request-id header then
-        // eslint-disable-next-line no-plusplus
-        `${Date.now()} - #${process.pid}:${++requestCounter}`
-      }]`
-
-  if (prefix) {
-    console.log(prefix, 'Incoming request for routing:', request.url)
-  }
+  const tracer = new SugaredTracer(trace.getTracer('next-routing', '0.0.1'))
+  const { pathname } = new URL(request.url)
+
+  return tracer.withActiveSpan(`next_routing ${request.method} ${pathname}`, async (span) => {
+    const stdoutPrefix = request.url.includes('.well-known')
+      ? undefined
+      : `[${
+          request.headers.get('x-nf-request-id') ??
+          // for ntl serve, we use a combination of timestamp and pid to have a unique id per request as we don't have x-nf-request-id header then
+          // eslint-disable-next-line no-plusplus
+          `${Date.now()} - #${process.pid}:${++requestCounter}`
+        }]`
+
+    const spanCounter = new WeakMap()
+    const log = (fmt: string, ...args: any) => {
+      const formatted = format(fmt, ...args)
+      if (stdoutPrefix) {
+        console.log(stdoutPrefix, formatted)
+      }
 
-  let currentRequest = new Request(request)
-  currentRequest.headers.set('x-ntl-routing', '1')
-
-  let { maybeResponse, currentRequest: updatedCurrentRequest } = await match(
-    currentRequest,
-    context,
-    selectRoutingPhasesRules(routingRules, routingPhasesWithoutHitOrError),
-    routingRules,
-    outputs,
-    prefix,
-    {
-      [NOT_A_FETCH_RESPONSE]: true,
-    },
-  )
-  currentRequest = updatedCurrentRequest
-
-  if (!maybeResponse.response) {
-    // check other things
-    maybeResponse = {
-      ...maybeResponse,
-      response: await context.next(currentRequest),
+      const currentSpan = trace.getActiveSpan()
+      if (currentSpan) {
+        const currentSpanCounter = (spanCounter.get(currentSpan) ?? 0) + 1
+        spanCounter.set(currentSpan, currentSpanCounter)
+        currentSpan.setAttribute(`log.${String(currentSpanCounter).padStart(3, ' ')}`, formatted)
+      }
     }
-  }
-
-  let response: Response
 
-  if (maybeResponse.response && (maybeResponse.status ?? maybeResponse.response?.status !== 404)) {
-    const initialResponse = maybeResponse.response
-    const { maybeResponse: updatedMaybeResponse } = await match(
-      currentRequest,
-      context,
-      selectRoutingPhasesRules(routingRules, ['hit']),
-      routingRules,
-      outputs,
-      prefix,
-      maybeResponse,
-    )
-    maybeResponse = updatedMaybeResponse
+    log('Incoming request for routing:', request.url)
 
-    const finalResponse = maybeResponse.response ?? initialResponse
+    let currentRequest = new Request(request)
+    currentRequest.headers.set('x-ntl-routing', '1')
 
-    response = new Response(finalResponse.body, {
-      ...finalResponse,
-      headers: {
-        ...Object.fromEntries(finalResponse.headers.entries()),
-        ...maybeResponse.headers,
-      },
-      status: maybeResponse.status ?? finalResponse.status ?? 200,
-    })
-  } else {
-    const { maybeResponse: updatedMaybeResponse } = await match(
+    let { maybeResponse, currentRequest: updatedCurrentRequest } = await match(
       currentRequest,
       context,
-      selectRoutingPhasesRules(routingRules, ['error']),
+      selectRoutingPhasesRules(routingRules, routingPhasesWithoutHitOrError),
       routingRules,
       outputs,
-      prefix,
-      { ...maybeResponse, status: 404 },
+      log,
+      {
+        [NOT_A_FETCH_RESPONSE]: true,
+      },
+      asyncLoadMiddleware,
+      tracer,
+      'Routing Phases Before Hit/Error',
     )
-    maybeResponse = updatedMaybeResponse
+    currentRequest = updatedCurrentRequest
 
-    const finalResponse = maybeResponse.response ?? new Response('Not Found', { status: 404 })
+    if (!maybeResponse.response) {
+      // check other things
+      maybeResponse = {
+        ...maybeResponse,
+        response: await context.next(currentRequest),
+      }
+    }
 
-    response = new Response(finalResponse.body, {
-      ...finalResponse,
-      headers: {
-        ...Object.fromEntries(finalResponse.headers.entries()),
-        ...maybeResponse.headers,
-      },
-      status: maybeResponse.status ?? finalResponse.status ?? 200,
-    })
-  }
+    let response: Response
+
+    if (
+      maybeResponse.response &&
+      (maybeResponse.status ?? maybeResponse.response?.status !== 404)
+    ) {
+      const initialResponse = maybeResponse.response
+      const { maybeResponse: updatedMaybeResponse } = await match(
+        currentRequest,
+        context,
+        selectRoutingPhasesRules(routingRules, ['hit']),
+        routingRules,
+        outputs,
+        log,
+        maybeResponse,
+        asyncLoadMiddleware,
+        tracer,
+        'Hit Routing Phase',
+      )
+      maybeResponse = updatedMaybeResponse
+
+      const finalResponse = maybeResponse.response ?? initialResponse
+
+      response = new Response(finalResponse.body, {
+        ...finalResponse,
+        headers: {
+          ...Object.fromEntries(finalResponse.headers.entries()),
+          ...maybeResponse.headers,
+        },
+        status: maybeResponse.status ?? finalResponse.status ?? 200,
+      })
+    } else {
+      const { maybeResponse: updatedMaybeResponse } = await match(
+        currentRequest,
+        context,
+        selectRoutingPhasesRules(routingRules, ['error']),
+        routingRules,
+        outputs,
+        log,
+        { ...maybeResponse, status: 404 },
+        asyncLoadMiddleware,
+        tracer,
+        'Error Routing Phase',
+      )
+      maybeResponse = updatedMaybeResponse
+
+      const finalResponse = maybeResponse.response ?? new Response('Not Found', { status: 404 })
+
+      response = new Response(finalResponse.body, {
+        ...finalResponse,
+        headers: {
+          ...Object.fromEntries(finalResponse.headers.entries()),
+          ...maybeResponse.headers,
+        },
+        status: maybeResponse.status ?? finalResponse.status ?? 200,
+      })
+    }
 
-  // for debugging add log prefixes to response headers to make it easy to find logs for a given request
-  if (prefix) {
-    response.headers.set('x-ntl-log-prefix', prefix)
-    console.log(prefix, 'Serving response', response.status)
-  }
+    log('Serving response', response.status)
+
+    // for debugging add log prefixes to response headers to make it easy to find logs for a given request
+    // if (prefix) {
+    //   response.headers.set('x-ntl-log-prefix', prefix)
+    //   console.log(prefix, 'Serving response', response.status)
+    // }
 
-  return response
+    return response
+  })
 }

From befe21a710fceb3f28baa7ef8b85578b788cf84f Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Thu, 6 Nov 2025 12:59:52 +0100
Subject: [PATCH 71/75] more

---
 edge-runtime/lib/next-request.ts | 12 +++++-------
 edge-runtime/middleware.ts       | 10 +++++++---
 src/adapter/adapter.ts           |  2 +-
 src/adapter/build/middleware.ts  | 28 +++++++++++++++++++++++-----
 src/adapter/build/routing.ts     | 22 +++++++++++++---------
 src/adapter/run/routing.ts       | 25 +++++++++++++++++++------
 6 files changed, 68 insertions(+), 31 deletions(-)

diff --git a/edge-runtime/lib/next-request.ts b/edge-runtime/lib/next-request.ts
index 4e28401310..29ab555d66 100644
--- a/edge-runtime/lib/next-request.ts
+++ b/edge-runtime/lib/next-request.ts
@@ -2,7 +2,10 @@ import type { Context } from '@netlify/edge-functions'
 
 import type { RequestData } from './types'
 
-export const buildNextRequest = (request: Request): RequestData => {
+export const buildNextRequest = (
+  request: Request,
+  nextConfig: RequestData['nextConfig'],
+): RequestData => {
   const { url, method, body, headers } = request
 
   // we don't really use it but Next.js expects a signal
@@ -11,12 +14,7 @@ export const buildNextRequest = (request: Request): RequestData => {
   return {
     headers: Object.fromEntries(headers.entries()),
     method,
-    // nextConfig?: {
-    //     basePath?: string;
-    //     i18n?: I18NConfig | null;
-    //     trailingSlash?: boolean;
-    //     experimental?: Pick;
-    // };
+    nextConfig,
     // page?: {
     //     name?: string;
     //     params?: {
diff --git a/edge-runtime/middleware.ts b/edge-runtime/middleware.ts
index 46e844f633..f0447caa24 100644
--- a/edge-runtime/middleware.ts
+++ b/edge-runtime/middleware.ts
@@ -3,7 +3,7 @@
 import { InternalHeaders } from './lib/headers.ts'
 import { logger, LogLevel } from './lib/logging.ts'
 import { buildNextRequest } from './lib/next-request.ts'
-import type { NextHandler } from './lib/types.ts'
+import type { NextHandler, RequestData } from './lib/types.ts'
 // import { buildResponse } from './lib/response.ts'
 
 /**
@@ -15,7 +15,11 @@ import type { NextHandler } from './lib/types.ts'
  * @param context Netlify-specific context object
  * @param nextHandler Next.js middleware handler
  */
-export async function handleMiddleware(request: Request, nextHandler: NextHandler) {
+export async function handleMiddleware(
+  request: Request,
+  nextHandler: NextHandler,
+  nextConfig: RequestData['nextConfig'],
+) {
   const url = new URL(request.url)
 
   const reqLogger = logger
@@ -25,7 +29,7 @@ export async function handleMiddleware(request: Request, nextHandler: NextHandle
     .withFields({ url_path: url.pathname })
     .withRequestID(request.headers.get(InternalHeaders.NFRequestID))
 
-  const nextRequest = buildNextRequest(request)
+  const nextRequest = buildNextRequest(request, nextConfig)
   try {
     const result = await nextHandler({ request: nextRequest })
 
diff --git a/src/adapter/adapter.ts b/src/adapter/adapter.ts
index 7eb8655ccd..78c7ceccfd 100644
--- a/src/adapter/adapter.ts
+++ b/src/adapter/adapter.ts
@@ -33,7 +33,7 @@ const adapter: NextAdapter = {
     await writeFile('./onBuildComplete.json', JSON.stringify(nextAdapterContext, null, 2))
     // debugger
 
-    const netlifyAdapterContext = createNetlifyAdapterContext(nextAdapterContext)
+    const netlifyAdapterContext = createNetlifyAdapterContext()
 
     await onBuildCompleteForImageCDN(nextAdapterContext, netlifyAdapterContext)
     await onBuildCompleteForMiddleware(nextAdapterContext, netlifyAdapterContext)
diff --git a/src/adapter/build/middleware.ts b/src/adapter/build/middleware.ts
index f9793bf7bd..96905d2eda 100644
--- a/src/adapter/build/middleware.ts
+++ b/src/adapter/build/middleware.ts
@@ -1,13 +1,17 @@
 import { cp, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'
 import { dirname, join, parse, relative } from 'node:path/posix'
 
-import type { IntegrationsConfig } from '@netlify/edge-functions'
 import { glob } from 'fast-glob'
-import { pathToRegexp } from 'path-to-regexp'
+
+import type { RequestData } from '../../../edge-runtime/lib/types.ts'
+
+// import type { IntegrationsConfig } from '@netlify/edge-functions'
+
+// import { pathToRegexp } from 'path-to-regexp'
 
 import {
-  DISPLAY_NAME_MIDDLEWARE,
-  GENERATOR,
+  // DISPLAY_NAME_MIDDLEWARE,
+  // GENERATOR,
   NETLIFY_FRAMEWORKS_API_EDGE_FUNCTIONS,
   PLUGIN_DIR,
 } from './constants.js'
@@ -152,6 +156,18 @@ const writeHandlerFile = async (
   // Netlify Edge Functions and the Next.js edge runtime.
   await copyRuntime(MIDDLEWARE_FUNCTION_DIR)
 
+  const nextConfigForMiddleware: RequestData['nextConfig'] = {
+    basePath: nextConfig.basePath,
+    i18n: nextConfig.i18n,
+    trailingSlash: nextConfig.trailingSlash,
+    experimental: {
+      // Include any experimental config that might affect middleware behavior
+      cacheLife: nextConfig.experimental?.cacheLife,
+      authInterrupts: nextConfig.experimental?.authInterrupts,
+      clientParamParsingOrigins: nextConfig.experimental?.clientParamParsingOrigins,
+    },
+  }
+
   // Writing a file with the matchers that should trigger this function. We'll
   // read this file from the function at runtime.
   // await writeFile(
@@ -196,7 +212,9 @@ const writeHandlerFile = async (
     import { handleMiddleware } from './edge-runtime/middleware.ts';
     import handler from './concatenated-file.js';
 
-    export default (req) => handleMiddleware(req, handler);
+    const nextConfig = ${JSON.stringify(nextConfigForMiddleware)}
+
+    export default (req) => handleMiddleware(req, handler, nextConfig);
     `,
   )
 }
diff --git a/src/adapter/build/routing.ts b/src/adapter/build/routing.ts
index f02a74cefc..7424b191cb 100644
--- a/src/adapter/build/routing.ts
+++ b/src/adapter/build/routing.ts
@@ -62,7 +62,7 @@ export async function generateRoutingRules(
   nextAdapterContext: OnBuildCompleteContext,
   netlifyAdapterContext: NetlifyAdapterContext,
 ) {
-  const escapedBuildId = escapeStringRegexp(nextAdapterContext.buildId)
+  // const escapedBuildId = escapeStringRegexp(nextAdapterContext.buildId)
 
   const hasMiddleware = Boolean(nextAdapterContext.outputs.middleware)
   const hasPages =
@@ -72,7 +72,7 @@ export async function generateRoutingRules(
     nextAdapterContext.outputs.appPages.length !== 0 ||
     nextAdapterContext.outputs.appRoutes.length !== 0
   const shouldDenormalizeJsonDataForMiddleware =
-    hasMiddleware && hasPages && nextAdapterContext.config.skipMiddlewareUrlNormalize
+    hasMiddleware && hasPages && !nextAdapterContext.config.skipMiddlewareUrlNormalize
 
   // group redirects by priority, as it impact ordering of routing rules
   const priorityRedirects: RoutingRuleApply[] = []
@@ -128,7 +128,7 @@ export async function generateRoutingRules(
         {
           description: 'Normalize _next/data',
           match: {
-            path: `^${nextAdapterContext.config.basePath}/_next/data/${escapedBuildId}/(.*)\\.json`,
+            path: `^${nextAdapterContext.config.basePath}/_next/data/${nextAdapterContext.buildId}/(.*)\\.json`,
             has: [
               {
                 type: 'header',
@@ -140,6 +140,7 @@ export async function generateRoutingRules(
             type: 'rewrite',
             destination: `${nextAdapterContext.config.basePath}/$1${nextAdapterContext.config.trailingSlash ? '/' : ''}`,
           },
+          continue: true,
         },
         {
           description: 'Fix _next/data index normalization',
@@ -156,6 +157,7 @@ export async function generateRoutingRules(
             type: 'rewrite',
             destination: `${nextAdapterContext.config.basePath}/`,
           },
+          continue: true,
         },
       ]
     : []
@@ -177,6 +179,7 @@ export async function generateRoutingRules(
             type: 'rewrite',
             destination: `${nextAdapterContext.config.basePath}/index`,
           },
+          continue: true,
         },
         {
           description: 'Denormalize _next/data',
@@ -193,6 +196,7 @@ export async function generateRoutingRules(
             type: 'rewrite',
             destination: `${nextAdapterContext.config.basePath}/_next/data/${nextAdapterContext.buildId}/$1.json`,
           },
+          continue: true,
         },
       ]
     : []
@@ -312,14 +316,15 @@ export async function generateRoutingRules(
 
     // server actions name meta routes
 
-    ...denormalizeNextData, // originally: // if skip middleware url normalize we denormalize _next/data if middleware + pages
-
     ...(hasMiddleware
       ? [
           {
             // originally: middleware route
             description: 'Middleware',
             // match: {
+            //   path: 'test',
+            // },
+            // match: {
             //   path: 'wat',
             // },
             apply: { type: 'middleware' },
@@ -327,8 +332,6 @@ export async function generateRoutingRules(
         ]
       : []),
 
-    ...normalizeNextData, // originally: // if skip middleware url normalize we normalize _next/data if middleware + pages
-
     // ...convertedRewrites.beforeFiles,
 
     // add 404 handling if /404 or locale variants are requested literally
@@ -487,6 +490,7 @@ export async function generateRoutingRules(
       routingPhase: 'rewrite',
     },
 
+    ...denormalizeNextData,
     // denormalize _next/data if middleware + pages
 
     // apply _next/data routes (including static ones if middleware + pages)
@@ -508,7 +512,7 @@ export async function generateRoutingRules(
                 '/',
                 nextAdapterContext.config.basePath,
                 '/_next/data/',
-                escapedBuildId,
+                nextAdapterContext.buildId,
                 '/(.*).json',
               )}`,
             },
@@ -529,7 +533,7 @@ export async function generateRoutingRules(
                 '/',
                 nextAdapterContext.config.basePath,
                 '/_next/data/',
-                escapedBuildId,
+                nextAdapterContext.buildId,
                 '/(.*).json',
               )}`,
             },
diff --git a/src/adapter/run/routing.ts b/src/adapter/run/routing.ts
index df2a6e5314..9c17b5fe92 100644
--- a/src/adapter/run/routing.ts
+++ b/src/adapter/run/routing.ts
@@ -142,17 +142,18 @@ async function match(
   let currentRequest = request
   let maybeResponse: MaybeResponse = initialResponse
 
-  const currentURL = new URL(currentRequest.url)
-  let { pathname } = currentURL
-
   return tracer.withActiveSpan(spanName, async (span) => {
     for (const rule of routingRules) {
+      const currentURL = new URL(currentRequest.url)
+      const { pathname } = currentURL
+
       const desc = rule.description ?? JSON.stringify(rule)
       // eslint-disable-next-line no-loop-func
       const result = await tracer.withActiveSpan(desc, async (span) => {
-        log('Evaluating rule:', desc)
+        log('Evaluating rule:', desc, pathname)
 
         let matched = false
+        let shouldContinueOnMatch = rule.continue ?? false
 
         if ('type' in rule) {
           if (rule.type === 'static-asset-or-function') {
@@ -172,7 +173,7 @@ async function match(
                   new URL(staticAlias, currentRequest.url),
                   currentRequest,
                 )
-                pathname = staticAlias
+                // pathname = staticAlias
               }
             }
 
@@ -213,6 +214,7 @@ async function match(
               }
             } else {
               span.setStatus({ code: SpanStatusCode.ERROR, message: 'Miss' })
+              // log('Path did not match regex', rule.match.path, pathname)
               return
             }
           }
@@ -224,10 +226,18 @@ async function match(
                 if (typeof condition.value === 'undefined') {
                   if (!currentRequest.headers.has(condition.key)) {
                     hasAllMatch = false
+                    // log('request header does not exist', {
+                    //   key: condition.key,
+                    // })
                     break
                   }
                 } else if (currentRequest.headers.get(condition.key) !== condition.value) {
                   hasAllMatch = false
+                  // log('request header not the same', {
+                  //   key: condition.key,
+                  //   match: condition.value,
+                  //   actual: currentRequest.headers.get(condition.key),
+                  // })
                   break
                 }
               }
@@ -381,12 +391,15 @@ async function match(
                   new URL(rewriteUrl, currentRequest.url),
                   currentRequest,
                 )
+                shouldContinueOnMatch = true
               } else if (nextRedirect) {
+                shouldContinueOnMatch = true
                 // just continue
                 // } else if (redirect) {
                 //   relativizeURL(redirect, currentRequest.url)
               } else if (isNext) {
                 // just continue
+                shouldContinueOnMatch = true
               } else {
                 // this includes redirect case
                 maybeResponse = {
@@ -458,7 +471,7 @@ async function match(
           }
         }
 
-        if (matched && !rule.continue) {
+        if (matched && !shouldContinueOnMatch) {
           // once hit a match short circuit, unless we should continue
           return { maybeResponse, currentRequest }
         }

From fe6ca5fa1de479e92cda20fd4ba36e67dadee253 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Thu, 6 Nov 2025 15:08:20 +0100
Subject: [PATCH 72/75] middleware + fully static workaround

---
 src/adapter/build/routing.ts | 14 ++++++++++++++
 src/adapter/run/routing.ts   | 19 +++++++++++++++----
 2 files changed, 29 insertions(+), 4 deletions(-)

diff --git a/src/adapter/build/routing.ts b/src/adapter/build/routing.ts
index 7424b191cb..6ea0c5851f 100644
--- a/src/adapter/build/routing.ts
+++ b/src/adapter/build/routing.ts
@@ -113,6 +113,19 @@ export async function generateRoutingRules(
       //   })
       // }
     }
+
+    // TODO: this seems wrong in Next.js (?)
+    if (
+      dynamicRoute.sourceRegex.includes('_next/data') &&
+      !dynamicRoute.destination.includes('/_next/data')
+    ) {
+      console.log(
+        'Skipping dynamic route because source care about next/data while destination does not',
+        dynamicRoute,
+      )
+      continue
+    }
+
     dynamicRoutes.push(
       convertDynamicRouteToRoutingRule(
         dynamicRoute,
@@ -523,6 +536,7 @@ export async function generateRoutingRules(
               },
             },
             continue: true,
+            override: true,
           } satisfies RoutingRuleApply,
           {
             // apply __next_data_catchall rewrite
diff --git a/src/adapter/run/routing.ts b/src/adapter/run/routing.ts
index 9c17b5fe92..93827f9705 100644
--- a/src/adapter/run/routing.ts
+++ b/src/adapter/run/routing.ts
@@ -21,6 +21,8 @@ type RoutingRuleBase = {
   description: string
   /** if we should keep going even if we already have potential response */
   continue?: true
+  /** this will allow to evaluate route even if previous route was matched and didn't have continue: true */
+  override?: true
 }
 
 type Match = {
@@ -142,12 +144,20 @@ async function match(
   let currentRequest = request
   let maybeResponse: MaybeResponse = initialResponse
 
+  let onlyOverrides = false
+
   return tracer.withActiveSpan(spanName, async (span) => {
     for (const rule of routingRules) {
       const currentURL = new URL(currentRequest.url)
       const { pathname } = currentURL
 
       const desc = rule.description ?? JSON.stringify(rule)
+
+      if (onlyOverrides && !rule.override) {
+        log('Skipping rule because there is a match and this is not override:', desc, pathname)
+        continue
+      }
+
       // eslint-disable-next-line no-loop-func
       const result = await tracer.withActiveSpan(desc, async (span) => {
         log('Evaluating rule:', desc, pathname)
@@ -472,8 +482,9 @@ async function match(
         }
 
         if (matched && !shouldContinueOnMatch) {
+          onlyOverrides = true
           // once hit a match short circuit, unless we should continue
-          return { maybeResponse, currentRequest }
+          // return { maybeResponse, currentRequest }
         }
 
         if (!matched) {
@@ -481,9 +492,9 @@ async function match(
         }
       })
 
-      if (result) {
-        return result
-      }
+      // if (result) {
+      //   return result
+      // }
     }
     return { maybeResponse, currentRequest }
   })

From b5745209ad0f5a43b8018f2cd967b01ef34f5002 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Thu, 6 Nov 2025 16:00:48 +0100
Subject: [PATCH 73/75] middleware matcher routes

---
 src/adapter/build/routing.ts | 19 ++++++++-----------
 1 file changed, 8 insertions(+), 11 deletions(-)

diff --git a/src/adapter/build/routing.ts b/src/adapter/build/routing.ts
index 6ea0c5851f..aa01dc9626 100644
--- a/src/adapter/build/routing.ts
+++ b/src/adapter/build/routing.ts
@@ -330,19 +330,16 @@ export async function generateRoutingRules(
     // server actions name meta routes
 
     ...(hasMiddleware
-      ? [
-          {
+      ? (nextAdapterContext.outputs.middleware!.config.matchers?.map((matcher, index) => {
+          return {
             // originally: middleware route
-            description: 'Middleware',
-            // match: {
-            //   path: 'test',
-            // },
-            // match: {
-            //   path: 'wat',
-            // },
+            description: `Middleware (matcher #${index})`,
+            match: {
+              path: matcher.sourceRegex,
+            },
             apply: { type: 'middleware' },
-          } as const,
-        ]
+          } as const
+        }) ?? [])
       : []),
 
     // ...convertedRewrites.beforeFiles,

From b9daa5a4a21af251ceec6606bd06b421c60f942c Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Thu, 6 Nov 2025 16:39:24 +0100
Subject: [PATCH 74/75] continue on auto-i18n default locale prefixing

---
 src/adapter/build/routing.ts | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/src/adapter/build/routing.ts b/src/adapter/build/routing.ts
index aa01dc9626..fb99f4cc21 100644
--- a/src/adapter/build/routing.ts
+++ b/src/adapter/build/routing.ts
@@ -298,6 +298,7 @@ export async function generateRoutingRules(
                 nextAdapterContext.config.i18n.defaultLocale,
               ),
             },
+            continue: true,
           } satisfies RoutingRuleApply,
           {
             description: 'Auto-prefix non-locale path with default locale',
@@ -319,6 +320,7 @@ export async function generateRoutingRules(
                 '$1',
               ),
             },
+            continue: true,
           } satisfies RoutingRuleApply,
         ]
       : []),

From 0b07f5da31debbe440e70549e3a465344819bd9f Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Thu, 6 Nov 2025 16:46:45 +0100
Subject: [PATCH 75/75] remove no longer needed patch-package

---
 package-lock.json                             | 378 ------------------
 package.json                                  |   2 -
 patches/@netlify+build+35.2.10.patch          |  16 -
 .../netlify-cli++@netlify+build+35.2.7.patch  |  13 -
 4 files changed, 409 deletions(-)
 delete mode 100644 patches/@netlify+build+35.2.10.patch
 delete mode 100644 patches/netlify-cli++@netlify+build+35.2.7.patch

diff --git a/package-lock.json b/package-lock.json
index 87cd3ca946..fe4d2d7fa4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,7 +7,6 @@
     "": {
       "name": "@netlify/plugin-nextjs",
       "version": "5.14.5",
-      "hasInstallScript": true,
       "license": "MIT",
       "devDependencies": {
         "@fastly/http-compute-js": "1.1.5",
@@ -50,7 +49,6 @@
         "os": "^0.1.2",
         "outdent": "^0.8.0",
         "p-limit": "^6.0.0",
-        "patch-package": "^8.0.1",
         "path-to-regexp": "^6.2.1",
         "picomatch": "^4.0.2",
         "prettier": "^3.2.5",
@@ -6924,13 +6922,6 @@
         "node": ">=18.0.0"
       }
     },
-    "node_modules/@yarnpkg/lockfile": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
-      "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
-      "dev": true,
-      "license": "BSD-2-Clause"
-    },
     "node_modules/abbrev": {
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz",
@@ -11162,16 +11153,6 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
-    "node_modules/find-yarn-workspace-root": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
-      "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
-      "dev": true,
-      "license": "Apache-2.0",
-      "dependencies": {
-        "micromatch": "^4.0.2"
-      }
-    },
     "node_modules/flat-cache": {
       "version": "3.1.1",
       "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.1.1.tgz",
@@ -12236,22 +12217,6 @@
         "url": "https://github.com/sponsors/wooorm"
       }
     },
-    "node_modules/is-docker": {
-      "version": "2.2.1",
-      "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
-      "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
-      "dev": true,
-      "license": "MIT",
-      "bin": {
-        "is-docker": "cli.js"
-      },
-      "engines": {
-        "node": ">=8"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
     "node_modules/is-error-instance": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/is-error-instance/-/is-error-instance-2.0.0.tgz",
@@ -12609,19 +12574,6 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
-    "node_modules/is-wsl": {
-      "version": "2.2.0",
-      "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
-      "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "is-docker": "^2.0.0"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
     "node_modules/isarray": {
       "version": "2.0.5",
       "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
@@ -12748,26 +12700,6 @@
       "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
       "dev": true
     },
-    "node_modules/json-stable-stringify": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
-      "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "call-bind": "^1.0.8",
-        "call-bound": "^1.0.4",
-        "isarray": "^2.0.5",
-        "jsonify": "^0.0.1",
-        "object-keys": "^1.1.1"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
     "node_modules/json-stable-stringify-without-jsonify": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
@@ -12798,16 +12730,6 @@
         "graceful-fs": "^4.1.6"
       }
     },
-    "node_modules/jsonify": {
-      "version": "0.0.1",
-      "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
-      "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
-      "dev": true,
-      "license": "Public Domain",
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
     "node_modules/jsonparse": {
       "version": "1.3.1",
       "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz",
@@ -12905,16 +12827,6 @@
         "node": ">=0.10.0"
       }
     },
-    "node_modules/klaw-sync": {
-      "version": "6.0.0",
-      "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
-      "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "graceful-fs": "^4.1.11"
-      }
-    },
     "node_modules/kuler": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz",
@@ -30077,23 +29989,6 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
-    "node_modules/open": {
-      "version": "7.4.2",
-      "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
-      "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "is-docker": "^2.0.0",
-        "is-wsl": "^2.1.1"
-      },
-      "engines": {
-        "node": ">=8"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
     "node_modules/optionator": {
       "version": "0.9.3",
       "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz",
@@ -30474,127 +30369,6 @@
         "url": "https://github.com/inikulin/parse5?sponsor=1"
       }
     },
-    "node_modules/patch-package": {
-      "version": "8.0.1",
-      "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz",
-      "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@yarnpkg/lockfile": "^1.1.0",
-        "chalk": "^4.1.2",
-        "ci-info": "^3.7.0",
-        "cross-spawn": "^7.0.3",
-        "find-yarn-workspace-root": "^2.0.0",
-        "fs-extra": "^10.0.0",
-        "json-stable-stringify": "^1.0.2",
-        "klaw-sync": "^6.0.0",
-        "minimist": "^1.2.6",
-        "open": "^7.4.2",
-        "semver": "^7.5.3",
-        "slash": "^2.0.0",
-        "tmp": "^0.2.4",
-        "yaml": "^2.2.2"
-      },
-      "bin": {
-        "patch-package": "index.js"
-      },
-      "engines": {
-        "node": ">=14",
-        "npm": ">5"
-      }
-    },
-    "node_modules/patch-package/node_modules/ansi-styles": {
-      "version": "4.3.0",
-      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
-      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "color-convert": "^2.0.1"
-      },
-      "engines": {
-        "node": ">=8"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
-      }
-    },
-    "node_modules/patch-package/node_modules/chalk": {
-      "version": "4.1.2",
-      "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
-      "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "ansi-styles": "^4.1.0",
-        "supports-color": "^7.1.0"
-      },
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/chalk?sponsor=1"
-      }
-    },
-    "node_modules/patch-package/node_modules/color-convert": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
-      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "color-name": "~1.1.4"
-      },
-      "engines": {
-        "node": ">=7.0.0"
-      }
-    },
-    "node_modules/patch-package/node_modules/color-name": {
-      "version": "1.1.4",
-      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
-      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/patch-package/node_modules/fs-extra": {
-      "version": "10.1.0",
-      "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
-      "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "graceful-fs": "^4.2.0",
-        "jsonfile": "^6.0.1",
-        "universalify": "^2.0.0"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/patch-package/node_modules/slash": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
-      "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=6"
-      }
-    },
-    "node_modules/patch-package/node_modules/supports-color": {
-      "version": "7.2.0",
-      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
-      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "has-flag": "^4.0.0"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
     "node_modules/path-exists": {
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz",
@@ -38745,12 +38519,6 @@
         "tslib": "^2.6.3"
       }
     },
-    "@yarnpkg/lockfile": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
-      "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
-      "dev": true
-    },
     "abbrev": {
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz",
@@ -41792,15 +41560,6 @@
       "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==",
       "dev": true
     },
-    "find-yarn-workspace-root": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
-      "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
-      "dev": true,
-      "requires": {
-        "micromatch": "^4.0.2"
-      }
-    },
     "flat-cache": {
       "version": "3.1.1",
       "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.1.1.tgz",
@@ -42528,12 +42287,6 @@
       "integrity": "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==",
       "dev": true
     },
-    "is-docker": {
-      "version": "2.2.1",
-      "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
-      "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
-      "dev": true
-    },
     "is-error-instance": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/is-error-instance/-/is-error-instance-2.0.0.tgz",
@@ -42760,15 +42513,6 @@
         "get-intrinsic": "^1.1.1"
       }
     },
-    "is-wsl": {
-      "version": "2.2.0",
-      "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
-      "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
-      "dev": true,
-      "requires": {
-        "is-docker": "^2.0.0"
-      }
-    },
     "isarray": {
       "version": "2.0.5",
       "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
@@ -42878,19 +42622,6 @@
       "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
       "dev": true
     },
-    "json-stable-stringify": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
-      "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
-      "dev": true,
-      "requires": {
-        "call-bind": "^1.0.8",
-        "call-bound": "^1.0.4",
-        "isarray": "^2.0.5",
-        "jsonify": "^0.0.1",
-        "object-keys": "^1.1.1"
-      }
-    },
     "json-stable-stringify-without-jsonify": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
@@ -42913,12 +42644,6 @@
         "universalify": "^2.0.0"
       }
     },
-    "jsonify": {
-      "version": "0.0.1",
-      "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
-      "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
-      "dev": true
-    },
     "jsonparse": {
       "version": "1.3.1",
       "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz",
@@ -42989,15 +42714,6 @@
       "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
       "dev": true
     },
-    "klaw-sync": {
-      "version": "6.0.0",
-      "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
-      "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
-      "dev": true,
-      "requires": {
-        "graceful-fs": "^4.1.11"
-      }
-    },
     "kuler": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz",
@@ -54598,16 +54314,6 @@
         "mimic-fn": "^4.0.0"
       }
     },
-    "open": {
-      "version": "7.4.2",
-      "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
-      "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
-      "dev": true,
-      "requires": {
-        "is-docker": "^2.0.0",
-        "is-wsl": "^2.1.1"
-      }
-    },
     "optionator": {
       "version": "0.9.3",
       "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz",
@@ -54868,90 +54574,6 @@
         "parse5": "^7.0.0"
       }
     },
-    "patch-package": {
-      "version": "8.0.1",
-      "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz",
-      "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==",
-      "dev": true,
-      "requires": {
-        "@yarnpkg/lockfile": "^1.1.0",
-        "chalk": "^4.1.2",
-        "ci-info": "^3.7.0",
-        "cross-spawn": "^7.0.3",
-        "find-yarn-workspace-root": "^2.0.0",
-        "fs-extra": "^10.0.0",
-        "json-stable-stringify": "^1.0.2",
-        "klaw-sync": "^6.0.0",
-        "minimist": "^1.2.6",
-        "open": "^7.4.2",
-        "semver": "^7.5.3",
-        "slash": "^2.0.0",
-        "tmp": "^0.2.4",
-        "yaml": "^2.2.2"
-      },
-      "dependencies": {
-        "ansi-styles": {
-          "version": "4.3.0",
-          "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
-          "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
-          "dev": true,
-          "requires": {
-            "color-convert": "^2.0.1"
-          }
-        },
-        "chalk": {
-          "version": "4.1.2",
-          "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
-          "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
-          "dev": true,
-          "requires": {
-            "ansi-styles": "^4.1.0",
-            "supports-color": "^7.1.0"
-          }
-        },
-        "color-convert": {
-          "version": "2.0.1",
-          "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
-          "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
-          "dev": true,
-          "requires": {
-            "color-name": "~1.1.4"
-          }
-        },
-        "color-name": {
-          "version": "1.1.4",
-          "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
-          "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
-          "dev": true
-        },
-        "fs-extra": {
-          "version": "10.1.0",
-          "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
-          "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
-          "dev": true,
-          "requires": {
-            "graceful-fs": "^4.2.0",
-            "jsonfile": "^6.0.1",
-            "universalify": "^2.0.0"
-          }
-        },
-        "slash": {
-          "version": "2.0.0",
-          "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
-          "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
-          "dev": true
-        },
-        "supports-color": {
-          "version": "7.2.0",
-          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
-          "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
-          "dev": true,
-          "requires": {
-            "has-flag": "^4.0.0"
-          }
-        }
-      }
-    },
     "path-exists": {
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz",
diff --git a/package.json b/package.json
index 719f684294..ec651bee7a 100644
--- a/package.json
+++ b/package.json
@@ -18,7 +18,6 @@
   },
   "scripts": {
     "prepack": "clean-package",
-    "postinstall": "patch-package",
     "postpack": "clean-package restore",
     "pretest": "npm run pretest:integration",
     "pretest:integration": "npm run build && node tests/prepare.mjs",
@@ -93,7 +92,6 @@
     "os": "^0.1.2",
     "outdent": "^0.8.0",
     "p-limit": "^6.0.0",
-    "patch-package": "^8.0.1",
     "path-to-regexp": "^6.2.1",
     "picomatch": "^4.0.2",
     "prettier": "^3.2.5",
diff --git a/patches/@netlify+build+35.2.10.patch b/patches/@netlify+build+35.2.10.patch
deleted file mode 100644
index fc3e204e95..0000000000
--- a/patches/@netlify+build+35.2.10.patch
+++ /dev/null
@@ -1,16 +0,0 @@
-diff --git a/node_modules/@netlify/build/lib/types/config/functions.d.ts b/node_modules/@netlify/build/lib/types/config/functions.d.ts
-index 273066d..e56e320 100644
---- a/node_modules/@netlify/build/lib/types/config/functions.d.ts
-+++ b/node_modules/@netlify/build/lib/types/config/functions.d.ts
-@@ -4,6 +4,11 @@ type FunctionsObject = {
-      * a list of additional paths to include in the function bundle. Although our build system includes statically referenced files (like `import * from "./some-file.js"`) by default, `included_files` lets you specify additional files or directories and reference them dynamically in function code. You can use `*` to match any character or prefix an entry with `!` to exclude files. Paths are relative to the [base directory](https://docs.netlify.com/configure-builds/get-started/#definitions-1).
-      */
-     included_files?: string[];
-+
-+    /**
-+     * [patched] allow to set included files base
-+     */
-+    included_files_base_path?: string
- } & ({
-     /**
-      * the function bundling method used in [`@netlify/zip-it-and-ship-it`](https://github.com/netlify/zip-it-and-ship-it).
diff --git a/patches/netlify-cli++@netlify+build+35.2.7.patch b/patches/netlify-cli++@netlify+build+35.2.7.patch
deleted file mode 100644
index 5bc2cb615d..0000000000
--- a/patches/netlify-cli++@netlify+build+35.2.7.patch
+++ /dev/null
@@ -1,13 +0,0 @@
-diff --git a/node_modules/netlify-cli/node_modules/@netlify/build/lib/plugins_core/functions/zisi.js b/node_modules/netlify-cli/node_modules/@netlify/build/lib/plugins_core/functions/zisi.js
-index 96cfa0b..8c9e154 100644
---- a/node_modules/netlify-cli/node_modules/@netlify/build/lib/plugins_core/functions/zisi.js
-+++ b/node_modules/netlify-cli/node_modules/@netlify/build/lib/plugins_core/functions/zisi.js
-@@ -45,7 +45,7 @@ export const normalizeFunctionConfig = ({ buildDir, functionConfig = {}, isRunni
-     externalNodeModules: functionConfig.external_node_modules,
-     includedFiles: functionConfig.included_files,
-     name: functionConfig.name,
--    includedFilesBasePath: buildDir,
-+    includedFilesBasePath: functionConfig.included_files_base_path ?? buildDir,
-     ignoredNodeModules: functionConfig.ignored_node_modules,
-     nodeVersion,
-     schedule: functionConfig.schedule,