Scenario
A small Astro blog moves from a traditional host to Cloudflare Workers Static Assets. astro.config.mjs sets trailingSlash: "never", so every canonical tag and sitemap entry is slashless: https://example.com/about, https://example.com/blog/first-post. The build uses Astro's default directory format, and wrangler.toml points an [assets] block at ./dist with not_found_handling = "404-page".
wrangler deploy succeeds. Then two reports arrive. First, Search Console flags /about as "Page with redirect": every request answers 307 Temporary Redirect with Location: /about/, so the canonical URL points at a redirect. Second, a mistyped link to /blog/nope shows the plain site-wide 404 page instead of the blog's own 404, which has a search box and a list of recent posts. Neither problem shows up in astro preview, because the Astro dev server is not Cloudflare's asset router.
The asset router answers every request in three layers, always in this order:
_redirectsis checked first. A matching rule wins even when a real file exists at that path.html_handlingmaps the path to an HTML file and decides whether the URL should gain or lose a trailing slash.not_found_handlingruns only when nothing above matched.
Both reports come from layers 2 and 3. The goal is to see which probe produced each response, using the real file list, before the next deploy.
Broken input
# astro.config.mjs
trailingSlash: "never" # canonical URLs have no trailing slash
build.format: "directory" # the default: about.astro -> about/index.html
# wrangler.toml
[assets]
directory = "./dist"
not_found_handling = "404-page"
# html_handling is not set, so it defaults to "auto-trailing-slash"
# find dist -type f | sed 's#^dist##'
/index.html
/about/index.html
/blog/index.html
/blog/first-post/index.html
/blog/404/index.html # from src/pages/blog/404.astro
/404.html # from src/pages/404.astro
/_astro/app.D4e5f6g7.js
/_redirects
/_headers
# dist/_redirects
/posts/:slug /blog/:slug 301
/old-about /about 301
/feed.xml /rss.xml
/legacy /blog 999
# Observed in production
GET /about -> 307 Location: /about/
GET /blog/nope -> 404 (generic site 404, not the blog one)
GET /old-about/ -> 404
Astro treats only the root src/pages/404.astro as a special 404 file. A nested src/pages/blog/404.astro is an ordinary page, so the directory format writes it to /blog/404/index.html, not /blog/404.html.
Goal
Reproduce all three responses in Cloudflare Workers Assets Routing Simulator, read the trace to find which layer answered and which file probe hit or missed, and pick the wrangler.toml, build, and _redirects changes that produce slashless 200s, the section-level 404 page, and redirects that match real inbound links.
Tool sequence
- Run
find dist -type f | sed 's#^dist##'afterastro buildand paste the output into the file list. Thesedpart strips thedistprefix so every line starts with/, the way the asset router sees paths. Pastedist/_redirectsanddist/_headersinto their own boxes. Setnot_found_handlingto 404-page and leavehtml_handlingon its default, auto-trailing-slash. - Request
/about. The result is307withLocation: /about/, and the trace shows the reason:/about✗,/about.html✗, then/about/index.html✓. Underauto-trailing-slash, a page stored asindex.htmlinside a folder is served at the folder URL, the one that ends in/, and every other spelling of that URL is redirected there. That is consistent with how the files are laid out. It conflicts with the slashless canonical URLs, though. - Switch
html_handlingto drop-trailing-slash and request/aboutagain. It is now200from/about/index.html, and/about/returns307to/about. The other two modes are worth one look each: force-trailing-slash sends every HTML page to its slash form, including pages stored asabout.html, and none serves files only at their exact path, so/aboutbecomes a 404 because no file is literally namedabout. Pick the mode that matches the URL style your canonical tags and sitemap already use. - Request
/blog/nope. The trace shows how404-pagelooks for a 404 page: after the page probes miss, it checks/blog/404.html✗ and then/404.html✓. It looks only for a file named exactly404.htmlin the requested path's own directory and then in each parent directory up to the root./blog/404/index.htmlis never checked. Edit the file list so the blog 404 sits at/blog/404.html, the place the router looks, and the same request serves/blog/404.htmlwith status404. In the real project, add a post-build step such asmv dist/blog/404/index.html dist/blog/404.html. - Set
not_found_handlingto single-page-application briefly and request/blog/nopeagain:200, served from/index.html. Use this mode for client-routed apps only. For a multi-page site it hides every broken link behind a success code. Set it back to 404-page. - Read the
_redirectswarnings./legacy /blog 999is rejected with Cloudflare's own message: valid codes are200,301,302,303,307, and308. Deploys don't fail on rejected lines. Wrangler only prints a warning, and the rule is missing in production. - Request
/old-about/, with a trailing slash, the form the old host linked to. It returns404, because a rule without*or:namemust match the path exactly, and/old-aboutis not/old-about/. Add a second line for the slash form, or use/old-about/* /about 301if deeper paths moved too. A*splat matches the rest of the path across segments, available in the target as:splat. A:nameplaceholder such as:slugmatches exactly one segment. - Check the rule counts.
/old-aboutand/feed.xmllook static, but they come after/posts/:slug, so Cloudflare counts them as dynamic rules. A rule counts toward the 2,000-rule static limit only if it appears before the first*or:placeholderrule. After that, every rule counts toward the 100-rule dynamic limit. Once a file goes past 100 dynamic rules, Cloudflare drops the rest of the file. Move the plain one-to-one redirects to the top. - Request
/_astro/app.D4e5f6g7.jsto check that_headersapplies a long-livedCache-Controlheader to hashed build assets, and request/old-about?ref=xto see that the query string carries over to theLocationheader.
Checkpoint output
# wrangler.toml after the fix
[assets]
directory = "./dist"
html_handling = "drop-trailing-slash"
not_found_handling = "404-page"
# dist/_redirects after the fix (static rules first)
/old-about /about 301
/old-about/ /about 301
/feed.xml /rss.xml 301
/legacy /blog 301
/posts/:slug /blog/:slug 301
GET /about 200 served /about/index.html
GET /about/ 307 Location: /about
GET /blog/nope 404 served /blog/404.html probes: /blog/404.html ✓
GET /old-about/ 301 Location: /about _redirects line 2 (static)
GET /old-about?ref=x 301 Location: /about?ref=x _redirects line 1 (static)
GET /posts/hello 301 Location: /blog/hello _redirects line 5 (dynamic)
Rules: 4 / 2000 static, 1 / 100 dynamic, 0 invalid
Every canonical URL now returns 200 directly. Unknown blog URLs get the blog's 404 page with a real 404 status, and every _redirects line parses.
Common mistakes
- Mismatched trailing slashes and canonical URLs. If Astro, Next.js, or Hugo emits slashless canonical tags while
html_handlingis left onauto-trailing-slashwith a directory build, every canonical URL answers with a307. Search engines then see a redirect where they expect a page. Sethtml_handlingto the URL style you publish, not the other way around. - 404.html at the wrong depth or under the wrong name.
404-pagechecks only files named exactly404.html, starting in the requested path's directory and moving up one directory at a time. A file at/blog/404/index.html,/blog/not-found.html, or/404/index.htmlis never picked. If no404.htmlis found at any level, the visitor gets an empty404. - SPA mode on a site that isn't an SPA.
single-page-applicationreturns/index.htmlwith status200for every unknown path. Broken links stop showing up in crawlers, uptime monitors, and analytics, and search engines may report soft 404s. - Assuming the file wins over a redirect.
_redirectsis evaluated before any file lookup, so a stale rule for a path you later published as a real page keeps redirecting. - Trusting that every
_redirectsline made it. Wrong token counts, unsupported status codes, duplicate source paths, and the loop-causing/* /index.htmlrule are skipped with a warning while the deploy succeeds. For SPA fallback, usenot_found_handling = "single-page-application"instead of/* /index.html. - Mixing static and dynamic rules at random. Each
*or:placeholderline moves every later line into the 100-rule dynamic quota. Past 100, the rest of the file is ignored. Keep exact-path redirects at the top.
Related tools and lessons
- URL Parser / Inspector - Split a reported URL into path, query, and fragment before deciding which
_redirectsrule should match it. - Regex Tester - A
*splat behaves like.*anchored to the whole path and a:nameplaceholder like[^/]+, so a regex check can confirm which paths a rule will catch. - Related lesson: Inspect a URL Before Debugging a Routing Bug
- Related lesson: Build a Regex That Catches the Right Inputs