How to Fix Trailing-Slash URLs on Cloudflare (Astro)
Here’s how to fix trailing-slash URLs on Cloudflare for an Astro site. If both /blog/post and /blog/post/ open, a human sees one page but a search engine sees two different URLs. After moving the site to English, I made every address settle on one trailing-slash form — the same one canonical, hreflang, and the sitemap already use.
Why trailing-slash URLs need to match
On this site the sitemap, canonical tag, and hreflang all emit the slashed form, /blog/post/. So if the un-slashed /blog/post also loads, the same content exists at two addresses. Google reads that as duplicate content and splits the ranking between them.
Cloudflare will add the slash for you. The catch: it does it as a 307 (temporary) redirect. “Temporary” tells search engines not to consolidate the two URLs. A permanent move has to be a 301. So I moved the job from the static-asset layer into the worker, and let the code issue the 301 itself.
Don’t redirect pages that don’t exist (“301 then 404”)
My first version said “no slash? add one and redirect.” That also caught typo’d URLs — it slapped a slash on them, redirected, and then served a 404. A redirect that lands on a 404 is exactly the pattern search engines hate.
The fix: only redirect when the page actually exists. The worker fires a single HEAD request at the slashed URL, and issues the 301 only if that comes back OK. Anything with a file extension (robots.txt, etc.) is left alone, and the query string (?cat=site) rides along.
if (!url.pathname.endsWith('/') && !/\.[^/]+$/.test(url.pathname)) {
const slashed = url.origin + url.pathname + '/';
const probe = await env.ASSETS.fetch(new Request(slashed, { method: 'HEAD' }));
if (probe.ok) return Response.redirect(slashed + url.search, 301);
}
Old addresses (/blog/002-... → /kr/blog/002-.../) follow the same rule — they land on the destination with the slash already attached, in one hop. No redirect chains.
The run_worker_first trap — the monster that only broke live
I wrote all of this, deployed, and it worked locally but did nothing in production. The culprit was a single line in wrangler.jsonc.
By default Cloudflare lets static assets answer before the worker. So the asset layer fired its 307 before my 301 code ever got a turn. The code was fine; it just silently failed live only — the worst kind of bug to chase.
"assets": {
"directory": "./dist",
"binding": "ASSETS",
"run_worker_first": true // worker goes first — without this, the redirects never run
}
With run_worker_first: true, the worker sees every request first and hands off anything that isn’t a redirect via env.ASSETS.fetch.
Keep canonical and hreflang on the same slash
Normalizing the address is pointless if the canonical tag in <head> still ships without the slash — then they disagree. So Base.astro has a tiny helper that adds a trailing slash only when one is missing. Both canonical and hreflang run through it, so components never have to think about slashes.
const abs = (p) => {
const u = p.startsWith('http') ? p : site + p;
return u.endsWith('/') ? u : u + '/';
};
A smoke test so it can’t silently break again
Rules like this are invisible — delete one config line months later and it quietly breaks. So I added a small curl-based smoke test. It checks that:
- an un-slashed URL returns a 301 (not 307) to the slashed one
- the query string (
?cat=site) survives the redirect - old URLs land on the destination in one hop (no chains)
- real pages,
robots.txt, and the sitemap still return 200 - a nonexistent URL goes straight to 404, no redirect first
Run it against the live domain after each deploy and any trap — like run_worker_first slipping off — shows up immediately.
Result
- ✅ Every URL 301s to the same trailing-slash form as canonical, hreflang, and the sitemap
- ✅ Typo’d addresses hit 404 directly, never “301 then 404”
- ✅ One
run_worker_firstline plus a smoke test guard against regressions - ⌨️ Lines of code I typed myself: still zero
A single slash sounds like nothing, but to a search engine it’s the difference between “one house with one address” and “two confusing addresses for the same house.” And for rules like this, you only flush out the monster by aiming curl at the live site yourself.