{"slug": "a-middleware-ts-rewrite-silently-disables-isr-in-next-js-15-5", "title": "A `middleware.ts` Rewrite Silently Disables ISR in Next.js 15.5", "summary": "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.", "body_md": "Every page on my site declared `export const revalidate = 300`\n\n. Nine locales served from ISR.\n\nThe tenth — the one that is actually canonical, the one crawlers hit most — re-rendered from scratch\n\non every single request for weeks.\n\nThe difference between them was not a page, a config flag, or a deployment. It was that the tenth\n\nlocale's URL went through a `NextResponse.rewrite()`\n\nin `middleware.ts`\n\n.\n\nI run [ AI Change Watch](https://aichangewatch.com), a Next.js App Router site on Cloudflare\n\n`en`\n\nis served unprefixed (`/deprecations`\n\n), the other nine locales are prefixed`/ja/deprecations`\n\n). That unprefixed mapping was one line of middleware.Measured on production, 2026-08-06. Same page, same component tree, same `revalidate = 300`\n\n— only\n\nthe routing path differs:\n\n```\n/bot, /rankings                (rewritten in middleware)\n  Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate\n  (no x-nextjs-* headers at all)\n\n/ja/bot, /ja/rankings          (passed through with next())\n  Cache-Control: s-maxage=300, stale-while-revalidate=...\n  x-nextjs-prerender: 1\n```\n\n`private, no-store`\n\non a page whose whole point is to be cached. And not \"cached badly\" — there are\n\nno `x-nextjs-*`\n\nheaders on those responses whatsoever, which means Next never treated the request as\n\na route that has an incremental cache entry. Nothing was written, so nothing could ever be read.\n\nThree things kept this invisible for weeks, and I think each one is general.\n\n**The prefixed locales are fine.** Any \"is my ISR working?\" check you run against `/ja/...`\n\npasses.\n\nThe bug is per-path, and it only touches the paths middleware rewrote.\n\n**Probing the prefixed form of the broken URL measures nothing.** `/en/bot`\n\n307-redirects to the\n\ncanonical `/bot`\n\n(verified again today), so `curl -I /en/bot`\n\nreturns a redirect and tells you\n\nnothing about the page. You have to test the **unprefixed** URL. I burned an eight-minute polling\n\nloop on `/en/bot`\n\nbefore noticing that.\n\n**Pages still refresh, so content never looks stale.** Deploys change the buildId, the buildId is part\n\nof the cache key space, and this repo deploys several times a day — so the whole cache was being\n\ninvalidated often enough that no page was ever visibly out of date. `revalidate = 300`\n\nwas decorative,\n\nand deployment frequency was covering for it. (There was a second, independent reason revalidation\n\nnever ran on this stack; that one is\n\n[its own post](https://dev.to/ai_changewatch/x-nextjs-cache-hit-doesnt-prove-your-isr-is-working-3lmn).)\n\nThis is [vercel/next.js#83862](https://github.com/vercel/next.js/issues/83862) — *\"SWR Cache-Control\ndisabled after Next.js 15.5 when using a rewrite middleware\"*, open, filed 2025-09-16, reported\n\nThe explanation in the issue is that Next matches the **pre-rewrite** path against the\n\ndynamic-route regexes. `/bot`\n\nmatches nothing (the real route is `app/[locale]/[provider]/page.tsx`\n\n),\n\nso the response falls back to the `private, no-store`\n\ndefault. That mechanism is upstream's account\n\nand the internals are not something I can observe from outside; what I can state is the header pair\n\nabove, and that it flips based solely on whether the rewrite happened in middleware.\n\nThe important consequence is that **it is a write-side failure, not a read-side one.** No entry is\n\never created for those keys. So nothing that improves cache *lookup* can help.\n\n**Rewriting the rewrite.** There is no shape of `NextResponse.rewrite()`\n\nthat avoids this. It is not\n\na matcher problem or an ordering problem.\n\n**Serving ISR from the adapter's routing layer** (OpenNext's `enableCacheInterception: true`\n\n) looked\n\nlike the perfect workaround: resolve the cache before NextServer is ever invoked, and the pre-rewrite\n\npath matching stops mattering. Cache hits did work — I have `x-opennext-cache: HIT`\n\non prefixed\n\nlocales to prove it. It still could not fix English, because the cache was empty for those keys, and\n\nthen it took the site down:\n\n```\nError in routingHandler\n  at Object.send (worker.js:116290)     at computeCacheControl (worker.js:120329)\n  at generateResult (worker.js:120394)  at cacheInterceptor (worker.js:121493)\n```\n\nOn a **stale** entry the interceptor has to dispatch the background re-render itself, and at that\n\npoint I had no revalidation queue bound. Inside NextServer that same failure is caught and logged as\n\na warning. In the routing layer it wasn't caught, so the request returned 500. Two properties made it\n\nmuch worse than an ordinary bug: it threw *before* the render, so the entry could never refresh and\n\nthe 500 was permanent per URL; and it only fired once an entry passed its `revalidate`\n\nwindow, so\n\npages died **one at a time over several hours** — 12 URLs before I reverted it.\n\nIf you take one thing from this post, take that shape: a failure that is caught in one layer and\n\nuncaught in the layer you moved it to.\n\nMove the rewrite out of middleware and into `next.config.mjs`\n\n. A config rewrite lands in the routes\n\nmanifest, which the adapter's routing layer applies to the internal request, so NextServer receives a\n\nplain `/en/...`\n\nrequest with no `x-middleware-rewrite`\n\nheader — the exact path `/ja/...`\n\nalways took.\n\n``` js\nasync rewrites() {\n  const reserved = 'en|ja|zh|es|de|fr|ko|pt|it|tr|api|_next|sitemaps';\n  return {\n    afterFiles: [\n      { source: '/', destination: '/en' },\n      { source: `/:seg((?!(?:${reserved})(?:/|$))[^/]+)`, destination: '/en/:seg' },\n      { source: `/:seg((?!(?:${reserved})(?:/|$))[^/]+)/:rest*`, destination: '/en/:seg/:rest*' },\n    ],\n  };\n}\n```\n\nMiddleware keeps the `/en/... → /...`\n\n**redirect**. Redirects are unaffected; only rewrites are.\n\n`afterFiles`\n\n, not `beforeFiles`\n\n.`afterFiles`\n\nruns only when no real route matched, so\n\n`/sitemap.xml`\n\n, `/robots.txt`\n\n, `/icon.svg`\n\nand friends resolve as themselves before this pattern is\n\nconsulted, and drop out of the exclusion list for free. With `beforeFiles`\n\nevery one of them needs an\n\nexplicit exclusion, and each missing exclusion is a 404 on a canonical URL.\n\nBoth of these produce a config that builds fine and 404s in production.\n\n**Trap 1: anchor each alternative to a segment boundary.** The negative lookahead has to end with\n\n`(?:/|$)`\n\n, not `$`\n\n. With `$`\n\nalone it only fires when the reserved word ends the path:\n\n``` php\n                 $ only                    (?:/|$)\n/bot          -> /en/bot                 -> /en/bot\n/ja/bot       -> /en/ja/bot   ← 404      -> (no rewrite)  ✓\n/en/bot       -> /en/en/bot   ← 404      -> (no rewrite)  ✓\n/api/contact  -> /en/api/contact ← 404   -> (no rewrite)  ✓\n/sitemaps/1   -> /en/sitemaps/1 ← 404    -> (no rewrite)  ✓\n```\n\nThat table is `RegExp.exec`\n\noutput, not a sketch. It is also the same family of bug as writing bare\n\n`api`\n\nin a matcher, which swallows `/api-features`\n\n— a page of mine that 404'd on its canonical URL\n\nfor exactly that reason.\n\n**Trap 2: root-level dynamic routes are not protected.** The \"real routes win first\" property of\n\n`afterFiles`\n\nis gated on the adapter's `/sitemaps/[id]`\n\nis root-level and`reserved`\n\nby hand. If you add a root-level**1. Test the compiled regex, not the source string.** What runs is the pattern Next compiles into\n\n`.next/routes-manifest.json`\n\n, and it is not what you typed. Build to a scratch directory\n\n(`NEXT_DIST_DIR=.next-rwtest next build`\n\n), read the manifest, and assert every URL class you care\n\nabout — locale-prefixed, `/en/`\n\n-prefixed, `/api/*`\n\n, `/_next/*`\n\n, root-level files, root-level dynamic\n\nroutes, the feeds. All four shadowing bugs above were caught this way before deploying, and none of\n\nthem was visible in the source.\n\n**2. Then check the unprefixed URL on production.** Today, on the same pages, 2026-08-27:\n\n``` bash\n$ curl -sI https://aichangewatch.com/bot | grep -i 'cache\\|nextjs'\nCache-Control: s-maxage=3600, stale-while-revalidate=31532400\nx-nextjs-cache: STALE\nx-nextjs-prerender: 1\nx-nextjs-stale-time: 300\n\n$ curl -sI https://aichangewatch.com/deprecations | grep -i 'nextjs'\nx-nextjs-cache: HIT\nx-nextjs-prerender: 1\n```\n\n`x-nextjs-prerender: 1`\n\nis the header that was **absent** before, and it is the one to look for.\n\n`x-nextjs-cache: MISS`\n\non its own proves nothing — that is the earlier post's subject.\n\n`@opennextjs/cloudflare`\n\non Cloudflare Workers. The upstream issue is not adapter-specific and the\nreporters were not on my stack, but I have not tested Vercel or a plain `next start`\n\nmyself.`no-store`\n\nfallback is chosen.`Object.send`\n\nbefore the render is read off\nthe 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\n\nlooks completely healthy: full-SSR responses are correct, just uncached. Test the URL your users get,\n\nnot the internal one.\n\n*The site this came out of is AI Change Watch — vendor deprecation\ntables, pricing and SDK changelogs, diffed on a schedule. The pages in the measurements above are\nreal ones; *\n\n`/deprecations`\n\nis the one with the highest cache-hit value, which is why it was the first\nthing I noticed serving `no-store`\n\n.", "url": "https://wpnews.pro/news/a-middleware-ts-rewrite-silently-disables-isr-in-next-js-15-5", "canonical_source": "https://dev.to/ai_changewatch/a-middlewarets-rewrite-silently-disables-isr-in-nextjs-155-2d37", "published_at": "2026-09-01 12:00:00+00:00", "updated_at": "2026-09-01 12:24:18.621781+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Next.js", "Vercel", "AI Change Watch", "Cloudflare", "OpenNext"], "alternates": {"html": "https://wpnews.pro/news/a-middleware-ts-rewrite-silently-disables-isr-in-next-js-15-5", "markdown": "https://wpnews.pro/news/a-middleware-ts-rewrite-silently-disables-isr-in-next-js-15-5.md", "text": "https://wpnews.pro/news/a-middleware-ts-rewrite-silently-disables-isr-in-next-js-15-5.txt", "jsonld": "https://wpnews.pro/news/a-middleware-ts-rewrite-silently-disables-isr-in-next-js-15-5.jsonld"}}