You run curl -sI https://yourdomain.com/article and a single request 308s to /article/, then 308s back to /article — the address bar flickers twice on every navigation, and crawlers see a redirect loop. This is a trailing-slash mismatch: one layer wants the slash, another wants it gone, and they fight on every request.
Fastest fix: the loop almost always means your framework rule and your host rule disagree. Pick one form (slash or no slash), set the same rule in your framework config (trailingSlash), your host config (vercel.json / Cloudflare / Netlify _redirects), and your internal links — then redeploy. Jump to Shortest path to fix. If you only want to confirm the diagnosis first, run curl -sIL https://yourdomain.com/article | grep -iE 'HTTP/|location:' and count the hops: more than one means a mismatch.
Why it matters beyond the flicker: Google treats /foo and /foo/ as two different URLs (a slash after the hostname’s path is “a significant part of the URL,” per Google Search Central), so a mismatch splits indexing signals and can trigger “Duplicate without user-selected canonical” in Search Console. CDN cache also keys on the exact path, so two forms of the same page halve your hit rate, and long redirect chains waste crawl budget.
Which bucket are you in?
Symptom from curl -sIL | Most likely cause | Go to |
|---|---|---|
Two 308/301 hops, opposite directions (loop) | Framework rule fights host rule | Cause 1 |
| Two hops, both add or both strip, different layers | CDN + app redirect stacked | Cause 2 |
| One redirect per click, only from some links | Mixed internal links | Cause 3 |
| Pages index fine but Search Console flags duplicates | Sitemap ≠ canonical | Cause 4 |
| Chains appeared right after a deploy/upgrade | Default changed on upgrade | Cause 5 |
Common causes
Ordered by hit rate, highest first.
1. Framework rule fights host rule
The classic loop. Astro’s default is trailingSlash: 'ignore' (verified June 2026 — it has not changed to always), but you set trailingSlash: 'always' in astro.config to match build.format: 'directory'. Meanwhile your host strips the slash: Next.js strips by default, and Vercel’s cleanUrls issues a 308 to the extensionless/slashless path. Now the browser hits /foo → the framework 308s to /foo/ → the host 308s back to /foo → loop.
$ curl -sI https://example.com/about | grep -i location
location: /about/
$ curl -sI https://example.com/about/ | grep -i location
location: /about
How to spot it: curl -sIL https://yourdomain.com/page and count the location: headers. More than one hop, or two hops pointing opposite ways, is this bucket.
2. Two redirect layers pointing opposite ways
A CDN rule and an app/host rule both fire. For example, a Cloudflare Redirect Rule (or legacy Page Rule) strips the trailing slash, while your Vercel/Netlify project adds one. Each layer is individually correct; stacked, the request ping-pongs.
How to spot it: In the Cloudflare dashboard go to Rules → Overview (and check both Redirect Rules and any legacy Page Rules) and search for slash or trailing. Then check the host platform’s project settings or config file for the same keyword. Both populated and pointing different ways = conflict. Note: as of June 2026 Page Rules still exist but Cloudflare steers new setups to Redirect Rules / Bulk Redirects — migrate stale slash logic there.
3. Mixed internal links
Some templates use <a href="/about/">, the nav component uses <a href="/about">. Both resolve, but every click to the “wrong” form costs a redirect (one extra round trip), even when the framework/host config is otherwise correct.
How to spot it: grep the repo for both forms:
grep -rE 'href="/[a-z][a-z0-9-]*"' src/ | head -20
grep -rE 'href="/[a-z][a-z0-9-]*/"' src/ | head -20
If both return results, your links are inconsistent.
4. Sitemap doesn’t match canonical
Sitemap says <loc>https://example.com/post/</loc> but the page declares <link rel="canonical" href="https://example.com/post">. Google now has two conflicting signals for the same page and may pick the wrong canonical, which shows up as “Duplicate without user-selected canonical” or “Alternate page with proper canonical tag” in Search Console’s Page indexing report.
How to spot it: sample a few sitemap URLs and diff them against the page’s canonical:
curl -s https://example.com/sitemap.xml | grep -oE '<loc>[^<]+</loc>' | head -3
Open one of those URLs and check its <link rel="canonical"> — the slash must match the <loc>.
5. Defaults changed after a framework upgrade
A major-version bump can flip the effective behavior. Astro recommends trailingSlash: 'always' only when you set build.format: 'directory' and 'never' for build.format: 'file'; if you change build.format during an upgrade without updating trailingSlash, redirects appear. On Next.js, the default has long been to strip the slash (/about/ → /about); if a teammate added trailingSlash: true or removed it, behavior flips for the whole app. New chains “out of nowhere” after a deploy are almost always this.
How to spot it: search the release notes / your config diff for trailingSlash and build.format (Astro) or trailingSlash / skipTrailingSlashRedirect (Next.js). Or roll back one version and re-run the curl test.
Shortest path to fix
Step 1: Map the current redirect chain with curl
curl -sIL "https://yourdomain.com/about" | grep -iE 'HTTP/|location:'
Each HTTP/2 30x + location: pair is one hop. Target state: 0 hops (direct 200) for the canonical form, or 1 hop from the non-canonical form to the canonical one. Two or more hops, or any loop, is the bug.
You can also use DevTools → Network → tick Preserve log and watch a single request expand into multiple rows.
Step 2: Pick one rule — always or never
Both are fine; consistency is what matters. Quick guidance:
| Rule | Pro | Con |
|---|---|---|
always (with slash) | Friendly to static export — /page/index.html maps cleanly to /page/; pairs with Astro build.format: 'directory' | One extra character |
never (no slash) | Cleaner URLs, REST-style; pairs with Astro build.format: 'file' and is Next.js’s default | Static export needs explicit per-route file mapping |
Once decided, write it into CONVENTIONS.md so nobody reintroduces the other form.
Step 3: Set the same rule in framework, host, and CDN
Astro + Vercel, choosing always:
// astro.config.mjs
export default defineConfig({
site: 'https://example.com',
trailingSlash: 'always',
build: { format: 'directory' }, // emits /about/index.html → /about/
});
// vercel.json
{
"trailingSlash": true,
"cleanUrls": false
}
Setting "cleanUrls": false matters: with cleanUrls: true, Vercel issues a 308 to strip the extension/slash, which fights trailingSlash: 'always'. Pick one side.
Next.js, choosing always:
// next.config.js
module.exports = { trailingSlash: true };
Leave it unset (or false) to keep Next.js’s default strip behavior. Only reach for skipTrailingSlashRedirect: true if you must handle slashes in custom middleware — it disables the built-in redirect and you own the logic.
Cloudflare: open Rules → Overview, then Redirect Rules (and legacy Page Rules), and delete or fix any rule that strips/adds the slash in the opposite direction from your chosen rule. If you genuinely need the CDN to enforce it, keep exactly one rule, not one on each layer.
Step 4: Normalize internal links
One-shot rewrite for everything under src/ (the '' after -i is the BSD/macOS sed syntax; drop it on GNU/Linux):
# Add a trailing slash to slashless internal links in .astro/.tsx/.mdx
grep -rlE 'href="/[a-z][a-z0-9-]*"' src/ | \
xargs sed -i '' -E 's|href="(/[a-z][a-z0-9-]*)"|href="\1/"|g'
Then lock it in CI so the other form can’t sneak back:
# Fail the build if any slashless internal link reappears
! grep -rE 'href="/[a-z][a-z0-9-]*"[^/]' src/ --include='*.{tsx,astro,mdx}'
(For never, invert: strip the slash and fail on the slashed form.)
Step 5: Align sitemap and canonical
If you generate the sitemap with a framework plugin (@astrojs/sitemap, next-sitemap), it follows trailingSlash automatically — prefer this. If hand-written, centralize one helper so <loc> and <link rel="canonical"> come from the same source of truth:
const canonical = (path) => `https://example.com${path.replace(/\/?$/, '/')}`;
Step 6: Confirm it’s fixed
Redeploy, then re-run Step 1 against both forms:
curl -sIL "https://yourdomain.com/about" | grep -iE 'HTTP/|location:' # non-canonical → 1 hop
curl -sIL "https://yourdomain.com/about/" | grep -iE 'HTTP/|location:' # canonical → direct 200
You want exactly one of them to redirect, in one hop, to the other — never a loop. Spot-check a sitemap URL and confirm its <loc> slash matches the served 200 form. In Google Search Console, use URL Inspection on the canonical URL to confirm “URL is on Google” with the expected canonical.
Prevention
- Write the convention into
CONVENTIONS.md/README.md; add a “trailing-slash check” item to the PR template. - Keep the Step 4 CI grep so mismatched internal links fail the build.
- Generate the sitemap from a framework plugin — never hand-maintain
<loc>values. - When upgrading Astro / Next.js major versions, search the changelog specifically for
trailingSlashandbuild.format. - After deploy, batch-test ~20 core URLs with httpstatus.io or
curl -sILto confirm 0–1 hops.
FAQ
Should I use a trailing slash or not for SEO?
Neither is better for ranking — Google’s only requirement is consistency. Pick one, redirect the other with a single 301/308, and make your internal links and sitemap agree. Google treats example.com/foo and example.com/foo/ as distinct URLs, so the harm comes from serving both, not from which one you choose.
Why do I see a 308 instead of a 301?
308 Permanent Redirect preserves the request method and body (a POST stays a POST), so modern frameworks (Next.js, Astro, Vercel cleanUrls) use it for slash normalization instead of 301. For SEO they’re equivalent — both pass signals to the target. The problem is the chain/loop, not the status code.
Is the homepage / affected?
The root / always keeps its slash and is exempt from “strip” rules, so it rarely loops. Mismatches show up on sub-paths like /about vs /about/. Test a real sub-page, not the homepage.
My config looks right but one specific page still double-redirects. Why?
Usually a stale CDN cache or an old per-page redirect rule. Purge the CDN cache for that path, then check for a one-off entry in vercel.json redirects, Netlify _redirects, or a Cloudflare Redirect Rule that targets that exact URL with the wrong slash.
Does fixing this require re-submitting my sitemap? No — Google re-crawls on its own. But after a large normalization it speeds reindexing to resubmit the sitemap in Search Console and to spot-check a few URLs with URL Inspection so the new canonical is picked up.
Related
Tags: #Hosting #Debug #Troubleshooting