Trailing Slash Mismatch — Double Redirects

/foo redirects to /foo/ then to /foo (or 308 → 301 chain). Pick one and stick to it.

You run curl -I https://yourdomain.com/article, and a single request 308s to /article/, then 301s back to /article — the browser address bar flickers twice on every navigation. Trailing slash mismatches aren’t just ugly: Google treats the two URLs as duplicate content, your sitemap URLs disagree with your canonicals, CDN cache hit rate drops in half, and redirect chains longer than 5 hops get dropped by crawlers. The fix isn’t “pick the right one” — it’s making sure framework, host, and internal links all use the same rule.

Common causes

Ordered by hit rate, highest first.

1. Framework rule fights host rule

The classic case: Astro defaults to trailingSlash: 'ignore', Vercel defaults to rewriting /foo//foo (strip), but you set trailingSlash: 'always' in astro.config. Now the browser hits /foo → Astro redirects to /foo/ → Vercel rewrites back to /foo → infinite loop or multi-hop chain.

$ 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 1 hop = problem.

2. Two layers of redirects pointing opposite ways

Cloudflare Page Rules has “strip trailing slash” enabled, while Vercel/Netlify project settings has “add trailing slash” enabled. Both fire, request ping-pongs.

How to spot it: In Cloudflare dashboard → Rules → Page Rules / Redirect Rules, search for trailing. Then check the host platform project settings for the same keyword. Both populated = conflict.

Some templates use <a href="/about/">, the nav component uses <a href="/about">. Both work, but every click costs a redirect.

How to spot it: grep the whole repo:

grep -rE 'href="/[a-z]+"' src/ | head -20
grep -rE 'href="/[a-z]+/"' src/ | head -20

If both return results, you’re inconsistent.

4. Sitemap doesn’t match canonical

Sitemap says <loc>https://example.com/post/</loc> but the page says <link rel="canonical" href="https://example.com/post">. Google sees two URLs, can’t tell which is primary, your index coverage drops.

How to spot it: Sample 10 sitemap URLs and diff against page canonicals:

curl -s https://example.com/sitemap.xml | grep -oE '<loc>[^<]+</loc>' | head -3

Eyeball them against the page’s <link rel="canonical">.

5. Defaults changed after a framework upgrade

Next.js 14+ removed legacy trailingSlash behavior. Astro 4.x switched the default from ignore to always for static output. If you upgraded without updating config, new redirect chains appear out of nowhere.

How to spot it: Search the release notes for trailingSlash, or roll back one minor version and re-test.

Shortest path to fix

Step 1: Map the current redirect chain with curl

curl -sIL "https://yourdomain.com/about" | grep -E 'HTTP/|location:'

Each HTTP/2 30x + location: pair is one hop. Ideal: 0 hops (direct 200) or 1 hop.

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 work. Pick whichever your team is less likely to forget. Recommendations:

RuleProCon
always (with slash)Friendly to static export (/page/index.html), default in most SSGsOne extra char
never (no slash)Cleaner URLs, REST-styleStatic export needs explicit file mapping per route, extra SSG config

Once decided, write it into CONVENTIONS.md and make everyone follow it.

Step 3: Same rule in all three places

Astro + Vercel + always:

// astro.config.mjs
export default defineConfig({
  site: 'https://example.com',
  trailingSlash: 'always',
  build: { format: 'directory' },  // emits /about/index.html
});
// vercel.json
{
  "trailingSlash": true,
  "cleanUrls": false
}

Next.js:

// next.config.js
module.exports = { trailingSlash: true };

Cloudflare: open Rules → Bulk Redirects or Page Rules and delete any “strip trailing slash” rule.

One-shot rewrite for everything under src/:

# Add trailing slash to slashless internal links (.astro/.tsx/.mdx only)
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:

# Fail the build if any slashless internal link sneaks back in
! grep -rE 'href="/[a-z][a-z0-9-]*"[^/]' src/ --include='*.{tsx,astro,mdx}'

Step 5: Align sitemap and canonical

If you generate the sitemap with a framework plugin (@astrojs/sitemap, next-sitemap), it follows trailingSlash automatically. If hand-written, centralize:

const canonical = (path) => `https://example.com${path.replace(/\/?$/, '/')}`;

After deploy, rerun Step 1’s curl to confirm 0–1 hops.

Prevention

  • Write the convention into CONVENTIONS.md / README.md; add a “trailing slash check” line to the PR template
  • Add a grep rule to CI that fails on mismatched internal links
  • Generate the sitemap from a framework plugin — never hand-maintain
  • When upgrading Astro / Next.js major versions, search the release notes specifically for trailingSlash
  • After deploy, batch-test 20 core URLs with httpstatus.io or curl -IL to verify 0–1 hops

Tags: #Hosting #Debug #Troubleshooting