
How to create a custom 404 page in Astro?
To create a custom 404 page in Astro, add a file named 404.astro (or 404.md) to src/pages/. Astro builds it to dist/404.html, which most static hosts automatically serve, with a 404 status, whenever a visitor requests a URL that doesn't exist. The page is an ordinary Astro page, so it can use your layout, styles and components. On sites with on-demand rendering, the same file is used by the server for unmatched routes, and you can send visitors to it from your own code by returning a Response with a 404 status.
A good 404 page turns a dead end into a way back into your site. In this article you'll learn how to build the page, how it's served on static hosts and in local preview, how to show the requested path, how to trigger it from dynamic routes rendered on demand (including one production pitfall with Astro.rewrite() I ran into), how to add a matching 500.astro error page, and what Astro 7's src/fetch.ts changes for not-found handling.
Creating the 404 Page
The file name is the only special part. Here is a complete 404 page that uses a shared layout:
---
// src/pages/404.astro
import Base from "../layouts/Base.astro";
---
<Base title="Page not found">
<main class="not-found">
<p class="code">404</p>
<h1>We couldn't find that page</h1>
<p>
The page <code id="missing-path">you requested</code> doesn't exist
or has moved.
</p>
<ul>
<li><a href="/">Go to the home page</a></li>
<li><a href="/blog/">Browse the blog</a></li>
</ul>
</main>
<script>
const el = document.getElementById("missing-path");
if (el) el.textContent = window.location.pathname;
</script>
</Base>
<style>
.not-found {
max-width: 40rem;
margin: 4rem auto;
padding: 0 1rem;
text-align: center;
}
.code {
font-size: 4rem;
font-weight: 700;
margin: 0;
}
</style>
And the layout it uses:
---
// src/layouts/Base.astro
interface Props {
title: string;
}
const { title } = Astro.props;
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
</head>
<body>
<slot />
</body>
</html>
Running astro build produced the file you'd expect:
dist/404.html
dist/index.html
Note that the 404 page is written as 404.html, not 404/index.html, regardless of your build.format setting. That's the file name hosts look for.
Where to Put the Script
Keep the <script> inside the layout's slot, as above. Since Astro renders scripts where you write them, a script placed after the closing </Base> tag ended up after </html> in my first build. Browsers still run it, but it's invalid markup. Inside the slot, it lands in the body where it belongs.
Using Markdown Instead
If your 404 page is mostly text, 404.md works too:
---
layout: ../layouts/MarkdownPage.astro
title: Page not found
---
# We couldn't find that page
Try the [home page](/) or the [blog](/blog/).
The layout frontmatter wraps it in your site's layout. If you aren't sure how Markdown pages and layouts fit together, see how file-based routing works in Astro.
What to Put on a 404 Page
A 404 page has one job: help the visitor find what they were looking for. A few elements do most of the work:
- A clear message: say plainly that the page wasn't found. Avoid jokes that hide the meaning.
- The requested path: showing the URL helps people spot typos. A static 404 page doesn't know the requested URL at build time, so read it from
window.location.pathnamein the browser, as the example does. - Useful links: the home page, the blog index, popular sections, or a search box if you have one. Static search in Astro covers adding search to a static site.
- Your normal layout: keep the header and navigation so visitors can carry on browsing.
- A noindex hint if you like: a real 404 status already keeps the page out of search results, but some sites add
<meta name="robots" content="noindex">as an extra signal.
How the Page Is Served
In Development
The dev server renders your 404.astro for any URL that doesn't match a route, so you can design it at http://localhost:4321/anything.
In Local Preview
After building, astro preview serves the static output. Requesting a missing URL returned the custom page with the right status:
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:4326/nope
404
On Static Hosts
For a fully static site, Astro has no server in production, so it's your host that decides what to send for missing URLs. Most look for a 404.html file in the output folder:
- Netlify and GitHub Pages: serve
404.htmlfrom the site root for missing paths. - Cloudflare Pages: looks for the closest
404.html, starting in the requested directory and moving up the tree. Be careful: if there's no top-level404.html, Cloudflare assumes a single-page app and serves your home page for every unknown path instead.
Because the host serves the file directly, a fully static site can't customise the 404 per request. If you need that, render the relevant routes on demand.
404s with On-Demand Rendering
When you use an adapter, the server handles unmatched URLs itself. I built a project with the Node adapter:
// astro.config.mjs
import { defineConfig } from "astro/config";
import node from "@astrojs/node";
export default defineConfig({
adapter: node({ mode: "standalone" }),
});
With 404.astro left as a prerendered page, requesting a missing URL from the built server returned the custom page and a 404 status. You don't need to make the 404 page itself on-demand for this to work.
Returning a 404 from a Dynamic Route
On-demand dynamic routes accept any value in the URL, so you have to decide when something doesn't exist. The cleanest way is to return an empty Response with a 404 status from the page's frontmatter:
---
// src/pages/products/[id].astro
export const prerender = false;
const products: Record<string, string> = { "1": "Enamel mug", "2": "Desk lamp" };
const name = products[Astro.params.id ?? ""];
if (!name) {
return new Response(null, { status: 404 });
}
---
<h1>{name}</h1>
Astro sees the bodiless 404 response and renders your custom 404 page in its place. Testing the built server:
/products/1 -> 200 <h1>Enamel mug</h1>
/products/9 -> 404 <h1>We couldn't find that page</h1>
The visitor stays on /products/9 and sees your designed page, with the correct status for search engines. If you're new to this kind of route, see how to create dynamic routes with getStaticPaths, which also covers the on-demand version.
A Pitfall with Astro.rewrite("/404")
The Astro docs show return Astro.rewrite("/404") as a way to render the 404 page from another route. It worked in the dev server, which logged [404] (rewrite). But in my production build with the Node adapter, when 404.astro was prerendered (the default), the rewrite failed with a 500 error:
[ERROR] Error: Unexpectedly unable to find a component instance for route /404
The rewrite needs the 404 page to exist on the server, and a prerendered page only exists as a static HTML file. Adding export const prerender = false to 404.astro fixed it, and the rewrite then returned the custom page with a 404 status. Returning new Response(null, { status: 404 }) worked in both cases, which is why I use it as the default. If you prefer rewrites, make the 404 page on-demand and test the production build, not just astro dev.
Unsupported HTTP Methods
Endpoints answer only the methods they export. When I sent a POST to an endpoint that only exports GET, the server responded with the custom 404 page and a 404 status. The endpoint guide covers this in more detail: how to build API endpoints in Astro.
Adding a 500 Error Page
For pages rendered on demand, you can also create src/pages/500.astro. Astro shows it when an error is thrown during rendering and passes the error to the page as an error prop:
---
// src/pages/500.astro
import Base from "../layouts/Base.astro";
interface Props {
error: unknown;
}
const { error } = Astro.props;
console.error(error);
---
<Base title="Something went wrong">
<h1>Something went wrong</h1>
<p>Please try again in a moment.</p>
</Base>
To test it, I added a page that throws:
---
// src/pages/broken.astro
export const prerender = false;
throw new Error("Database unavailable");
---
Requesting /broken returned <h1>Something went wrong</h1> with a 500 status. A few rules apply:
- On-demand only: the 500 page is used for errors in on-demand routes. It isn't available for prerendered pages, whose errors happen at build time.
- Don't leak details: avoid printing
error.stackor raw messages, which can reveal how your server code is structured. Log them instead. - Keep it simple: if the 500 page itself throws, your host's default error page is shown.
- Development behaviour: with a
500.astropresent, runtime errors inastro devare logged to the terminal instead of appearing in the error overlay.
404 Handling and src/fetch.ts in Astro 7
Astro 7 enables advanced routing by default, which lets you replace the request pipeline with your own src/fetch.ts. If you use the all-in-one astro() handler, nothing changes: it already includes not-found handling. If you compose individual handlers from astro/fetch instead, add notFound(), available since Astro 7.4, before rendering:
// src/fetch.ts
import { FetchState, middleware, notFound, pages } from "astro/fetch";
export default {
async fetch(request: Request): Promise<Response> {
const state = new FetchState(request);
const notFoundResponse = await notFound(state);
if (notFoundResponse) return notFoundResponse;
return middleware(state, (s) => pages(s));
},
};
According to the docs, notFound() renders your custom 404 page, including a prerendered 404.astro, when no route matches, and returns undefined when a route does match so you can continue. Leave it out and unmatched requests won't get your 404 page.
I tested this composition against the built Node server. Unknown URLs got the custom 404 page, but /products/9, which returns new Response(null, { status: 404 }), came back with a 404 status and an empty body. Swapping in the full astro() handler brought the custom page back for both. So if you compose handlers yourself, check how your own empty 404 responses are rendered. Most sites don't need a custom src/fetch.ts at all; Astro middleware covers the usual request logic.
FAQ: Custom 404 Pages in Astro
Create src/pages/404.astro, or 404.md for a Markdown page. Astro builds it to 404.html in your output folder, which most hosts serve for missing URLs.
On hosts that support 404.html, such as Netlify, GitHub Pages and Cloudflare Pages, yes. With an adapter, Astro's server also sends a 404 status along with your custom page for unmatched routes.
In an on-demand route, return an empty Response with a 404 status from the frontmatter when the data doesn't exist. Astro renders your custom 404 page in its place, at the requested URL.
In my testing with the Node adapter, rewriting to a prerendered 404 page failed because the page only exists as a static file. Mark 404.astro with prerender set to false, or return a 404 Response instead.
Not at build time, because the page is generated once. Read window.location.pathname in a small client-side script and insert it into the page.
Only if you render pages on demand. A src/pages/500.astro page is shown for errors thrown while rendering on-demand routes and receives the error as a prop.
Conclusion
A custom 404 page in Astro is just src/pages/404.astro. Astro builds it to 404.html, static hosts serve it for missing URLs, and on-demand servers use it for unmatched routes. Give it your normal layout, a clear message and a few useful links, and show the requested path with a small script if you want to help visitors spot typos.
For on-demand routes, returning new Response(null, { status: 404 }) is the most reliable way to show the page from your own code, and a 500.astro page completes the picture for runtime errors. Whatever you choose, test the production build as well as the dev server, because the two don't always behave the same way.


