Skip to content

[BUG] handleRewrites() ships un-interpolated rewrite destinations (root 404) and crashes on multi-segment destination params (500) #1173

Description

@rikbrown

human note: apologies, I got Claude to write this report as I was a bit crunched on time shipping the thing that uncovered this bug. I've verified both the workaround and that this repro is accurate/truthful/not slop - but I will admit I fully used AI to unearth the problem so it may have hallucinated some details. I'm also happy to create a PR with the suggested fix which I will do more carefully.

Describe the bug

handleRewrites() (in core/routing/matcher.ts) re-implements Next.js's next.config rewrites() engine, and diverges from Next core in two distinct ways. Both:

  • live in handleRewrites(),
  • reproduce only on the OpenNext build (observed via @opennextjs/cloudflare running on wrangler dev), and never under next dev / next start, because Next's own router handles these cases correctly,
  • are filed together because they share a root theme — OpenNext's rewrite destination handling diverges from Next core — and a maintainer triaging one will want to see the other. Happy to split into two issues if preferred.
Bug Symptom
A A catch-all locale rewrite { source: '/:path*', destination: '/en-US/:path*' } 404s the bare root / (sub-paths are fine).
B A rewrite whose source param captures a multi-segment (slash-containing) value, fed into a plain :param destination, throws a 500 in routingHandler.

Bug A — destination shipped un-interpolated when the source yields zero params

Root cause

In handleRewrites() (v4.0.2):

const isUsingParams = Object.keys(params).length > 0;
// ...
let rewrittenPath = pathname;            // pathname = the RAW destination template
if (isUsingParams) {
  rewrittenPath = unescapeRegex(toDestinationPath(params));   // interpolation happens ONLY here
  rewrittenHost = unescapeRegex(toDestinationHost(params));
  rewrittenQuery = unescapeRegex(toDestinationQuery(params));
}

For request /, match('/:path*')('/') returns params: {} — path-to-regexp omits an empty repeated param entirely. So isUsingParams is false, interpolation is skipped, and rewrittenPath is left as the literal, un-substituted template /en-US/:path*. That path resolves to no route → 404.

convertMatch() (used for header rewrites) has the identical isUsingParams ? toDestination(params) : destination shape.

Evidence

With OpenNext's bundled path-to-regexp@6.3.0:

match('/:path*')('/')      => { params: {} }                  (isUsingParams = false)
match('/:path*')('/hello') => { params: { path: ['hello'] } } (isUsingParams = true)

In the repro, GET / on the OpenNext build renders <h1>/en-US/:path*</h1> — the literal placeholder leaked into the path unchanged.

Expected

