p4ni.

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 200 code (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, _redirects only governs the static-asset side. Redirects for scripted routes belong in the script.
  • Fragments don’t participate. #section never 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 /a moved to /b and later /b moved 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.