A `middleware.ts` Rewrite Silently Disables ISR in Next.js 15.5 A developer reports that a middleware rewrite in Next.js 15.5 silently disables Incremental Static Regeneration (ISR) for rewritten paths, causing pages to be served with 'private, no-store' headers and no cache entries. The issue, filed as vercel/next.js#83862, affects the canonical unprefixed locale on the developer's site AI Change Watch, while prefixed locales continue to cache correctly. The developer notes that the failure is write-side, meaning no cache entry is created, and that deployment frequency masked the problem. 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 https://aichangewatch.com , 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 its own post https://dev.to/ai changewatch/x-nextjs-cache-hit-doesnt-prove-your-isr-is-working-3lmn . This is vercel/next.js 83862 https://github.com/vercel/next.js/issues/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. js 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: php $ 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 and reserved by hand. If you add a root-level 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: bash $ 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 .