Next core interpolates correctly (//en-US). A destination should be compiled/interpolated even when the source produced no params — compile() of /en-US/:path* applied to {} correctly yields /en-US (the :path* is an optional repeated segment).

Proposed fix

Interpolate unconditionally — drop the if (isUsingParams) gate (the flag can remain for the debug() log):

rewrittenPath  = unescapeRegex(toDestinationPath(params));
rewrittenHost  = unescapeRegex(toDestinationHost(params));
rewrittenQuery = unescapeRegex(toDestinationQuery(params));

(Pairs naturally with the Bug B fix below — compile() then needs { validate: false } so a param-free or empty branch never throws.)


Bug B — destinations compiled with path-to-regexp validation ON

Root cause

In handleRewrites() (v4.0.2):

const toDestinationPath = compile(escapeRegex(pathname, { isPath: true }));
const toDestinationHost = compile(escapeRegex(hostname));
const toDestinationQuery = compile(escapeRegex(queryString));

compile() is called without { validate: false }. path-to-regexp then validates every interpolated value against its param regex. A plain :param in the destination gets the default single-segment regex [^/#?]+?, which rejects /. When the captured value spans segments, compile() throws and the error propagates out of handleRewrites()routingHandler → HTTP 500:

Error in routingHandler TypeError: Expected "rest" to match "[^\/#\?]+?", but got "a/b"
    at routingHandler (.open-next/middleware/handler.mjs:2601:28)

The header path (compile(h.key) / compile(h.value)) has the same omission (there it's swallowed by a try/catch, so a wildcard header silently falls back to the un-interpolated literal).

Evidence

With OpenNext's bundled path-to-regexp@6.3.0, value "a/b":

compile('/bug-b-dest/:rest')('a/b')       => THROWS  Expected "rest" to match "[^\/#\?]+?"
compile('/bug-b-dest/:rest(.+)')('a/b')   => "/bug-b-dest/a/b"   (ok)

Expected

OpenNext should compile rewrite destinations with { validate: false }, matching Next core (lib/router/utils/prepare-destination.ts compiles destinations with validate: false, with an explicit comment that the value was already validated against the source) — which is exactly why Next never throws here. The source regex already gates what matches; re-validating the value at destination-compile time is both stricter than Next and incorrect.

Proposed fix

const toDestinationPath  = compile(escapeRegex(pathname, { isPath: true }), { validate: false });
const toDestinationHost  = compile(escapeRegex(hostname), { validate: false });
const toDestinationQuery = compile(escapeRegex(queryString), { validate: false });

…and the same for compile(h.key) / compile(h.value) in getNextConfigHeaders().


Steps to reproduce

Full minimal repro: https://github.com/rikbrown/opennext-rewrite-repro

git clone https://github.com/rikbrown/opennext-rewrite-repro
cd opennext-rewrite-repro
npm install

# Correct behaviour — everything 200s:
npm run dev        # http://localhost:3210

# Buggy behaviour — OpenNext build on the Workers runtime:
npm run preview    # http://localhost:8788

next.config.ts declares one afterFiles group:

async rewrites() {
  return [
    { source: '/bug-b/:rest(.+)', destination: '/bug-b-dest/:rest' }, // Bug B
    { source: '/:path*',          destination: '/en-US/:path*' },     // Bug A
  ]
}
Request next dev OpenNext build
/ 200 (/en-US) 404 — Bug A
/hello 200 (/en-US/hello) 200 (/en-US/hello) — control
/bug-b/a/b 200 (/bug-b-dest/a/b) 500 — Bug B
/bug-b/single 200 (/bug-b-dest/single) 200 — control

Expected behavior

Both rewrites should behave identically on the OpenNext build and under next dev/ rewritten to /en-US, and /bug-b/a/b rewritten to /bug-b-dest/a/b — matching Next core.

Environment

Package Version
@opennextjs/aws (bug lives here) 4.0.2
@opennextjs/cloudflare (adapter — only consumes core) 1.19.11
next 16.2.6
wrangler 4.93.0
path-to-regexp (bundled by @opennextjs/aws) 6.3.0
Node 24.x

Filed against opennextjs-aws because both bugs are in the core router (handleRewrites()); the Cloudflare adapter only consumes it. Surfaced via @opennextjs/cloudflare, so adapter users searching may land here.

Workaround

Both can be worked around in next.config until fixed upstream.

Bug A — give the root its own param-free rule (the literal destination is then correct), and match everything else with /:path+ (one-or-more, never empty):

{ source: '/',       destination: '/en-US' },
{ source: '/:path+', destination: '/en-US/:path+' },

Bug B — give the destination param an explicit slash-permitting regex, so it's no longer the default single-segment param:

{ source: '/bug-b/:rest(.+)', destination: '/bug-b-dest/:rest(.+)' },

next build accepts and preserves a regex in a rewrite destination.

Additional context

Originally hit on a multi-tenant Next 16 app deployed to Cloudflare: an afterFiles locale-prefix rewrite (Bug A) and a beforeFiles rule injecting a tenant id whose value then carries a / into a later rewrite (Bug B). Both were worked around in app code (see above), but the underlying divergence from Next core should be fixed upstream.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions