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 under /_llms/. 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).