The Trailing Slash Is Not Cosmetic

You add a redirect rule so /posts/hello cleanly becomes /posts/hello/. It works. You ship it.
Three weeks later someone reports that a mistyped URL hangs the browser. Not a 404 — a hang. The tab spins, then Chrome gives up with ERR_TOO_MANY_REDIRECTS. Every real post still loads fine, so nothing in your monitoring noticed.
The rule you wrote redirects /posts/typo/ to /posts/typo/. Same URL. Forever.
The trailing slash looks like a formatting preference. It is not. It is the input to your host’s path-resolution algorithm, and getting it wrong produces broken relative links, duplicate URLs in the index, and redirect loops that only fire on the paths you never test.
Where the slash actually comes from
A static host does not have routes. It has files, and a rule for turning a URL path into one.
Your build emits one of two shapes:
dist/
about.html # "file" format -> /about
posts/
hello.html # -> /posts/hello
dist/
about/
index.html # "directory" format -> /about/
posts/
hello/
index.html # -> /posts/hello/
Directory format is the default almost everywhere, because it gives you clean URLs without the host needing to strip extensions. The cost is that /about is now a directory, not a document — and serving a directory means picking an index file.
That step, “resolve the directory index,” is where the slash gets decided, and every server does it slightly differently.
The redirect you didn’t write
Classic servers do not just quietly serve /about/index.html when you ask for /about. They issue a redirect first.
nginx, with a stock config:
location / {
index index.html;
try_files $uri $uri/ =404;
}
Request /about, and $uri misses but $uri/ matches a directory. nginx responds 301 Moved Permanently to /about/, and the browser makes a second request. Apache does the same thing via DirectorySlash On, which is on by default.
This is not a quirk. It is required for correctness, and the reason is relative URLs.
Given <a href="team"> on the page at /about, the browser resolves against / and requests /team. Given the same link on /about/, it resolves against /about/ and requests /about/team. Same HTML, two different destinations. The server adds the slash before serving the document so that relative links, relative image srcs, and relative fetch() calls in that document all resolve the way the author meant.
If you have ever seen a page render fine at one URL and lose every image at the other, this is why. The slash is part of the document’s base URL.
Managed hosts hide the redirect, then you re-add it
Render, Netlify, Cloudflare Pages, GitHub Pages, and friends all resolve directory indexes natively. Ask for /about, get 200 and the contents of /about/index.html. No round trip, no visible redirect. Most of them will also serve /about/, meaning both forms return 200 and neither one is canonical.
That looks like a duplicate-content problem, so the obvious fix is a redirect rule. This is the trap.
Here is the rule, in Render’s syntax, and it is broken:
routes:
- type: redirect
source: /posts/:slug
destination: /posts/:slug/
The problem is that :slug happily matches a segment that already ends in a slash. Feed it /posts/typo/ and the rule fires, produces /posts/typo/, and matches again on the next request. A wildcard is worse — source: /posts/* sends /posts/typo/ to /posts/typo//, then ///, growing a slash per hop until the browser quits.
Real pages escape, which is exactly what makes this so hard to catch. Render skips route rules when a resource already exists at the path, so /posts/hello/ serves the file and never reaches the rule. Only paths with no page behind them fall through to it: a typo, a stale inbound link, a post you renamed. In other words, the rule converts your 404 page into an infinite redirect, and only for the requests that most needed the 404.
This is not Render-specific. Any rule engine that matches the post-rewrite path against the same pattern set has the same failure mode. The invariant to hold onto:
A redirect rule whose destination can match its own source is not a redirect. It is a loop with a delay.
Fix it at the generator, not the edge
The correct place to settle this is the build, because the build is what writes the links.
Astro:
export default defineConfig({
site: 'https://example.com',
trailingSlash: 'always',
build: { format: 'directory' },
})
build.format controls what lands on disk; trailingSlash controls what the dev server accepts and what internal link checking expects. Set both, and set them to agree. format: 'directory' with trailingSlash: 'never' is a config that dev-server-passes and production-fails, because dev and the host disagree about which form is real.
Next.js static export:
module.exports = {
output: 'export',
trailingSlash: true,
}
trailingSlash: true emits about/index.html; the default false emits about.html. Whichever you choose, the framework’s own <Link> components now generate hrefs in that form, which is the actual win — your internal links stop generating a redirect hop on every navigation.
Then let the canonical tag do the deduplication work:
<link rel="canonical" href="https://example.com/posts/hello/" />
Search engines consolidate on the canonical URL. They have handled slash-variant duplicates for two decades. You do not need an edge rule for this, and an edge rule that breaks your 404 page is a strictly worse trade: a canonicalised duplicate costs you nothing visible, while a loop makes an entire class of URLs unreachable.
This site landed exactly there. The trailing-slash rules for /posts/:slug and /categories/:slug are gone; the flat, non-parameterised ones (/posts → /posts/) stay, because a literal source path cannot match its own destination. Canonical tags handle the rest.
Rules worth keeping
Edge redirects are not the enemy. Self-matching ones are. A rule is safe when its source pattern provably cannot match its own destination:
- Literal to literal.
/posts→/posts/is fine. The source is an exact path, and the destination is not that path. - Old slug to new slug.
/categories/rendercom→/categories/web-performance/. Different destination namespace, no overlap. - Anything with a parameter or wildcard on both sides. Assume it loops until you have proven otherwise with a request against a path that has no file behind it.
That last test is the one people skip. Testing /posts/hello proves nothing, because the file exists and the rule never runs.
Probe your host in thirty seconds
Do not reason about your host’s behavior from its documentation. Ask it. curl -I with no follow, both forms, including a path you know is missing:
SITE=https://example.com
for path in /about /about/ /posts/hello /posts/hello/ /posts/definitely-not-real /posts/definitely-not-real/; do
code=$(curl -s -o /dev/null -w '%{http_code}' -I "$SITE$path")
loc=$(curl -s -I "$SITE$path" | grep -i '^location:' | tr -d '\r')
printf '%-40s %s %s\n' "$path" "$code" "$loc"
done
Read the output against three questions:
- Do both forms of a real page resolve? One
200and one301to the other is ideal. Two200s is acceptable with a canonical tag. A404on either form is a bug. - Does the missing path terminate? It must reach
404. If it returns301, follow theLocationheader by hand — if it points at itself or grows a slash, you have the loop. - How many hops does a real navigation take? Swap
-Ifor-sIL -w '%{num_redirects}\n'. Every hop is a full round trip in front of your carefully optimised first paint.
Wire the missing-path case into CI. It is one assertion — a request for a URL that does not exist must return 404 — and it is the single check that would have caught every version of this bug.
The takeaway
Pick one form. Configure the generator to emit it in both the filesystem and the hrefs. Add a canonical tag. Then stop, because the next thing you’re tempted to add — a parameterised redirect that enforces the slash at the edge — is the part that breaks.
And test the URLs that do not exist. Your working pages will tell you nothing; they never reach the rules. The broken ones are where your routing config actually runs.

