---
title: Provide a 404 page
description: Author the not-found body with a content-root 404.md (or a NotFound.razor component); the static build writes it to output/404.html for your host to serve.
canonical_url: https://usepennington.net/how-to/pages/not-found-page/
sidecar_url: https://usepennington.net/how-to/pages/not-found-page.md
content_hash: sha256:61ff9260a3395cb3281eb7fa62b4862524aa6419940c32813836074a117f5dc2
tokens: 1154
uid: how-to.pages.not-found
reading_time_minutes: 3
---

Guides
# Provide a 404 page

Author the not-found body with a content-root 404.md (or a NotFound.razor component); the static build writes it to output/404.html for your host to serve.

 
A static host serves a single `404.html` for any URL it can't find. You supply that page's body with one file — no server-side code, no error-handling route. Pennington's build renders it and writes `output/404.html` for you.

 
## Add a `404.md`

 
Drop a `404.md` at your content root. Give it a title and a short body, and point readers somewhere useful.

 
```markdown:symbol
---
title: Page not found
description: The page you were looking for doesn't exist.
---
  
We couldn't find that page. It may have moved, or the link that brought you here is out of date.
  
Head back to the [home page](/) to pick up where you left off.
```

 
Run `dotnet run -- build` and the file lands at `output/404.html`. The file is reserved: there is no `/404/` route, and it never appears in navigation, the sitemap, search, or `llms.txt`. BlogSite works the same way — put `404.md` at the content root (outside the blog folder) and it becomes the site's not-found body.

 
> [!TIP]
> Don't gold-plate the 404. On a static host a reader reaches it only by following a dead link or mistyping a URL, and your host serves the same `404.html` for every miss. A title, a sentence, and a link home is plenty. You'll see it often in development; your readers almost never will.

 
## Why there's no `/404/` route

 
A routable `/404/` would be a valid route whose job is to announce an invalid destination, and nothing runs on a static host to choose it; instead the body renders at the catch-all and reaches readers only through your host's `404.html` mapping. For how the build materializes that file, see [Dev mode and build mode share one code path](https://usepennington.net/explanation/core/dev-vs-build.md).

 
## Use a Razor component instead

 
When you want components or richer markup, add a `NotFound.razor` (no `@page` directive). The catch-all finds it by name and renders it for any unmatched URL.

 
```razor:symbol
@namespace DocSiteChromeOverridesExample.Components
@using Microsoft.AspNetCore.Components.Web
  
@* A component named NotFound with no @page directive. DocSite's catch-all finds it by
   reflection and renders it for any unmatched URL when no Content/404.md is present —
   so it is the not-found body, never a route. Unlike ExtraPage.razor it needs no
   AdditionalRoutingAssemblies wiring; the reflection scan walks every loaded assembly. *@
  
<PageTitle>Page not found</PageTitle>
  
<article class="prose mx-auto py-12">
    <h1>This page wandered off</h1>
    <p>
        We couldn't find what you were looking for. Try the
        <a href="/">home page</a> or one of the guides in the sidebar.
    </p>
</article>
```

 
If both a `404.md` and a `NotFound.razor` exist, the markdown file wins. With neither, Pennington renders a built-in localized message, so every site still produces a valid `404.html`.

 
## Make your host serve it

 
Producing `404.html` is half the job — your host has to return it for unknown URLs. The mapping differs per host:

 
 - [GitHub Pages](https://usepennington.net/how-to/deployment/github-pages.md) serves a root `404.html` automatically.
 - [Other managed hosts](https://usepennington.net/how-to/deployment/adapt-for-other-hosts.md) (Netlify, Cloudflare Pages, Azure Static Web Apps) need a fallback rule in their config.
 - [Nginx or IIS](https://usepennington.net/how-to/deployment/self-host.md) need an `error_page` / fallback directive.
 
 
## Verify

 
 - Run `dotnet run -- build` and confirm `output/404.html` contains your content.
 - Run `dotnet run`, visit a URL that doesn't exist, and confirm you see the body with an HTTP 404 status (`curl -I http://localhost:5000/nope`).
 
 
## Related

 
 - How-to: [Build a static site](https://usepennington.net/how-to/deployment/static-build.md) — where `output/404.html` comes from in the build.
 - Background: [Dev mode and build mode share one code path](https://usepennington.net/explanation/core/dev-vs-build.md) — how the crawler materializes `404.html`.
 - Background: [The response pipeline](https://usepennington.net/explanation/core/response-processing.md) — how the rendered 404 body keeps an HTTP 404 status.
 
 
[Previous
                
                Reuse one snippet across many pages](https://usepennington.net/how-to/pages/include-shared-content.md)[Next
                    
                Annotate specific lines in a code block](https://usepennington.net/how-to/code-samples/code-annotations.md)