Add navigation across your pages
Give the styled bare host a header menu that builds itself from the content pipeline and highlights the current page.
By the end of this tutorial the styled site from Style the site with MonorailCSS has a navigation menu in its header. The menu links every page on the site, builds itself from the Content/ folder — so adding a markdown file adds a menu entry — and renders the current page in bold.
This is the last step of the getting-started arc. After it, a bare AddPennington host serves a complete, styled, navigable multi-page site — no template involved.
Prerequisites
- .NET 10 SDK installed
- Completed Style the site with MonorailCSS — this tutorial extends that project's
MainLayout.razorandContent/folder
The finished code for this tutorial lives in examples/GettingStartedNavigationExample.
1. Add pages to navigate to
The styling site has a single home page. A menu needs somewhere to point, so let's add three more pages — two inside a guides/ folder, one at the top level. A folder with no index.md becomes a section node in the tree, so guides/ will render as a labeled Guides group with its two pages nested under it.
Create the guides/ folder with two pages
Add Content/guides/installation.md and Content/guides/deployment.md. The order: front-matter key sets each page's position in its section — lower sorts first.
---
title: Installation
description: A page inside the guides section.
order: 10
---
This page lives at `Content/guides/installation.md`. Its URL is `/guides/installation/`,
so `NavigationBuilder` places it under a **Guides** section in the menu.
Its `order:` of `10` sorts it ahead of the Deployment page within that section.
---
title: Deployment
description: Another page inside the guides section.
order: 20
---
This page also lives under `Content/guides/`, so it joins the Installation page
under the **Guides** section. Its `order:` of `20` places it second.
Open the menu and notice that the entry for the page you are on renders bold —
`NavMenu.razor` reads the `IsSelected` flag `NavigationBuilder` stamps onto the
node matching the current URL.
Create a top-level page
Add Content/about.md directly under the content root — not in a folder — so it becomes a top-level menu entry rather than part of a section.
---
title: About
description: A top-level page outside any section.
order: 30
---
This page lives at `Content/about.md` — directly under the content root, not in
a folder — so it appears as a top-level menu entry rather than inside a section.
Its `order:` of `30` is the highest on the site, so it sorts last.
Checkpoint
dotnet run --urls http://localhost:5000, then visithttp://localhost:5000/guides/installation/andhttp://localhost:5000/about/- Both pages render through the styled layout — but there is still no menu, so the only way to reach them is by typing the URL
2. Build the navigation menu
AddPennington already registers NavigationBuilder — the service that turns content into a navigation tree — so the menu needs no new wiring in Program.cs, only a component to render it.
Add the Pennington.Navigation namespace to _Imports.razor
NavMenu.razor uses types from Pennington.Navigation, so add that namespace to the project's _Imports.razor:
@using Pennington.Navigation
<NavMenu /> is referenced by its short tag name in MainLayout.razor, which resolves only when the layout folder's namespace (<RootNamespace>.Components.Layout) is in scope. The styling tutorial already added that line when it moved the shell into MainLayout.razor, so it is in place — an unresolved component tag is a build warning, not an error, and <NavMenu /> would silently render as raw markup without it.
Create Components/Layout/NavMenu.razor
This component renders the menu from the content pipeline:
@* NavMenu.razor — the site's navigation, built from the content pipeline.
Every IContentService exposes its pages as flat table-of-contents entries.
NavigationBuilder sorts those entries by their `order:` front matter and
nests them by folder into a tree of NavigationTreeItem. This component
renders that tree and marks the entry matching the current URL. *@
@inject IEnumerable<IContentService> ContentServices
@inject NavigationBuilder Navigation
@inject NavigationManager NavManager
<nav class="mt-3 flex flex-wrap items-center gap-x-5 gap-y-1 text-sm">
@foreach (var item in _tree)
{
if (item.Children.Count == 0)
{
<a class="@LinkClass(item)" href="@item.Route.CanonicalPath.Value">@item.Title</a>
}
else
{
@* A folder with no index page becomes a section: a label, then its pages. *@
<span class="font-semibold text-base-400">@item.Title</span>
foreach (var child in item.Children)
{
<a class="@LinkClass(child)" href="@child.Route.CanonicalPath.Value">@child.Title</a>
}
}
}
</nav>
@code {
private IReadOnlyList<NavigationTreeItem> _tree = [];
protected override async Task OnInitializedAsync()
{
// Collect the table-of-contents entries from every content source.
var entries = await ContentServices.CollectTocEntriesAsync();
// Passing the current URL lets NavigationBuilder return the matching
// node with IsSelected already set.
var currentPath = new UrlPath(NavManager.ToBaseRelativePath(NavManager.Uri))
.EnsureLeadingSlash();
_tree = await Navigation.BuildTreeAsync(entries, currentPath);
}
// The current page renders bold; the rest are muted links.
private static string LinkClass(NavigationTreeItem item) => item.IsSelected
? "font-semibold text-primary-700"
: "text-base-600 hover:text-primary-700";
}
CollectTocEntriesAsync gathers a flat list — one ContentTocItem per page — from every content source. BuildTreeAsync is what gives it shape: it sorts entries by their order: value and nests them by folder, so Content/guides/ becomes a Guides section. Passing the current URL makes the matching node come back with IsSelected set. For the full picture of how the tree is folded together, see Navigation-tree construction.
3. Wire the menu into the layout
Drop <NavMenu /> into the header of MainLayout.razor. Because the layout wraps every routed page, the menu then appears site-wide.
@* Styled shell from the styling tutorial, now with a navigation menu in the
header. <NavMenu /> builds its links from the content pipeline, so adding a
markdown file to Content/ adds a menu entry — no edit to this file. *@
@inherits LayoutComponentBase
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/styles.css" />
<HeadOutlet />
</head>
<body class="bg-base-50 text-base-900 min-h-screen">
<div class="max-w-3xl mx-auto px-6 py-10">
<header class="mb-8 border-b border-base-200 pb-4">
<a class="text-lg font-bold text-primary-700" href="/">My Pennington Site</a>
<NavMenu />
</header>
<article class="prose">
@Body
</article>
<footer class="mt-12 pt-4 border-t border-base-200 text-xs text-base-500">
Built on a bare Pennington host.
</footer>
</div>
</body>
</html>
Checkpoint
Run dotnet run --urls http://localhost:5000 and open http://localhost:5000/.
- The header shows a menu: Welcome, a Guides group containing Installation and Deployment, then About
- The entry for the page you are viewing renders bold — click into
/guides/deployment/and the highlight follows - Add a new markdown file under
Content/and reload — a menu entry appears for it with no edit toNavMenu.razororMainLayout.razor
Summary
NavigationBuilderships withAddPennington;NavMenu.razoris the only new code, andProgram.csdid not change.NavMenu.razorcollects each source's table-of-contents entries andNavigationBuilder.BuildTreeAsyncturns that flat list into a sorted, folder-nested tree.- The bare host now serves a complete site: a content pipeline, a styled layout, and navigation — all on
AddPennington.
That is the whole getting-started arc. AddPennington gives you the lower-level host: you wire the pipeline, the layout, and the navigation yourself, and you have now done each part. The DocSite and BlogSite templates package this wiring for documentation and blog sites. The beyond-basics tutorials build on the host you just finished.