Trailing Slashes Are Different URLs
https://example.com/page and https://example.com/page/ are different URLs. Not stylistic variants — different addresses that a server may resolve to different content, that a crawler treats as distinct, and that can both end up indexed. The only exception is the root: https://example.com and https://example.com/ are equivalent by specification, because an empty path is defined as /.
Everywhere below the root, the slash is part of the path. Which one you serve is a decision, and the cost of not making it is duplicate URLs and a redirect hop on every inbound link that guessed wrong.
Why both variants exist on almost every site
Nobody sets out to serve two versions. They arrive from four directions.
Framework defaults differ. Some static site generators emit directories with index.html, producing /page/. Some emit page.html served at /page. Some routers accept both and normalise silently at request time — which is convenient and hides the problem until something upstream of the router does not normalise.
Hand-written internal links. Someone types /about in one template and /about/ in another. Both work, so nobody notices.
External links pick their own. A referring site links to whichever form the author typed. You do not control that, which is exactly why you need a single hop that fixes it.
Migrations change the convention. A replatform switches from one style to the other, redirects get added, and now old links take an extra hop. This is one of the two classic sources of accidental chains described in redirect chains and how to flatten them.
Which one to pick
Mechanically it does not matter. There is no indexing advantage to either form, and search engines handle both. What matters is that you pick one, enforce it everywhere, and never change it again.
Two practical inputs to the choice:
- Whatever you are already serving on your indexed URLs. Switching convention means redirecting every URL on the site, which is a migration. The status quo has real value.
- Whatever your platform emits naturally. Fighting a static generator’s output convention with rewrite rules is a permanent maintenance cost for no gain.
If it is a genuine greenfield choice, trailing slashes on paths that represent collections and no slash on leaves is a defensible convention, and so is “slashes everywhere.” Pick, document it in a single line somewhere developers read, and move on.
Enforcing it in one hop
The requirement is that any request for the non-preferred form returns a single 301 straight to the preferred form — not a 301 into another 301.
nginx, enforcing trailing slashes:
# nginx: add a trailing slash to any path without a file extension
location ~ ^(?<path>.*[^/])$ {
if ($path !~ \.[a-z0-9]+$) {
return 301 https://example.com$path/;
}
try_files $uri $uri/ =404;
}
The extension check is load-bearing. Without it the rule appends a slash to /logo.png, which is not a directory, and the result is a 404 for every static asset — or worse, a redirect loop if something else strips the slash back off.
Apache, the same direction:
# Apache: add a trailing slash, skipping real files
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_URI} !\.[a-zA-Z0-9]{2,4}$
RewriteCond %{REQUEST_URI} !/$
RewriteRule ^(.*)$ /$1/ [R=301,L]
The !-f condition excludes anything that exists as a file on disk. That is the safety rail; without it this rule is a loop generator.
Going the other way — stripping slashes — has the mirror-image risk. A rule that strips the trailing slash from a directory path can send the request to a URL the server resolves back to the directory with a slash, and the two rules bounce. Whichever direction you choose, test a directory path, a file path, the root, and a path with a query string before deploying.
Query strings and fragments
The slash goes before the query string, and the query string is not part of the normalisation decision:
/page?ref=x → 301 → /page/?ref=x
Dropping the query string in the redirect is a routine bug in hand-written rules — nginx’s return 301 https://example.com$uri/; discards it, while $request_uri preserves it but already includes the query, so appending a slash to $request_uri puts the slash in the wrong place. Build the target from the path and re-append the query explicitly, and verify with a request that has parameters on it.
Fragments never reach the server, so they are not your problem — though a browser preserves them across a redirect, which is why a redirect that drops the query string is often reported as “the anchor works but the tracking doesn’t.”
Fixing the sources, not just the symptom
The redirect is a safety net for links you do not control. Every URL you emit should already be in the canonical form, because a link that goes through a redirect costs a round trip and, in aggregate, makes your internal link graph noisier than it needs to be.
Four places to fix:
- Internal links. Crawl your own site and list every internal link whose target returns a 3xx. That list should be empty. The reasoning is in internal linking as plumbing.
- The sitemap. Every URL in it should be a 200. A sitemap full of redirects is telling the crawler to fetch URLs you have said are not the right ones — see what an XML sitemap is actually for.
- Canonical tags. They must name the preferred form. A canonical pointing at the non-slash variant while you redirect to the slash variant is a direct contradiction.
- Hreflang annotations. Same problem, with the added twist that a redirected hreflang target counts as an unconfirmed return tag and is ignored.
The audit
# both variants, one command
for u in https://example.com/page https://example.com/page/; do
printf '%s -> ' "$u"
curl -sI "$u" | awk 'NR==1{c=$2} /^[Ll]ocation:/{l=$2} END{print c, l}'
done
What you want: one variant returns 200, the other returns 301 with a Location equal to the first, and no third line. What you do not want:
- Both return 200. Two indexable URLs for one page. Add the rule.
- Both return 301. A loop. Fix before anything else.
- One returns 301 to a URL that also returns 301. A two-hop chain, usually because the slash rule and the host or protocol rule are separate. Combine them, as in consolidating www and HTTPS in a single hop.
Run it across a sample of paths rather than one, and include a static asset, because the asset case is where slash-normalisation rules do their damage and where nobody thinks to look.