This documentation is also published as Markdown for efficient machine reading: the whole site is indexed at /llms.txt, and every page has a clean Markdown copy at the same URL with .md appended. These are generated from the same source and cost far fewer tokens to read than this rendered HTML.

Skip to main content Skip to navigation
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
---
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.

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
@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 serves a root 404.html automatically.
  • Other managed hosts (Netlify, Cloudflare Pages, Azure Static Web Apps) need a fallback rule in their config.
  • Nginx or IIS 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).