DEV Community

ushiro
ushiro

Posted on

A `middleware.ts` Rewrite Silently Disables ISR in Next.js 15.5

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
Workers (via OpenNext) that crawls what AI vendors publish about their own models and records every
change. en is served unprefixed (/deprecations), the other nine locales are prefixed
(/ja/deprecations). That unprefixed mapping was one line of middleware.

What the headers said

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
Enter fullscreen mode Exit fullscreen mode

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.

Why this hides so well

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
its own post.)

The cause

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
against 15.4.2-canary.2 through 15.5.3. I reproduce 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 (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.

Two things that did not fix it

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)
Enter fullscreen mode Exit fullscreen mode

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.

The fix

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*' },
    ],
  };
}
Enter fullscreen mode Exit fullscreen mode

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.

The exclusion regex has two traps

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)  ✓
Enter fullscreen mode Exit fullscreen mode

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 static route matcher. /sitemaps/[id] is root-level and
dynamic, so it is not covered, and it has to be named in reserved by hand. If you add a root-level
dynamic route later, it needs the same entry — there is nothing to remind you.

How to check it, in the order that actually works

1. 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
Enter fullscreen mode Exit fullscreen mode

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.

What I still haven't proven

  • Whether this reproduces off this adapter. Every measurement here is Next 15.5.21 + @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.
  • Why the no-store fallback is chosen. I am quoting the issue's explanation of the path matching, not something I read out of the runtime.
  • The 500 attribution. That the interceptor threw at 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.

Top comments (0)