Build1 distinct publisher3 min readPublished
One line of middleware mapped the unprefixed English locale onto a dynamic route, and every path it rewrote answered with no-store while the nine prefixed locales kept s-maxage=300. The only signal was in the headers.
The Engineer · Build desk

Compiled by The EngineerSomething wrong?How this is made
Middleware runs before route matching, and that ordering is where the entry goes missing. Per the upstream issue, Next matches the pre-rewrite path against its dynamic-route regexes, and `/bot` matches nothing because the real route file is `app/[locale]/[provider]/page.tsx`, so the response falls back to the `private, no-store` default [10]. The author marks that as upstream's account rather than something he can observe from outside the runtime; what he stands behind is that the header pair flips on nothing except whether middleware rewrote the URL [11].
The important property is that this is a write-side failure [12]. No entry is created for those keys, so the absence of any `x-nextjs-*` header on the response is the honest reading: Next never treated the request as a route that has an incremental cache entry [6]. There is also no shape of `NextResponse.rewrite()` that dodges it, and it is neither a matcher nor an ordering problem in your own code [13].
Detection is the hard part, and two mechanics conspire. The bug is per-path, and only paths middleware rewrote are affected, so any "is my ISR working" check aimed at `/ja/...` passes [19]. Then there is the freshness math. `revalidate = 300` is a five-minute window [1], but the buildId changes on every deploy and forms part of the cache key space, and this repo ships several times a day, which dropped the whole cache often enough that no page ever looked stale [8]. Nine prefixed locales plus one unprefixed makes ten locale paths [2], and the broken one was the canonical target that crawlers hit most [2]. The author spent eight minutes polling `/en/bot` before noticing it 307-redirects to `/bot`, so the loop was measuring a redirect [7].
The adapter-side workaround is the part worth reading closely. OpenNext's `enableCacheInterception: true` resolves the cache before NextServer is invoked, which should make pre-rewrite matching irrelevant, and hits did work, with `x-opennext-cache: HIT` on prefixed locales [14]. English stayed broken because those keys were empty [15]. On a stale entry the interceptor has to dispatch the background re-render itself, and no revalidation queue was bound [16]. Inside NextServer that same failure is caught and logged as a warning; in the routing layer nothing caught it, so the request returned 500 [16]. It threw before the render, so the entry could never refresh and the 500 was permanent per URL, and it only fired once an entry passed its revalidate window [17].
For this header pair to transfer to your stack, three things have to hold: App Router, a middleware rewrite landing on a dynamic route, and a version in the affected range, which the issue reports as 15.4.2-canary.2 through 15.5.3 and the author reproduces on 15.5.21 [9]. The measurement itself is one site, dated, on Cloudflare Workers via OpenNext [1][4], so treat it as a reproducible tell rather than a survey. The tell is cheap to run: request the rewritten URL directly, not its prefixed form, and look for `x-nextjs-prerender` rather than parsing the `Cache-Control` string [4][5].
Ranked by verification strength, evidence, and original report placement.
The behaviour is tracked as vercel/next.js#83862, "SWR Cache-Control disabled after Next.js 15.5 when using a rewrite middleware", open, filed 2025-09-16, reported against 15.4.2-canary.2 through 15.5.3; the author reproduces it on 15.5.21.
The explanation in the issue is that Next matches the pre-rewrite path against the dynamic-route regexes; /bot matches nothing, because the real route is app/[locale]/[provider]/page.tsx, so the response falls back to the private, no-store default.
The author runs AI Change Watch, a Next.js App Router site on Cloudflare Workers via OpenNext, which crawls what AI vendors publish about their own models and records every change.
Every page on the site declared export const revalidate = 300. Nine locales were served from ISR; the tenth, the canonical one that crawlers hit most, re-rendered from scratch on every request for weeks.
en is served unprefixed (/deprecations) and the other nine locales are prefixed (/ja/deprecations); that unprefixed mapping was one line of middleware.
Measured on production, 2026-08-06: the rewritten paths /bot and /rankings returned Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate, with no x-nextjs-* headers at all.
Distinct publishers with included, body-backed reporting in this cluster.
dev.to
1 article · September 1, 2026
Follow any of these and your For You feed starts watching them — no settings page required.
build
At 70,000 images, next/image is not your optimizer, it is your outage1 distinct publisher
build
A build step instead of a backend: 1,025 records, 8 locales, no runtime API1 distinct publisher
build
Nine locales, no middleware: route groups as a Cloudflare billing decision1 distinct publisher
build
With CRA out of React's docs, the new project default is a rendering decision1 distinct publisher
Evidence-backed comparisons of source perspectives and observed adoption signals. Read the methodology
Which Builder, Operator, and Investor concerns the observed source mix emphasized—not a truth score.
Evidence, demonstrated adoption, hype gap, incentives, and confidence are assessed independently, each on its own current evidence. How these are measured.
Two header dumps, one pair of hands
The case turns on a controlled comparison a reader can rerun in a minute: same page, same component tree, same revalidate value, one variable changed, and the two Cache-Control lines printed in full. That is unusually clean for a debugging post, and the version pin to 15.5.21 plus the named upstream issue give it edges. What holds the score down is that every instrument was held by the same person on the same Cloudflare Workers deployment, with no second host, no second app shape, and no independent reproduction anywhere in the reporting.
One site, one thread, no third party
Everything observable sits inside a single production deployment: the headers, the interception experiment that 500'd twelve URLs, the config rewrite that replaced it. The only footprint beyond that is the upstream issue's reported version range, which implies other people hit this before 15.5.3 shipped but tells us nothing about how many. For a defect claimed to affect any path middleware rewrites, that is a very small observed surface.
Underclaimed where it matters most
The headline says the framework stops writing cache entries, which is broader than one site can prove — but the body immediately hands the mechanism back to the upstream thread and refuses to speculate about internals, and the sharpest generalisation it offers is about exception handling across layers rather than about Next.js. A post with these headers in hand could have been considerably louder. The small negative reflects one direction of understatement: nowhere does it estimate what weeks of uncached rendering on the most-crawled locale actually cost.
Self-published, and the site is the product
This is the author's own platform, his own site named in the first paragraph, and a second post teased inside the first — mild attention incentives, all of them visible rather than hidden. There is no vendor money in view and no reason to flatter Next.js, OpenNext or Cloudflare here; if anything the incentive runs toward drama about a framework the author depends on, and the post spends its energy on a config diff instead. No editor or reviewer stood between the measurement and publication.
Narrow, checkable, uncorroborated
Confidence here is bounded less by the quality of the work than by its width. The specific facts are the kind that are hard to get wrong and easy for a reader to falsify — two header sets, a version string, an issue number, a stack trace. But nobody else has run the experiment, the causal account is admittedly second-hand, and a second unexplained revalidation failure lurks in the same environment, which leaves room for the wrong variable having been credited.