将“ middleware.ts” 重写为 Next.js 15.5

2026年9月1日1 次浏览来源:Dev.to阅读原文

正文保留英文原文(机翻易破坏代码与排版),标题/摘要已提供中文

Every page on my site declared .

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 in .

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. is served unprefixed (), the other nine locales are prefixed ().

That unprefixed mapping was one line of middleware.

What the headers said Measured on production, 2026-08-06.

Same page, same component tree, same — only the routing path differs: on a page whose whole point is to be cached.

And not "cached badly" — there are no 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 passes.

The bug is per-path, and it only touches the paths middleware rewrote.

Probing the prefixed form of the broken URL measures nothing. 307-redirects to the canonical (verified again today), so 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 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. 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. matches nothing (the real route is ), so the response falls back to the 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 that avoids this.

It is not a matcher problem or an ordering problem.

Serving ISR from the adapter's routing layer (OpenNext's ) 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 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: 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 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 .

A config rewrite lands in the routes manifest, which the adapter's routing layer applies to the internal request, so NextServer receives a plain request with no header — the exact path always took.

Middleware keeps the redirect.

Redirects are unaffected; only rewrites are. , not . runs only when no real route matched, so , , and friends resolve as themselves before this pattern is consulted, and drop out of the exclusion list for free.

With 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: That table is output, not a sketch.

It is also the same family of bug as writing bare in a matcher, which swallows — 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 is gated on the adapter's static route matcher. is root-level and dynamic, so it is not covered, and it has to be named in 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 , and it is not what you typed.

Build to a scratch directory (), read the manifest, and assert every URL class you care about — locale-prefixed, -prefixed, , , 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: is the header that was absent before, and it is the one to look for. 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 + 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 myself.

Why the 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 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; is the one with the highest cache-hit value, which is why it was the first thing I noticed serving .

分享