Tutorial
Cloudflare Workers Redirects: The _redirects File
· 6 min read
On this page
Last week I deleted six tag pages from this site. They were casualties of a tag-vocabulary
cleanup — /tags/satori/, /tags/json-ld/, and four others that each pointed at a single
article. Deleting a page is easy; deleting it responsibly means every indexed URL and every
backlink still lands somewhere useful, which means 301 redirects.
This site is a fully static Astro build on Workers static
assets — there is no Worker script, and I didn’t
want to write one just to map twelve URLs. It turns out I didn’t have to. Workers static assets
supports the same plain-text _redirects file that Cloudflare Pages made familiar, and it
covers everything a content site is likely to need. This post is the setup, the syntax, the
limits, and the one gotcha that nearly slipped past me.
Where the file goes
Create a file named _redirects — no extension — in your static assets directory. With a
framework, that’s the directory whose contents get copied into the build output verbatim: for
Astro that’s public/, and the file ends up at the root of dist/.
public/
├── _redirects
├── robots.txt
└── favicon.svg
Cloudflare picks the file up with your deploy and applies the rules at the edge. The file
itself is never served — a request for /_redirects won’t expose your rule list.
If you came from Pages: this is the same mechanism, same syntax. It’s one of the reasons the
Pages-to-Workers migration is smaller than it sounds —
_redirects and _headers files come along unchanged.
The syntax
One rule per line: source, destination, and an optional status code.
# Old tag pages → the tag that replaced them
/tags/satori/ /tags/seo/ 301
/tags/json-ld/ /tags/seo/ 301
# An article that moved
/blog/old-slug/ /blog/new-slug/ 301
# Off-site is fine too
/discord https://discord.gg/example 302
Lines starting with # are comments. The status code defaults to 302 if you omit it, and the
supported set is 301, 302, 303, 307, and 308.
For anything you’ve deleted or renamed permanently, be explicit about the 301. A 302 tells
search engines the move is temporary, so they keep the old URL indexed and check back; a 301
transfers the old URL’s standing to the new one and gets the old one dropped from the index.
Defaulting to 302 is the safe choice for a parser, but it’s almost never what a content site
wants for a restructure.
The trailing-slash gotcha
Here’s the one that nearly slipped past me: static rules match exact paths. My first draft redirected
/tags/satori/ — with the trailing slash — and it worked in the browser, because that’s the
canonical form my sitemap had always advertised. But external links don’t read your sitemap.
Someone linking to /tags/satori (no slash) would have sailed past the rule.
Whether that misses depends on how your asset routing normalizes URLs, and I’d rather not depend on the interaction. Listing both forms is two lines and removes the question:
/tags/satori /tags/seo/ 301
/tags/satori/ /tags/seo/ 301
Mechanical, but it’s the difference between “redirects I tested” and “redirects that catch what the web actually throws at them”. If you have many URLs to cover, a splat handles both forms in one rule — that’s next.
Splats and placeholders
Beyond exact paths, two kinds of dynamic matching are supported.
Splats match greedily and are reused with :splat:
# Move an entire section
/docs/* /guides/:splat 301
A request for /docs/setup/install lands on /guides/setup/install. One splat per rule.
Placeholders match a single path segment:
/posts/:year/:slug /blog/:slug 301
That collapses a dated URL structure (/posts/2024/my-article) into a flat one — the classic
blog-migration rule. Each placeholder can be referenced once in the destination.
These dynamic rules are the reason I’d reach for _redirects even on a big migration: a WordPress
or Jekyll import with hundreds of dated URLs usually reduces to a handful of splat rules rather
than hundreds of exact lines.
Ordering, limits, and precedence
Rules are evaluated top to bottom and the first match wins, so put exact rules above the splats that would otherwise swallow them:
# Specific exception first…
/docs/legacy-page /blog/why-we-dropped-this/ 301
# …then the catch-all
/docs/* /guides/:splat 301
The limits are generous for a content site: 2,000 static rules plus 100 dynamic (splat or placeholder) rules per deployment, 1,000 characters per line. Past that scale, Cloudflare’s Bulk Redirects — account-level rules managed outside the repo — are the intended tool.
Redirects are checked before asset lookup, so a rule fires even if a file still exists at the source path. That’s occasionally surprising, but it’s the behavior you want during a migration: the rule wins until you remove it.
What it won’t do
Three boundaries worth knowing before you commit to the approach:
- No rewrites for status codes other than 200. You can proxy a relative URL with a
200code (the URL stays, the content comes from elsewhere on your site), but general rewrites à la nginx aren’t here. For a static site I’d treat even the 200 proxy with suspicion — two URLs serving identical content is a duplicate-content problem you then have to patch with canonicals. - Rules don’t apply to routes served by Worker code. If your project has a script handling
some routes,
_redirectsonly governs the static-asset side. Redirects for scripted routes belong in the script. - Fragments don’t participate.
#sectionnever reaches the server, so rules can’t match on it. Browsers generally carry the fragment through the redirect on their own.
None of these has mattered for this site. The whole deployment remains what it was before the
redirects existed: a dist/ folder and a dozen lines of wrangler
config, with _headers carrying the
CSP and _redirects carrying the history.
Verifying it worked
Trust curl, not your browser — browsers cache 301s aggressively, and a stale cache will
happily show you yesterday’s broken behavior:
curl -sI https://astro.p4ni.com/tags/satori/ | head -3
HTTP/2 301
location: /tags/seo/
Check both slash forms, check a URL that shouldn’t redirect, and if you’re mid-migration,
run your old sitemap’s URLs through a loop and grep for anything that answers 404. Five
minutes of curl beats a month of silently bleeding link equity.
The SEO half of the job
The file is the mechanism; the redirect map is the actual work. What I’d keep in mind while drawing it:
- Redirect to the closest living equivalent, not the homepage. A pile of URLs all pointing
at
/is treated by Google as a soft 404 — the link equity you were trying to preserve evaporates anyway. My deleted tag pages each went to the surviving tag that covers the same articles, which is as close as an equivalent gets. - One hop. If
/amoved to/band later/bmoved to/c, update the first rule to point straight at/c. Chains get followed (up to a point), but every hop costs latency and crawl budget. - Update your own internal links too. A redirect covers the URLs you can’t reach — other people’s links, old indexes. Your own pages shouldn’t be relying on it; grep the codebase for the old paths and fix them at the source.
- Leave the rules in place. A 301 isn’t done in a week. External links never get updated, and crawlers revisit old URLs for months. The rules cost nothing to keep — mine are staying indefinitely, with a comment noting why they exist.
Deleting pages felt risky enough that I put it off for a week; the actual fix was a dozen redirect rules and one deploy. If your static site is on Workers and you’ve been avoiding a cleanup because “static hosting can’t redirect” — it can, and it’s this.