Add your first markdown page
By the end of this tutorial a site runs at http://localhost:5000 with three markdown pages (/, /about, /contact) and a nav strip that sorts itself — with no edits to Program.cs after step 1.
The tutorial covers how Pennington maps a Content/**/*.md path directly to a URL, what the title: key does for the page title and nav label, and how order: sorts siblings without any routing code.
Prerequisites
- .NET 11 SDK installed
- Completed Spin up a minimal Pennington site (or that example's Program.cs ready to reuse)
- A code editor that renders YAML front matter cleanly (VS Code, Rider, etc.)
The finished code for this tutorial lives in examples/GettingStartedFirstPageExample.
1. Write a single page with required front matter
Starting from the minimal site built in the previous tutorial, this step adds a real front-matter block and turns a single markdown file into a routed, titled page.
- 1
Drop
Content/index.mdinto the projectCreate a
Content/folder at the project root if it isn't there yet — the previous tutorial already pointedContentRootPaththere. Add a file namedindex.mdwith a YAML front-matter block between two---fences. Pennington'sFrontMatterParserreads that block into aDocFrontMatterrecord;titleis the only key required to render a page. Any markdown body works below the closing fence.--- title: Welcome description: The home page of a three-page Pennington site. --- # Welcome to the site This is the home page. It lives at `Content/index.md`, so Pennington maps it to the site root `/`. The `title:` key in the YAML block above becomes the page title and the default navigation label. Pick any of the links in the nav strip to jump to another page. As you add more markdown files under `Content/`, they show up there automatically — no router table to edit by hand.The
title:value flows to both the HTML<title>tag and the nav link label. For the full range of front-matter capability interfaces, see The front-matter capability system — for now,titleis enough. - 2
Confirm the host from the previous tutorial is unchanged
Program.cscallsAddPennington, registersAddMarkdownContent<DocFrontMatter>, appliesUsePennington, and maps every route with a singleMapGet("/{*path}", ...)that walksIContentServiceinstances. The only addition since the previous tutorial is aNavigationBuilderinjection — nothing else changes for the rest of this tutorial.var builder = WebApplication.CreateBuilder(args); builder.Services.AddPennington(penn => { penn.SiteTitle = "My First Pennington Site"; penn.ContentRootPath = "Content"; penn.AddMarkdownContent<DocFrontMatter>(md => { md.ContentPath = "Content"; md.BasePageUrl = "/"; }); }); var app = builder.Build(); app.UsePennington(); app.MapGet("/{*path}", async ( string? path, IEnumerable<IContentService> services, IContentParser parser, IContentRenderer renderer, NavigationBuilder navigation) => { var requested = new UrlPath("/" + (path ?? string.Empty).Trim('/')); var tocItems = new List<ContentTocItem>(); foreach (var service in services) { var entries = await service.GetIndexableEntriesAsync(); tocItems.AddRange(entries); } var navTree = navigation.BuildTree(tocItems); var navHtml = string.Join( "", navTree.Select(i => $"<li><a href=\"{i.Route.CanonicalPath.Value}\">{i.Title}</a></li>")); foreach (var service in services) { await foreach (var discovered in service.DiscoverAsync()) { if (!discovered.Route.CanonicalPath.Matches(requested)) continue; var parsed = await parser.ParseAsync(discovered); if (parsed is not ParsedItem parsedItem) continue; var rendered = await renderer.RenderAsync(parsedItem); if (rendered is not RenderedItem renderedItem) continue; var html = $""" <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <title>{renderedItem.Metadata.Title}</title> </head> <body> <nav><ul>{navHtml}</ul></nav> <article> <h1>{renderedItem.Metadata.Title}</h1> {renderedItem.Content.Html} </article> </body> </html> """; return Results.Content(html, "text/html"); } } return Results.NotFound(); }); await app.RunOrBuildAsync(args);Notice the
NavigationBuilder.BuildTree(tocItems)call and the string join that becomesnavHtml— that's the piece that grows in later steps without any edits. The flat join here only renders the top level; once a section gains nested children, switch to Navigation components'sTableOfContentsNavigationcomponent, which walks the fullChildrentree.
Checkpoint — A single page renders at /
- Run
dotnet runfrom the example project - Visit
http://localhost:5000/ - The page shows the heading Welcome to the site and a nav strip with one link: Welcome pointing at
/
2. Let the file path become the URL
Now let's add a second file and watch Pennington map the on-disk path straight to a route — no router-table edits required.
- 1
Add
Content/about.mdwith its own front matterCreate
about.mdin the sameContent/folder. The filename (minus.md) becomes the URL segment:about.mdserves at/about. Setorder: 20so this file sorts predictably when the third one arrives. A short body — a paragraph or two — is enough.--- title: About description: Who made this example and why. order: 20 --- # About this site This tiny site has three markdown files under `Content/`. Each one exposes a `title:` in its front matter — the only key Pennington truly requires — and each one becomes a URL built from its file path. - `Content/index.md` serves `/` - `Content/about.md` serves `/about` - `Content/contact.md` serves `/contact` No routing configuration was added to `Program.cs` between the second and third page. The content pipeline walks the folder, reads the front matter, and the navigation strip fills in on its own.Keep an eye on the
order: 20line — its role becomes apparent once the third file lands in step 3. - 2
Reload and confirm the host code is still the same
The Stage 2 host method delegates entirely to
Stage1.Run— zero code changes between steps 1 and 2. The only thing that moved was a file on disk.Stage1.Run(args)Stage2.Run(args) => Stage1.Run(args)is intentional — the point is that the host is untouched.
Checkpoint — Two pages, two nav entries, zero code edits
- With the host still running (or after a
dotnet runrestart), visithttp://localhost:5000/about - The page shows the heading About this site and a nav strip with two links: Welcome (
/) and About (/about) - Revisit
/— the same two-item nav strip appears there too
3. Watch navigation auto-assemble from a third file
With two pages confirmed, let's add a third and see both URL mapping and front-matter ordering click into place together.
- 1
Add
Content/contact.mdwithorder: 30The
order:field is how Pennington sorts siblings in the nav tree. Settingorder: 30here — higher than About'sorder: 20— places Contact after About. The rootindex.mdcarries noorder:and sorts first by convention.--- title: Contact description: How to reach the author. order: 30 --- # Get in touch You can usually find the Pennington maintainers on GitHub. Because this page has `order: 30` in its front matter, it sorts after **About** (`order: 20`) in the auto-assembled nav strip — front matter controls ordering without touching code. Try renaming this file to `reach-out.md` and reloading the browser: the URL becomes `/reach-out` the next time the dev host picks up the change.The example body invites a filename rename — that's coming in step 3.3.
- 2
Confirm the host is still unchanged in Stage 3
Stage 3 also delegates to
Stage1.Run. Three files on disk, one host method, nothing edited between any of the stages.Stage1.Run(args)The
NavigationBuilderinjected back in step 1.2 is what produces the three-item nav — it's been at work the whole time. - 3
Rename
contact.mdto see the URL follow the fileWith the host running, rename
Content/contact.mdtoContent/reach-out.md. On the next request the nav link's href becomes/reach-out— no config, no restart. This is file-path-to-URL mapping in action. Rename it back tocontact.mdbefore continuing so later tutorials match.
Checkpoint — Three pages, sorted by front matter
- Visit
/,/about, and/contactin turn — each renders its own H1 and body - The nav strip on every page lists three links in this order: Welcome, About, Contact
- Temporarily rename
contact.mdtoreach-out.mdand refresh — the nav link's href becomes/reach-out; rename it back afterward
Summary
- A Pennington-ready markdown page needs a YAML front-matter block and the required
title:key. - Any
Content/**/*.mdpath becomes a URL automatically — no route table, no registration per file. - The nav strip builds itself from the content folder, sorted by the
order:field, without changes toProgram.cs. - Adding or renaming a markdown file predictably updates the URL and nav position.