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.
Describe the bug
handleRewrites()(incore/routing/matcher.ts) re-implements Next.js'snext.configrewrites()engine, and diverges from Next core in two distinct ways. Both:handleRewrites(),@opennextjs/cloudflarerunning onwrangler dev), and never undernext dev/next start, because Next's own router handles these cases correctly,{ source: '/:path*', destination: '/en-US/:path*' }404s the bare root/(sub-paths are fine).:paramdestination, throws a 500 inroutingHandler.Bug A — destination shipped un-interpolated when the source yields zero params
Root cause
In
handleRewrites()(v4.0.2):For request
/,match('/:path*')('/')returnsparams: {}— path-to-regexp omits an empty repeated param entirely. SoisUsingParamsisfalse, interpolation is skipped, andrewrittenPathis left as the literal, un-substituted template/en-US/:path*. That path resolves to no route → 404.convertMatch()(used for header rewrites) has the identicalisUsingParams ? toDestination(params) : destinationshape.Evidence
With OpenNext's bundled
path-to-regexp@6.3.0: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 thedebug()log):(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):compile()is called without{ validate: false }. path-to-regexp then validates every interpolated value against its param regex. A plain:paramin the destination gets the default single-segment regex[^/#?]+?, which rejects/. When the captured value spans segments,compile()throws and the error propagates out ofhandleRewrites()→routingHandler→ HTTP 500:The header path (
compile(h.key)/compile(h.value)) has the same omission (there it's swallowed by atry/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":Expected
OpenNext should compile rewrite destinations with
{ validate: false }, matching Next core (lib/router/utils/prepare-destination.tscompiles destinations withvalidate: 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
…and the same for
compile(h.key)/compile(h.value)ingetNextConfigHeaders().Steps to reproduce
Full minimal repro: https://github.com/rikbrown/opennext-rewrite-repro
next.config.tsdeclares oneafterFilesgroup:next dev//en-US)/hello/en-US/hello)/en-US/hello) — control/bug-b/a/b/bug-b-dest/a/b)/bug-b/single/bug-b-dest/single)Expected behavior
Both rewrites should behave identically on the OpenNext build and under
next dev—/rewritten to/en-US, and/bug-b/a/brewritten to/bug-b-dest/a/b— matching Next core.Environment
@opennextjs/aws(bug lives here)4.0.2@opennextjs/cloudflare(adapter — only consumes core)1.19.11next16.2.6wrangler4.93.0path-to-regexp(bundled by@opennextjs/aws)6.3.024.xWorkaround
Both can be worked around in
next.configuntil 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):Bug B — give the destination param an explicit slash-permitting regex, so it's no longer the default single-segment param:
next buildaccepts and preserves a regex in a rewrite destination.Additional context
Originally hit on a multi-tenant Next 16 app deployed to Cloudflare: an
afterFileslocale-prefix rewrite (Bug A) and abeforeFilesrule 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.