cd /news/developer-tools/a-middleware-ts-rewrite-silently-dis… Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-117672] src=dev.to β†— pub= topic=developer-tools verified=true sentiment=↓ negative

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.

read7 min views1 publishedSep 1, 2026

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

its own post.)

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

.

── more in #developer-tools 4 stories Β· sorted by recency
── more on @next.js 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/a-middleware-ts-rewr…] indexed:0 read:7min 2026-09-01 Β· β€”