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.
3. Mixed internal links
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:
| Rule | Pro | Con |
|---|---|---|
always (with slash) | Friendly to static export (/page/index.html), default in most SSGs | One extra char |
never (no slash) | Cleaner URLs, REST-style | Static 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.
Step 4: Normalize internal links
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 -ILto verify 0–1 hops
Related
Tags: #Hosting #Debug #Troubleshooting