Every page on my site declared export const revalidate = 300
. Nine locales served from ISR.
The tenth β the one that is actually canonical, the one crawlers hit most β re-rendered from scratch
on every single request for weeks.
The difference between them was not a page, a config flag, or a deployment. It was that the tenth
locale's URL went through a NextResponse.rewrite()
in middleware.ts
.
I run AI Change Watch, a Next.js App Router site on Cloudflare
en
is served unprefixed (/deprecations
), the other nine locales are prefixed/ja/deprecations
). That unprefixed mapping was one line of middleware.Measured on production, 2026-08-06. Same page, same component tree, same revalidate = 300
β only
the routing path differs:
/bot, /rankings (rewritten in middleware)
Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
(no x-nextjs-* headers at all)
/ja/bot, /ja/rankings (passed through with next())
Cache-Control: s-maxage=300, stale-while-revalidate=...
x-nextjs-prerender: 1
private, no-store
on a page whose whole point is to be cached. And not "cached badly" β there are
no x-nextjs-*
headers on those responses whatsoever, which means Next never treated the request as
a route that has an incremental cache entry. Nothing was written, so nothing could ever be read.
Three things kept this invisible for weeks, and I think each one is general.
The prefixed locales are fine. Any "is my ISR working?" check you run against /ja/...
passes.
The bug is per-path, and it only touches the paths middleware rewrote.
Probing the prefixed form of the broken URL measures nothing. /en/bot
307-redirects to the
canonical /bot
(verified again today), so curl -I /en/bot
returns a redirect and tells you
nothing about the page. You have to test the unprefixed URL. I burned an eight-minute polling
loop on /en/bot
before noticing that.
Pages still refresh, so content never looks stale. Deploys change the buildId, the buildId is part
of the cache key space, and this repo deploys several times a day β so the whole cache was being
invalidated often enough that no page was ever visibly out of date. revalidate = 300
was decorative,
and deployment frequency was covering for it. (There was a second, independent reason revalidation
never ran on this stack; that one is
This is vercel/next.js#83862 β "SWR Cache-Control disabled after Next.js 15.5 when using a rewrite middleware", open, filed 2025-09-16, reported
The explanation in the issue is that Next matches the pre-rewrite path against the
dynamic-route regexes. /bot
matches nothing (the real route is app/[locale]/[provider]/page.tsx
),
so the response falls back to the private, no-store
default. That mechanism is upstream's account
and the internals are not something I can observe from outside; what I can state is the header pair
above, and that it flips based solely on whether the rewrite happened in middleware.
The important consequence is that it is a write-side failure, not a read-side one. No entry is
ever created for those keys. So nothing that improves cache lookup can help.
Rewriting the rewrite. There is no shape of NextResponse.rewrite()
that avoids this. It is not
a matcher problem or an ordering problem.
Serving ISR from the adapter's routing layer (OpenNext's enableCacheInterception: true
) looked
like the perfect workaround: resolve the cache before NextServer is ever invoked, and the pre-rewrite
path matching stops mattering. Cache hits did work β I have x-opennext-cache: HIT
on prefixed
locales to prove it. It still could not fix English, because the cache was empty for those keys, and
then it took the site down:
Error in routingHandler
at Object.send (worker.js:116290) at computeCacheControl (worker.js:120329)
at generateResult (worker.js:120394) at cacheInterceptor (worker.js:121493)
On a stale entry the interceptor has to dispatch the background re-render itself, and at that
point I had no revalidation queue bound. Inside NextServer that same failure is caught and logged as
a warning. In the routing layer it wasn't caught, so the request returned 500. Two properties made it
much worse than an ordinary bug: 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, so
pages died one at a time over several hours β 12 URLs before I reverted it.
If you take one thing from this post, take that shape: a failure that is caught in one layer and
uncaught in the layer you moved it to.
Move the rewrite out of middleware and into next.config.mjs
. A config rewrite lands in the routes
manifest, which the adapter's routing layer applies to the internal request, so NextServer receives a
plain /en/...
request with no x-middleware-rewrite
header β the exact path /ja/...
always took.
async rewrites() {
const reserved = 'en|ja|zh|es|de|fr|ko|pt|it|tr|api|_next|sitemaps';
return {
afterFiles: [
{ source: '/', destination: '/en' },
{ source: `/:seg((?!(?:${reserved})(?:/|$))[^/]+)`, destination: '/en/:seg' },
{ source: `/:seg((?!(?:${reserved})(?:/|$))[^/]+)/:rest*`, destination: '/en/:seg/:rest*' },
],
};
}
Middleware keeps the /en/... β /...
redirect. Redirects are unaffected; only rewrites are.
afterFiles
, not beforeFiles
.afterFiles
runs only when no real route matched, so
/sitemap.xml
, /robots.txt
, /icon.svg
and friends resolve as themselves before this pattern is
consulted, and drop out of the exclusion list for free. With beforeFiles
every one of them needs an
explicit exclusion, and each missing exclusion is a 404 on a canonical URL.
Both of these produce a config that builds fine and 404s in production.
Trap 1: anchor each alternative to a segment boundary. The negative lookahead has to end with
(?:/|$)
, not $
. With $
alone it only fires when the reserved word ends the path:
$ only (?:/|$)
/bot -> /en/bot -> /en/bot
/ja/bot -> /en/ja/bot β 404 -> (no rewrite) β
/en/bot -> /en/en/bot β 404 -> (no rewrite) β
/api/contact -> /en/api/contact β 404 -> (no rewrite) β
/sitemaps/1 -> /en/sitemaps/1 β 404 -> (no rewrite) β
That table is RegExp.exec
output, not a sketch. It is also the same family of bug as writing bare
api
in a matcher, which swallows /api-features
β a page of mine that 404'd on its canonical URL
for exactly that reason.
Trap 2: root-level dynamic routes are not protected. The "real routes win first" property of
afterFiles
is gated on the adapter's /sitemaps/[id]
is root-level andreserved
by hand. If you add a root-level1. Test the compiled regex, not the source string. What runs is the pattern Next compiles into
.next/routes-manifest.json
, and it is not what you typed. Build to a scratch directory
(NEXT_DIST_DIR=.next-rwtest next build
), read the manifest, and assert every URL class you care
about β locale-prefixed, /en/
-prefixed, /api/*
, /_next/*
, root-level files, root-level dynamic
routes, the feeds. All four shadowing bugs above were caught this way before deploying, and none of
them was visible in the source.
2. Then check the unprefixed URL on production. Today, on the same pages, 2026-08-27:
$ curl -sI https://aichangewatch.com/bot | grep -i 'cache\|nextjs'
Cache-Control: s-maxage=3600, stale-while-revalidate=31532400
x-nextjs-cache: STALE
x-nextjs-prerender: 1
x-nextjs-stale-time: 300
$ curl -sI https://aichangewatch.com/deprecations | grep -i 'nextjs'
x-nextjs-cache: HIT
x-nextjs-prerender: 1
x-nextjs-prerender: 1
is the header that was absent before, and it is the one to look for.
x-nextjs-cache: MISS
on its own proves nothing β that is the earlier post's subject.
@opennextjs/cloudflare
on Cloudflare Workers. The upstream issue is not adapter-specific and the
reporters were not on my stack, but I have not tested Vercel or a plain next start
myself.no-store
fallback is chosen.Object.send
before the render is read off the stack trace and the fact that the 500s stopped on revert. I did not instrument it.If you serve a default locale unprefixed on App Router, the check is one command and the failing case
looks completely healthy: full-SSR responses are correct, just uncached. Test the URL your users get,
not the internal one.
*The site this came out of is AI Change Watch β vendor deprecation tables, pricing and SDK changelogs, diffed on a schedule. The pages in the measurements above are real ones; *
/deprecations
is the one with the highest cache-hit value, which is why it was the first
thing I noticed serving no-store
.