Skip to content

[BUG] beforeFiles rewrites apply only the first match, so Next's generated interception-route rewrites never fire behind a user rewrite #1215

Description

@lxcid

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:

  1. /@:org/:space/:path*/orgs/:org/s/:space/:path*
  2. /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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions