How to Fix On-Demand Revalidate 404 on Deployed Hostnames (2026)
Your /api/revalidate 404s on production — or returns 200 with zero effect. A Next.js ISR diagnosis tree with a verified invalidation battery.
Working locally, curl http://localhost:3000/api/revalidate?secret=…&slug=my-post
returns { revalidated: true } and your page updates. Push to production, call
the exact same URL on your real domain, and one of two things happens: the
endpoint 404s, or it happily returns 200 — and the site stays stale
anyway. Both symptoms show up constantly in support threads and platform
forums, and both trace to the same three causes. This is the diagnosis tree I
wish existed when we wired ours up, plus the invalidation battery that runs on
this site (live in production since the first post).
TL;DR
- A 404 on the endpoint almost always means the request never reached your route: hostname rewrites, reverse-proxy rules, or calling the wrong origin. Next's docs are explicit that on-demand revalidation bypasses the proxy layer and target the exact path — rewrites don't follow you there.
- A 200-but-stale success means the data was invalidated while the route entry wasn't (or the tags were never attached, or your host flattens ISR to static assets).
- Next 16's
revalidateTag(tag)single-argument form is deprecated — webhook-style invalidations needrevalidateTag(tag, { expire: 0 }). - The bulletproof combo, verified in production on this very site: data-tag +
route-entry-tag +
revalidatePath+ warm-up requests, then verify with headers, not with hope.
First: which failure do you actually have?
Two distinct diseases get posted under the same "revalidate 404" keyword. Diagnose with one command before touching code:
# Hit the endpoint directly with the secret (bypass nothing)
curl -s -o /dev/null -w "%{http_code}\n" \
"https://www.example.com/api/revalidate?secret=SECRET&slug=my-post"
404here — the endpoint itself is unreachable: routing problem (§1–2).200here, but the page still shows old content after the revalidate — invalidation problem (§3–5).
1. The endpoint 404s: hostname rewrites and the proxy bypass
The most-upvated reports in this family share one shape: something rewrites
URLs before they hit Next — a vercel.json rewrite mapping an app-wide
prefix, a CDN edge rule, an nginx proxy_pass, a platform's "trailing
hostname" feature. Locally there is no rewriting layer, so everything works.
On the deployed hostname, /api/revalidate becomes
/blog/api/revalidate — or worse, a different subdomain entirely — and the
route you shipped never sees the request.
Next's ISR guide handles one branch of this coldly: Proxy (the v16 name for
middleware) won't even be executed for on-demand ISR requests, and
revalidation targets the exact path — /post/1, not a rewritten
/post-1. So if your revalidation logic lived in middleware thinking it
would intercept or decorate those requests, it silently never runs.
The fix is boring and mandatory:
- Test the canonical URL, not a rewritten one.
https://www.example.com/api/revalidatefrom the server that calls it — not a preview domain, not the apex when the canonical iswww(or vice versa). On this site the caller config has an explicitSITE_URL, because "the same domain" turned out to be two hosts joined by a redirect. - Put the rewrite layer's exclusions where the rewrites live — a matcher or rule exception for the API path — or better, call the endpoint from a path that no rewrite touches.
- Keep
secretchecking inside the route handler, not in middleware: if middleware is your auth gate for this route, the very requests it is protecting go around it by design.
One diagnostic that resolves all arguments: curl -I the endpoint from the
external machine and inspect the response headers. On Vercel, a
X-Vercel-Id with no Next runtime markers means you never reached the
framework at all — the platform answered before Next saw anything.
2. It 404s only for some secrets (a contract mismatch)
If the route exists but specific calls 404, suspect the shape of the call. Our
handler accepts two modes — &tag=posts for the list entity and
&slug=<slug> for a single article — and validates the secret in both. A 404
mismatch with a 200 elsewhere is usually one of:
- The secret never made it — unencoded characters in the query string
(a
+in a base64-ish token bleeds into spaces;encodeURIComponenton the busy side fixes the whole class). - Casing/shape drift between the caller (
?slug=…) and the handler (?path=…) — the handler answers 400/401 for a bad secret but your platform's access rules answer 404 for a missing route, and the temptation to conflate the two has cost people hours.
[Verified locally against our own handler with both modes before publishing; the encode step was the difference between "works" and "works on Tuesdays".]
3. It returns 200 but nothing changes: tags that were never attached
The second disease looks identical from the caller. On non-Vercel hosts it's
often structural — platform Q&A threads for Azure Static Web Apps and Netlify
describe ISR flattened to static assets, where the on-demand call "returns
revalidated: true and nothing actually updates"; Azure's help desk
explicitly directs users to time-based revalidation only. On Vercel a 200
is at least a real attempt — but that makes the silent subclass more
poisonous, because nobody checks the page after.
The subclass we measured to death on a Next 16 site under
cacheComponents: data tags alone are not enough. Invalidate only the
data tag and the prerendered route entry keeps serving old HTML with calm
success — headers even confirm freshness. A complete batch is:
// app/api/revalidate/route.ts — the working battery (excerpt)
// ?secret=xxx&tag=posts | ?secret=xxx&slug=abc
import { revalidatePath, revalidateTag } from "next/cache";
import { NextRequest, NextResponse } from "next/server";
export async function GET(req: NextRequest) {
const secret = req.nextUrl.searchParams.get("secret");
const tag = req.nextUrl.searchParams.get("tag");
const slug = req.nextUrl.searchParams.get("slug");
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ error: "Invalid secret" }, { status: 401 });
}
// 1. Data tags — expire immediately (Next 16: single-arg is deprecated)
if (tag) revalidateTag(tag, { expire: 0 });
if (slug) revalidateTag(`post:${slug}`, { expire: 0 });
// 2. Route-entry tags — data tags alone DO NOT retire prerendered entries
if (slug) {
revalidateTag(`_N_T_/post/${slug}`, { expire: 0 });
revalidatePath(`/post/${slug}`);
}
revalidateTag("_N_T_/page", { expire: 0 }); // home/list route
revalidatePath("/");
return NextResponse.json({ revalidated: true, now: Date.now() });
}
The _N_T_… route tags come from reading x-next-cache-tags off a route's
.meta payload — they are how the framework bookkeeps its route (not
data) entries between builds. Invalidate those too, and the two symptoms
collapse into one behavior that behaves like a law.
Note the contract in the code above: expire: 0 as the second argument.
Next 16 deprecated revalidateTag(tag); the single-argument form still
compiles only while TypeScript strictness lets you — migrate now, because a
deprecation is just a 404 with extra steps.
4. The first request after invalidation may be stale (by design)
Even a perfect battery has one sharp edge: the request immediately following an invalidation can still be served from a stale entry before the framework finishes re-rendering. It's a tiny window, but a crawler that fires the exact moment you finish the API call can read the old bytes — and RSS readers crawl at machine speed.
The cure is to re-warm from the caller right after invalidating, so the cache is guaranteed fresh by the time your script exits. Our publish script — the one true writer on this site, since v0.1 ships without an admin UI — does exactly that:
// scripts/insert-post.ts — after the two revalidate GETs
for (let i = 0; i < 2; i++) {
await fetch(`${siteUrl}/`); // home — list feeds RSS/sitemap
await fetch(`${siteUrl}/post/${slug}`); // article render + cache pin
}
[Verified on this site: after the batch, first requests returned the new bytes both locally and in production — measured across several publishes.] If you skip this, the occasional crawler gets one stale read of a post you just "published"; a benign race, but one you paid all that invalidation effort to avoid.
5. Your platform flatters you (multi-instance and CDN-only hosts)
Two gotchas the Next docs put in plain sentences:
- File-system cache is per-instance. If you run multiple containers, one pod "revalidating" invalidates only itself; others keep serving stale HTML until they rebuild or share a custom cache handler. This is the classic "three servers, three answers" symptom of on-demand revalidation, reported and solved the same way for years.
- On static-flattened hosts (e.g., Azure Static Web Apps, or self-host
setups behind an aggressive CDN), there is no long-running Next server
holding the cache — so an on-demand call can return 200 and update
nothing, because the mechanism it's calling into doesn't exist. The
workaround there is a rebuild pipeline, not
revalidateTag.
If your deployment matches either, the code above was correct and the host was the second suspect all along.
Wrap-up
- Diagnose first: 404 from
curlis routing;200-but-stale is invalidation. They're the same search keywords, but they're different diseases with different answers. - The three fixes in Next 15/16 that this site runs in production: exact
canonical paths (no rewrites), a full
revalidateTag(tag, { expire: 0 })+ route-entry battery + warm-up calls, and headers checked as a unit test. - Between "did it work?" and "did the site update," trust only the second.
Sources: official behavior from Next.js's
ISR guide
and
revalidateTag reference
(single-argument deprecation, { expire: 0 }, proxy bypass, per-instance
cache). Cross-host failure reportsonly: Azure Static Web Apps Q&A and
Netlify forums (support threads — quoted as platform answers, not verified
on self-hosting). All config snippets run on this production blog's own
deploy (measured Nov 6, 2026).