Redirects Not Working After Deploy: 6 Causes + curl Fix

Your vercel.json / _redirects 301 deployed but fires a 404 or the old page. Diagnose with curl -I across Vercel, Netlify, and Cloudflare Pages — file location, rule order, file shadowing, CDN cache, trailing slash, rewrites vs redirects.

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.json must be at the repo root (not in src/, not in a subfolder).
  • Netlify’s _redirects belongs in your publish directory. For Astro / Vite / most static frameworks, put it in public/ so the build copies it to dist/_redirects. netlify.toml goes at the repo root.
  • Cloudflare Pages requires _redirects to end up in the build output directory you set in the dashboard (often dist/ or public/).

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, set force = true on 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 showsMost likely causeJump to
200 + old page HTML, no locationRule not in build output, or file shadowingCauses 1 & 3
200 but the redirect is in your configRewrite used instead of redirect, or wrong cause orderCauses 6 & 2
308/307 (Vercel) or 301/302 (Netlify/CF) + correct locationIt works — Vercel’s 308/307 is expectedDone
308 whose location only adds/removes a trailing slashPlatform slash normalization, not your ruleCause 5
Redirect works only with ?cb=..., not plain URLCDN cacheCause 4
cf-cache-status: HIT / x-vercel-cache: HITCDN cacheCause 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) or 301 Moved Permanently (Netlify / Cloudflare) + a location: header → redirect works
  • First response is 307 (Vercel) or 302 (Netlify / Cloudflare) → temporary redirect works
  • First response is 200 → rule didn’t fire (see Steps 2 and 3)
  • First response is 308 whose location only 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:

PlatformRule fileMust live in
Vercelvercel.jsonRepo root
Netlify_redirects or netlify.tomlPublish dir (public/) or root
Cloudflare Pages_redirectsBuild output dir (often public/ or dist/)
Astro static_redirectspublic/_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 --force for the project
  • Netlify: Deploys → latest deploy → Trigger deployClear 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

  1. curl -I -L "https://yourdomain.com/old-path?cb=$(date +%s)" returns the expected 3xx + location: on the first hop, then 200 at the destination.
  2. Run the same curl without the ?cb= buster — it should now also redirect (proves the CDN cache is cleared, not just bypassed).
  3. 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, and headers arrays share this budget); source/destination max 4,096 characters; the /.well-known path 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 -I right after you ship a redirect — never trust the browser alone.
  • Remember Vercel returns 308/307, not 301/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.

Tags: #Hosting #Debug #Troubleshooting