The 404 Page That Kept Disappearing

A Catch-All Route, a Framework Bug, and Caddy

14.04.2026 | 26 Shawwal 1447
6 min read

بِسْمِ ٱللَّهِ ٱلرَّحْمَـٰنِ ٱلرَّحِيمِ

What Should Have Been Simple

Every website has broken links. Pages get renamed, URLs get shared and then changed, search engines index things that no longer exist. When someone hits one of those dead ends, what they see next shapes whether they stay or leave.

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].astro

The 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 instead of a helpful page with navigation, visitors would see a silent redirect to the homepage. No explanation, no indication that anything went wrong. 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:

  1. Reserved routes (_astro/, _server_islands/, _actions/)
  2. More specific path segments beat less specific ones
  3. Static routes take precedence over dynamic routes
  4. 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 overwrites it.

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.

external link github.com/withastro/astro/issues/9103

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.

external link github.com/withastro/astro/issues/9832

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.

external link github.com/withastro/astro/issues/12175

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, but it 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 same pattern as the 404 bug: something that looks fine until you check. The kind of things that only surface when you’re paying attention to what your visitors actually experience.

What This Comes Down To

Frameworks fail silently. When they do, the quality of your response is the quality of your engineering. You can google a workaround and paste it in, or you can read the docs, understand the routing system, try five approaches, find the root cause, and then choose the simplest clean solution. The outcome looks the same: a working 404 page. The difference is whether you understand why it works and what to do when it breaks again.

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.

Aerospace engineer

Ethical entrepreneur in public

You handle your business

I handle the digital side

Work with me
  • AI with honesty
  • Private infrastructure
  • Websites that perform

Tell me about your situation:

javed@javedab.com More about me