Describe the bug
handleRewrites applies only the first matching rule (rewrites.find(...) in packages/open-next/src/core/routing/matcher.ts — the function's own TODO notes "It should check for all matches for beforeFiles and afterFiles rewrite").
This breaks App Router intercepting routes whose URLs are reached through a user beforeFiles rewrite. next build compiles each interceptor into a generated rewrite that is appended after the user's beforeFiles rules in routes-manifest.json (rewrites.beforeFiles.push(...generateInterceptionRoutesRewrites(...)) in Next's build/index.ts), with the intercepted physical path as its source, gated on the Next-Url header a soft navigation carries.
Next's own production server chains beforeFiles rules: a matching rewrite mutates the parsed URL and evaluation continues, so the generated interception rule can match the path produced by a user rewrite (see server/lib/router-utils/resolve-routes.js — the rewrite branch assigns parsedUrl.pathname and does not return). OpenNext stops at the first match, so when a user rewrite matches the navigation URL, the generated interception rule is never evaluated and the soft navigation renders the full fallback page instead of the interceptor.
Reproduction
Next 16.2.11, @opennextjs/cloudflare 1.19.9 (@opennextjs/aws 4.0.2); the relevant code is unchanged on main.
App shape (a pretty-URL namespace rewritten onto a physical tree — the modals pattern from the Next docs plus one user rewrite):
// next.config.js
async rewrites() {
return {
beforeFiles: [
{ source: '/@:org/:space/:path*', destination: '/orgs/:org/s/:space/:path*' },
],
}
}
app/(authed)/@modal/(...)orgs/[org]/s/[space]/delete/page.tsx ← interceptor (dialog)
app/(authed)/orgs/[org]/s/[space]/delete/page.tsx ← standalone page
routes-manifest.json then contains, in rewrites.beforeFiles, in this order:
/@:org/:space/:path* → /orgs/:org/s/:space/:path*
/orgs/:org/s/:space/delete → /(...)orgs/:org/s/:space/delete (has: [{ type: 'header', key: 'next-url' }]) (generated)
Simulated soft navigation against a production build (curl -H 'RSC: 1' -H 'Next-Url: /orgs/acme' <url>/@acme/demo/delete):
|
next build && next start |
opennextjs-cloudflare build && preview |
/@acme/demo/delete (through user rewrite) |
✅ interceptor (x-nextjs-rewritten-path: /(...)orgs/acme/s/demo/delete) |
❌ standalone page tree |
/orgs/acme/spaces/new-style physical URL (no user rewrite involved) |
✅ interceptor |
✅ interceptor |
Hard navigation (no Next-Url) |
standalone page (correct) |
standalone page (correct) |
The physical-URL control works on OpenNext precisely because the generated rule is then the first match — which isolates the first-match behavior as the cause.
Worth noting: users cannot work around this in next.config.js, because a user rewrite cannot target an interception destination — bare /(...)… fails rewrite validation (path-to-regexp parses it as unnamed segment 0, and load-custom-routes' sourceSegments drops a 0 name via .filter(Boolean)), and backslash-escaping the parens passes validation but breaks at runtime (prepareDestination round-trips the destination through URL parsing, which mangles backslashes). So an adapter-side fix is the only path.
Suggested fix
Chain the beforeFiles phase the way Next's router does — evaluate every rule once, in order, each against the URL produced by the previous matching rule — while keeping first-match semantics for redirects (which return immediately). Verified locally against the Cloudflare adapter (wrangler preview): the table above becomes all-green, and single-rewrite paths, hard navigations, and redirects are unaffected.
--- a/dist/core/routing/matcher.js
+++ b/dist/core/routing/matcher.js
@@ -175,6 +175,18 @@ export function handleRewrites(event, rewrites) {
isExternalRewrite,
};
}
+// Applies rewrites the way Next's own production router does for the
+// `beforeFiles` phase: every rule is evaluated once, in order, each
+// against the URL produced by the previous matching rule. Chaining
+// stops early on an external rewrite, which is proxied as-is.
+export function handleChainedRewrites(event, rewrites) {
+ let result = { internalEvent: event, __rewrite: undefined, isExternalRewrite: false };
+ for (const rule of rewrites) {
+ const stepResult = handleRewrites(result.internalEvent, [rule]);
+ if (stepResult.__rewrite) {
+ result = stepResult;
+ if (result.isExternalRewrite) {
+ break;
+ }
+ }
+ }
+ return result;
+}
--- a/dist/core/routingHandler.js
+++ b/dist/core/routingHandler.js
@@ -115,7 +115,7 @@ export default async function routingHandler(event, { assetResolver }) {
if (!isExternalRewrite) {
// First rewrite to be applied
- const beforeRewrite = handleRewrites(eventOrResult, RoutesManifest.rewrites.beforeFiles);
+ const beforeRewrite = handleChainedRewrites(eventOrResult, RoutesManifest.rewrites.beforeFiles);
eventOrResult = beforeRewrite.internalEvent;
(Applied against the published dist via pnpm patch for verification; happy to turn it into a proper PR against packages/open-next/src/core/routing/matcher.ts if the approach looks right to you. Per the Next docs afterFiles should chain as well; the diff above deliberately touches only beforeFiles, which is the phase Next's generated interception rewrites live in.)
@opennextjs/cloudflare version
1.19.9
@opennextjs/aws version
4.0.2
Describe the bug
handleRewritesapplies only the first matching rule (rewrites.find(...)inpackages/open-next/src/core/routing/matcher.ts— the function's own TODO notes "It should check for all matches forbeforeFilesandafterFilesrewrite").This breaks App Router intercepting routes whose URLs are reached through a user
beforeFilesrewrite.next buildcompiles each interceptor into a generated rewrite that is appended after the user'sbeforeFilesrules inroutes-manifest.json(rewrites.beforeFiles.push(...generateInterceptionRoutesRewrites(...))in Next'sbuild/index.ts), with the intercepted physical path as its source, gated on theNext-Urlheader a soft navigation carries.Next's own production server chains
beforeFilesrules: a matching rewrite mutates the parsed URL and evaluation continues, so the generated interception rule can match the path produced by a user rewrite (seeserver/lib/router-utils/resolve-routes.js— the rewrite branch assignsparsedUrl.pathnameand does not return). OpenNext stops at the first match, so when a user rewrite matches the navigation URL, the generated interception rule is never evaluated and the soft navigation renders the full fallback page instead of the interceptor.Reproduction
Next
16.2.11,@opennextjs/cloudflare1.19.9(@opennextjs/aws4.0.2); the relevant code is unchanged onmain.App shape (a pretty-URL namespace rewritten onto a physical tree — the modals pattern from the Next docs plus one user rewrite):
routes-manifest.jsonthen contains, inrewrites.beforeFiles, in this order:/@:org/:space/:path*→/orgs/:org/s/:space/:path*/orgs/:org/s/:space/delete→/(...)orgs/:org/s/:space/delete(has: [{ type: 'header', key: 'next-url' }]) (generated)Simulated soft navigation against a production build (
curl -H 'RSC: 1' -H 'Next-Url: /orgs/acme' <url>/@acme/demo/delete):next build && next startopennextjs-cloudflare build && preview/@acme/demo/delete(through user rewrite)x-nextjs-rewritten-path: /(...)orgs/acme/s/demo/delete)/orgs/acme/spaces/new-style physical URL (no user rewrite involved)Next-Url)The physical-URL control works on OpenNext precisely because the generated rule is then the first match — which isolates the first-match behavior as the cause.
Worth noting: users cannot work around this in
next.config.js, because a user rewrite cannot target an interception destination — bare/(...)…fails rewrite validation (path-to-regexp parses it as unnamed segment0, andload-custom-routes'sourceSegmentsdrops a0name via.filter(Boolean)), and backslash-escaping the parens passes validation but breaks at runtime (prepareDestinationround-trips the destination through URL parsing, which mangles backslashes). So an adapter-side fix is the only path.Suggested fix
Chain the
beforeFilesphase the way Next's router does — evaluate every rule once, in order, each against the URL produced by the previous matching rule — while keeping first-match semantics for redirects (which return immediately). Verified locally against the Cloudflare adapter (wrangler preview): the table above becomes all-green, and single-rewrite paths, hard navigations, and redirects are unaffected.(Applied against the published
distviapnpm patchfor verification; happy to turn it into a proper PR againstpackages/open-next/src/core/routing/matcher.tsif the approach looks right to you. Per the Next docsafterFilesshould chain as well; the diff above deliberately touches onlybeforeFiles, which is the phase Next's generated interception rewrites live in.)@opennextjs/cloudflare version
1.19.9
@opennextjs/aws version
4.0.2