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

> Source: <https://dev.to/ai_changewatch/a-middlewarets-rewrite-silently-disables-isr-in-nextjs-155-2d37>
> Published: 2026-09-01 12:00:00+00:00

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`

.
