A Catch-All Route, a Framework Bug, and Caddy
بِسْمِ ٱللَّهِ ٱلرَّحْمَـٰنِ ٱلرَّحِيمِ
What Should Have Been Simple
Adding a custom 404 page to an Astro site sounds like a five-minute task. The docs say: create src/pages/404.astro, it builds to dist/404.html, your hosting provider serves it. Done.
Except it wasn’t done. The page built without errors, no warnings, no conflict messages — but the output was wrong. Instead of the custom 404 page, dist/404.html contained a redirect to the homepage. The 404 page was being overwritten, silently.
This is the story of figuring out why and deciding what to do about it.
The Setup
This site runs on Astro 6.x, deployed as a static build to a server running Caddy. It’s multilingual — English, German, Spanish, French, Chinese. The landing page uses a catch-all rest parameter route at the root:
src/pages/[...index].astroThe getStaticPaths function returns language codes as params — undefined for English (which generates /), de for German (/de), and so on. If a visitor hits a path that doesn’t match any content entry, the catch-all redirects to the homepage.
The rest parameter is structurally required — undefined is a rest-parameter-only feature in Astro, and it’s the only way to serve English at / without a language prefix. A named [index] parameter can’t do this. So the catch-all isn’t a design choice that can be swapped out — it’s load-bearing.
This worked fine for the site. Then I wanted to add a custom 404 page.
The Problem
I created src/pages/404.astro — a clean page with the site logo, a short message, and two cards pointing to the main content sections. Built the site. Checked dist/404.html.
The file existed, but the content was this:
<!doctype html><title>Redirecting to: /</title><meta http-equiv=refresh content="2;url=/"><a href=/>Redirecting from /404/ to /</a>That’s not my 404 page. That’s the catch-all route’s redirect. Astro’s [...index].astro intercepted /404 during the build, found no matching content entry, hit the fallback Astro.redirect("/"), and wrote that redirect to dist/404.html — overwriting whatever 404.astro would have produced.
No errors. No warnings. No indication that anything went wrong. The build reported 404.html (+24ms) as if everything was fine.
What the Docs Say
Astro’s routing priority order is clear:
- Reserved routes (
_astro/,_server_islands/,_actions/) - More specific path segments beat less specific ones
- Static routes take precedence over dynamic routes
- Named parameters beat rest parameters
By these rules, 404.astro (a static route) should win over [...index].astro (a rest parameter route). It doesn’t. The file gets generated one on top of the other — the catch-all writes last and wins.
Everything That Didn’t Work
Every obvious fix, and some less obvious ones.
Returning a 404 Response from the catch-all:
if (index === "404") { return new Response(null, { status: 404 });}Result: Astro skipped creating dist/404.html entirely. The file didn’t exist at all.
Using Astro.rewrite("/404"):
if (!entry?.data?.file_name) { return Astro.rewrite("/404");}Result: loop detected. The rewrite went through [...index].astro again.
Setting prerenderConflictBehavior: 'error' in the Astro config:
Result: no error reported. Astro doesn’t consider this a conflict.
Moving 404.astro to a subdirectory (src/pages/_error/404.astro):
Result: Astro ignores directories starting with underscores.
Renaming [...index].astro to [index].astro:
This would have removed the catch-all behavior. But [...index].astro returns { params: { index: undefined } } for English to generate the root /. The Astro docs state that undefined params are a rest-parameter-only feature. Switching to a named [index] parameter would break the homepage.
The GitHub Issues
This isn’t a new problem. It’s been reported and acknowledged by Astro core team members:
Issue #9103 — „static route in subfolder gets overridden by Rest-parameter.“ An Astro core team member confirmed: „the priority is correct, it even works correctly on the dev server, but the file gets generated one on top of the other.“ Filed in January 2024, the underlying problem persists as of April 2026.
Issue #9832 — „Prerender page conflicts are silently ignored.“ Filed by the same core team member who diagnosed #9103. The issue was assigned, closed, but the fix was never comprehensive enough to cover all cases.
Issue #12175 — „Custom 404 pages in localized sites.“ Still open. An Astro maintainer closed it once saying „this is fixed,“ then couldn’t remember why when it was reopened.
The root cause is in how Astro’s static build generates files: routes are built sequentially, and when the catch-all generates a path that matches an already-written file, it overwrites it. The routing priority is correct at the resolution level — it just isn’t enforced at the file-writing level.
Two Solutions
Once you understand the bug, there are two clean workarounds:
Option A: Post-build copy. Keep 404.astro but name it something else (like not-found.astro). It builds to dist/not-found/index.html without conflict. Then a post-build script copies it to dist/404.html. You get the full Astro pipeline — fonts, theme system, shared components.
Option B: Standalone HTML. Put a self-contained 404.html in the Public/ directory. Astro copies it to dist/404.html as a static asset, completely bypassing the routing system. No conflict possible.
Why Standalone HTML Won
Option B has no moving parts. No post-build script to maintain, no rename trick to document, no coupling to a bug that might get fixed someday (which would require cleaning up the workaround).
The trade-off: no Astro font pipeline, no light/dark theme toggle, no shared components. The page uses system font fallbacks and hardcoded dark theme CSS variables. For a page with two lines of text and two navigation cards, that’s a trade-off worth making.
If Astro fixes the underlying bug, moving to src/pages/404.astro will inshallah be a one-step change. Until then, the simpler solution is the better one.
The Caddy Side
The 404 page needs the web server to actually serve it. In Caddy, this is one directive:
handle_errors { rewrite * /404.html file_server}When file_server returns a 404 (file not found), handle_errors catches it, rewrites the request to /404.html, and serves the custom page. No backend needed, no additional configuration.
This was also the session where I discovered that Caddy’s default config has no Cache-Control headers for favicons — which is why Chromium browsers were showing stale or missing favicons after a server migration. A one-line header directive fixed that:
header /favicon.svg Cache-Control "max-age=3600, must-revalidate"Small things, but the kind of things that only surface when you’re debugging something else.
What This Comes Down To
Frameworks have bugs. When you find one, you have a choice: fight the framework to make it do what the docs promise, or work around it cleanly and move on. The investigation matters — you need to understand the problem well enough to know you’re not just masking it. But once you understand it, the simplest solution that actually works is usually the right one.
The 404 page works. Visitors who hit a dead link see a clean page with a way forward. The code is one HTML file with no dependencies. And if Astro fixes the bug, the upgrade path is straightforward.
That’s inshallah good enough.
Luft- und Raumfahrtingenieur
Ethischer Unternehmer in der Öffentlichkeit
Sie kümmern sich um Ihr Geschäft
Ich kümmere mich um die digitale Seite
Zusammenarbeiten ↓
- KI mit Ehrlichkeit
- Private Infrastruktur
- Websites die performen
Erzählen Sie mir von Ihrer Situation:
javed@javedab.com Mehr über mich