You added a /old-path → /new-path redirect in vercel.json, _redirects, or netlify.toml, deployed, opened the browser, and either get a 404 or still see the old page. This is the most common “config looks right but isn’t running” failure on web hosts.
Fastest fix: run curl -I -L "https://yourdomain.com/old-path?cb=$(date +%s)" and read the first status line. A 308/307/301/302 with a location: header means the rule fired; a 200 means it never matched (file location or rule order); a cache header like x-vercel-cache: HIT or cf-cache-status: HIT means you’re seeing a stale cached response. The rest of this article maps each curl result to a specific cause and fix.
One correction up front, because it trips up almost everyone debugging Vercel: Vercel returns 308 Permanent Redirect (not 301) when permanent: true, and 307 Temporary Redirect (not 302) when permanent: false (Vercel docs, as of June 2026). Netlify and Cloudflare Pages _redirects default to 301. So if you curl a Vercel redirect expecting “301 Moved Permanently” and see “308,” that is correct and working — not a bug.
Common causes
Ordered by hit rate, highest first.
1. The rule file isn’t in the build output
The rule file has to land in the directory the platform actually serves from:
vercel.jsonmust be at the repo root (not insrc/, not in a subfolder).- Netlify’s
_redirectsbelongs in your publish directory. For Astro / Vite / most static frameworks, put it inpublic/so the build copies it todist/_redirects.netlify.tomlgoes at the repo root. - Cloudflare Pages requires
_redirectsto end up in the build output directory you set in the dashboard (oftendist/orpublic/).
If you stashed the file in src/ or app/, the build won’t copy it and the redirects silently do nothing.
How to spot it: after npm run build, run ls dist/ (or ls .vercel/output/) and confirm _redirects or the Vercel config.json is really there. On Vercel you can also open a deployment → Source tab and search for the filename in the built output.
2. An earlier catch-all rule wins
Redirect engines are top-down, first-match-wins. If your vercel.json has:
{
"redirects": [
{ "source": "/:path*", "destination": "/new-site/:path*", "permanent": true },
{ "source": "/old", "destination": "/new", "permanent": true }
]
}
the second rule can never fire because the first consumes every path.
The same ordering rule applies everywhere: in Netlify _redirects the first matching line wins, and on Cloudflare Pages the top-most rule for a given source is applied — Cloudflare additionally requires all static rules before dynamic (splat/:placeholder) rules (Cloudflare Pages docs, June 2026).
How to spot it: temporarily move /old to the top of the list. If it suddenly works, it’s an ordering problem — keep specific rules above catch-alls.
3. A real file is shadowing the rule (Netlify / Cloudflare Pages)
This one is Netlify-specific and easy to miss. On Netlify, an unforced redirect only fires if no static file already exists at the source path. If /old (or /old.html, /old/index.html) exists in your publish directory, Netlify serves that file and ignores your redirect. This is called file shadowing.
The fix is to force the rule:
- In
_redirects, append!to the status code:/old /new 301! - In
netlify.toml, setforce = trueon the rule.
Forced rules override file shadowing and always take effect. Use them when you intend the redirect to win even if matching content exists (Netlify docs, June 2026).
How to spot it: if curl -I returns 200 and the body is the old page’s HTML, check whether a file at that path exists in dist/. If it does and you want the redirect anyway, add ! / force = true.
4. CDN or browser is serving the cached old response
If the previous deploy didn’t have a redirect and the CDN cached a 200 + HTML for that URL, the new rule won’t take effect until that cache entry expires or is purged. Cloudflare doesn’t cache HTML by default, but once you turn on HTML caching (Cache Rules) or sit behind another CDN, a cached 200 can stick around until its Edge Cache TTL lapses; Vercel also caches redirect responses at the edge.
How to spot it: run curl -I "https://yourdomain.com/old-path?cb=$(date +%s)" with a unique cache-buster query. If the busted URL returns the redirect but the plain URL still returns 200, it’s caching. Look for cf-cache-status: HIT or x-vercel-cache: HIT in the headers.
5. Trailing slash mismatch
Most platforms treat /old and /old/ as different URLs. Vercel defaults to trailingSlash: false, which means a request to /old/ is itself 308-redirected to /old before your rule even runs — so a rule whose source is /old/ never matches the normalized /old. The same trap exists in Netlify _redirects.
How to spot it: if curl -I shows a 308 whose location: points back to the same path with the slash added or removed, that’s the platform’s trailing-slash normalization, not your rule. Match your rule’s source to your site’s actual slash style and retest.
6. You used rewrites when you wanted redirects
rewrites keep the URL in the address bar and proxy content internally; the visitor still sees /old. redirects actually send the browser to /new with a 3xx + location: header. Mixing up the two fields gives the “looks like it jumped but didn’t” symptom (or the reverse — the URL changed when you wanted it to stay).
How to spot it: curl -I and read the status code — 200 means a rewrite served different content at the same URL; 301/302/307/308 means a real redirect.
Diagnosis table — match your curl result to the cause
Run curl -I -L "https://yourdomain.com/old-path?cb=$(date +%s)" and read the first response line plus headers:
| What curl shows | Most likely cause | Jump to |
|---|---|---|
200 + old page HTML, no location | Rule not in build output, or file shadowing | Causes 1 & 3 |
200 but the redirect is in your config | Rewrite used instead of redirect, or wrong cause order | Causes 6 & 2 |
308/307 (Vercel) or 301/302 (Netlify/CF) + correct location | It works — Vercel’s 308/307 is expected | Done |
308 whose location only adds/removes a trailing slash | Platform slash normalization, not your rule | Cause 5 |
Redirect works only with ?cb=..., not plain URL | CDN cache | Cause 4 |
cf-cache-status: HIT / x-vercel-cache: HIT | CDN cache | Cause 4 |
Shortest path to fix
Ordered by ROI. The first three usually solve 80% of cases.
Step 1: Use curl to see the real response
Don’t trust the address bar — the browser auto-follows redirects and may hit local cache. Curl directly:
curl -I -L "https://yourdomain.com/old-path?cb=$(date +%s)"
Read the output:
- First response is
308 Permanent Redirect(Vercel) or301 Moved Permanently(Netlify / Cloudflare) + alocation:header → redirect works - First response is
307(Vercel) or302(Netlify / Cloudflare) → temporary redirect works - First response is
200→ rule didn’t fire (see Steps 2 and 3) - First response is
308whoselocationonly flips a trailing slash → that’s the platform’s trailing-slash behavior, not your rule (Cause 5) cf-cache-status: HIT/x-vercel-cache: HIT→ caching (see Step 4)
Step 2: Confirm the file is in the artifact and the order is right
Build locally and inspect:
npm run build
ls -la dist/ | grep -E '_redirects|vercel.json'
cat dist/_redirects 2>/dev/null || cat vercel.json
If the file isn’t in dist/ (or .vercel/output/), move it to where the platform expects:
| Platform | Rule file | Must live in |
|---|---|---|
| Vercel | vercel.json | Repo root |
| Netlify | _redirects or netlify.toml | Publish dir (public/) or root |
| Cloudflare Pages | _redirects | Build output dir (often public/ or dist/) |
| Astro static | _redirects | public/_redirects |
Then put specific rules before generic ones. Vercel example:
{
"redirects": [
{ "source": "/old", "destination": "/new", "permanent": true },
{ "source": "/blog/:slug", "destination": "/articles/:slug", "permanent": true },
{ "source": "/legacy/:path*", "destination": "/", "permanent": false }
]
}
Equivalent Netlify _redirects (order matters; force with ! if a file shadows the path):
/old /new 301!
/blog/* /articles/:splat 301
/legacy/* / 302
Step 3: If curl shows 200 + old HTML, check for file shadowing
On Netlify (and Cloudflare Pages), a static file at the source path beats an unforced rule. Confirm whether a file exists:
ls -la dist/old dist/old.html dist/old/index.html 2>/dev/null
If one exists and you still want the redirect, force it: append ! in _redirects or set force = true in netlify.toml. Redeploy and re-run Step 1’s curl.
Step 4: Purge the CDN for that one URL
Don’t purge everything — it’s expensive and rarely needed. Purge just the affected URL:
- Cloudflare: Caching → Configuration → Purge Custom URLs, paste the full URL (with
https://) - Vercel: redeploy, or run
vercel --prod --forcefor the project - Netlify: Deploys → latest deploy → Trigger deploy → Clear cache and deploy site
Re-run Step 1’s curl with a cache buster to confirm.
Step 5: Add a CI smoke test
The cheapest regression guard is a post-deploy hook that curls a handful of redirects and asserts the status and location. Note the expected codes differ by host — Vercel emits 308, Netlify/Cloudflare emit 301:
#!/usr/bin/env bash
set -e
BASE="https://yourdomain.com"
check() {
local from="$1" expected="$2"
local actual=$(curl -s -o /dev/null -w "%{http_code} %{redirect_url}" "$BASE$from")
if [[ "$actual" != "$expected"* ]]; then
echo "FAIL $from -> got '$actual', expected '$expected'"; exit 1
fi
}
# On Vercel use 308; on Netlify/Cloudflare Pages use 301
check "/old" "308 ${BASE}/new"
check "/blog/hello" "308 ${BASE}/articles/hello"
echo "All redirects OK"
Run it on the GitHub Actions deployment_status event so it fires after each deploy.
How to confirm it’s fixed
curl -I -L "https://yourdomain.com/old-path?cb=$(date +%s)"returns the expected 3xx +location:on the first hop, then200at the destination.- Run the same curl without the
?cb=buster — it should now also redirect (proves the CDN cache is cleared, not just bypassed). - Open the URL in a private/incognito window (fresh browser cache) and confirm the address bar lands on
/new-path.
Watch the platform limits
Hitting a limit makes later rules silently disappear, which looks identical to “redirect not working”:
- Vercel: up to 1,024 routes per deployment (the
redirects,rewrites, andheadersarrays share this budget);source/destinationmax 4,096 characters; the/.well-knownpath is reserved and cannot be redirected (Vercel docs, June 2026). - Cloudflare Pages: 2,000 static + 100 dynamic = 2,100 rules in
_redirects, 1,000 characters per line; beyond that, move to Bulk Redirects (Cloudflare Pages docs, June 2026).
Prevention
- Verify with
curl -Iright after you ship a redirect — never trust the browser alone. - Remember Vercel returns
308/307, not301/302; assert the right code in tests. - Order rules: specific first, catch-all last; on Cloudflare put static rules before dynamic ones.
- Force Netlify rules (
!/force = true) when a real file could shadow the source path. - Pick one trailing-slash policy site-wide and make rule sources match it.
- Run a 5-10 URL smoke test post-deploy in CI, and commit before bulk redirect edits so you can revert by file diff.
FAQ
Why does my Vercel redirect return 308 instead of 301?
That’s expected. Vercel maps permanent: true to 308 Permanent Redirect and permanent: false to 307 Temporary Redirect (as of June 2026). Both are honored by browsers and search engines as permanent/temporary; 308/307 preserve the HTTP method, unlike 301/302. Nothing is broken.
My Netlify _redirects rule is ignored but the file is clearly deployed. Why?
Almost always file shadowing: a static file already exists at the source path, so Netlify serves it instead of redirecting. Force the rule with a trailing ! in _redirects (e.g. /old /new 301!) or force = true in netlify.toml.
curl shows the redirect but my browser still loads the old page. What now?
Your browser cached the old 200 (or a previous 301, which browsers cache aggressively). Hard-reload, test in an incognito window, and purge the CDN for that single URL (Step 4). Permanent 301/308 responses can stick in the browser cache for a long time, so always re-test in a fresh profile.
Why does my rule only work when I add ?cb=12345 to the URL?
The plain URL is being served from the CDN cache while the cache-busted variant is a fresh request that hits your new rule. Purge the CDN for that path (Step 4); after purging, the plain URL should redirect too.
Should I use a redirect or a rewrite?
Use a redirect when the URL should change in the address bar and search engines should follow the move (3xx + location:). Use a rewrite when you want to serve content from another path while keeping the original URL visible (returns 200). If curl -I shows 200, you have a rewrite.
How do I know if I hit the redirect limit? If early rules work but rules near the end of a large file don’t, you may be over the platform cap (Vercel 1,024 routes; Cloudflare Pages 2,100). Move bulk redirects to Cloudflare Bulk Redirects or Vercel Edge Middleware + Edge Config instead of stuffing them all into one file.
Related
Tags: #Hosting #Debug #Troubleshooting