Create your first Pennington site
By the end of this tutorial a runnable ASP.NET project — MyFirstPenningtonSite — serves Content/index.md as HTML at http://localhost:5000/, with the front-matter title appearing in both the <title> tag and the page's <h1>.
The tutorial covers how to wire AddPennington, UsePennington, and RunOrBuildAsync into a minimal web host — the same foundation underneath every Pennington-powered site, whether DocSite, BlogSite, or hand-rolled. For when a bundled template is the faster path instead, Positioning DocSite as a fast path walks through the tradeoffs before you pick.
Prerequisites
Pennington targets .NET 11 with C# 15 union types. On .NET 10 the build reports language-version errors, so use the .NET 11 SDK before starting.
- .NET 11 SDK installed (preview build —
dotnet --versionreports11.0.*) - A terminal and a text editor or IDE that understands C# 15
The finished code for this tutorial lives in examples/GettingStartedMinimalSiteExample.
1. Scaffold a bare ASP.NET host
First, let's create the project shell Pennington will plug into — no Pennington code yet, a plain web app that returns a string, so the changes in step 2 stand out.
- 1
Create the web project
Run these two commands in a working folder. The
webtemplate produces a minimal top-level-statementProgram.cs— no MVC, no Razor Pages — which is the starting shape we'll edit in the steps ahead.dotnet new web -n MyFirstPenningtonSite cd MyFirstPenningtonSite - 2
Add the Pennington package reference
Add the Pennington package so the
AddPenningtonextension method resolves. The backing example in this repo uses aProjectReference, but for a new project this one command is enough.dotnet add package PenningtonImportant
Pennington is in alpha — check NuGet for the current prerelease and pin every
Pennington.*package to that same version. - 3
Opt into C# preview language features
Pennington is built on C# 15 union types, and the samples in this tutorial use
unionpattern matching. The .NET 11 preview SDK does not enable preview language features by default, so compiling against Pennington without the opt-in produceserror CS8652: The feature 'unions' is currently in Preview and *unsupported*. Edit the csproj to add<LangVersion>preview</LangVersion>:<Project Sdk="Microsoft.NET.Sdk.Web"> <PropertyGroup> <TargetFramework>net11.0</TargetFramework> <LangVersion>preview</LangVersion> <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> </PropertyGroup> <ItemGroup> <PackageReference Include="Pennington" Version="0.1.0-alpha.0.20" /> </ItemGroup> </Project>For a multi-project host, dropping a
Directory.Build.propsat the solution root with the same<LangVersion>preview</LangVersion>property keeps every project aligned. - 4
Confirm the bare host runs
Before adding Pennington,
Program.cslooks like this — a plainWebApplicationwith a singleMapGetthat returns a string. This is the baseline we'll build on.var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); app.MapGet("/", () => "Hello from ASP.NET."); await app.RunAsync();
Checkpoint — Bare host responds
The browser shows the literal text Hello from ASP.NET. — no markdown involved at this stage. If HTML output appears instead, double-check that the running project is the bare scaffold from step 1.4, not a later stage.
- Run
dotnet runfrom the project folder. - Open
http://localhost:5000/and confirm the page body readsHello from ASP.NET. - Stop the process with
Ctrl+Cbefore continuing.
2. Register Pennington and point it at markdown
Now let's swap the pass-through string endpoint for the Pennington content pipeline: AddPennington registers the core services, AddMarkdownContent<DocFrontMatter> names the markdown folder, and the host gains a ContentRootPath it will watch for changes.
- 1
Create the Content folder and an index page
Create a
Content/folder besideProgram.cs, then addindex.mdwith the contents below. Two things are required: a YAML front-matter block with atitle:key, and a markdown body.--- title: Welcome to your first Pennington site description: The smallest Pennington host that renders a markdown page with front matter. --- # Hello from Pennington This page is a single markdown file in `Content/index.md`. Its `title` in the front matter above is what the host reads out when it renders the page. ## What just happened 1. `AddPennington` registered the content pipeline. 2. `AddMarkdownContent<DocFrontMatter>` pointed Pennington at this folder. 3. `UsePennington` wired the middleware into the request pipeline. 4. A tiny `MapGet` endpoint walks the content service, renders this file, and returns the HTML. Everything else you see in later tutorials builds on top of these four moves. - 2
Wire
AddPenningtoninProgram.csReplace the body of
Program.cswith the service-registration block below, which walks throughWebApplication.CreateBuilder→AddPennington→AddMarkdownContent<DocFrontMatter>→app.Build().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(); await app.RunAsync();ContentRootPathsets the host's base for static files; theContentPathpassed toAddMarkdownContentis where this particular markdown source reads from — both point at"Content"here.
Checkpoint — Services resolve
dotnet build succeeds with no errors. The host does not yet render the markdown page — stage 2 only registers services, so running it at this point returns a 404 for /. That's expected; hold off until step 3 adds the middleware.
- Run
dotnet buildand confirm the build succeeds with no errors. - Do not run the site yet — the middleware arrives in the next step.
3. Wire the middleware and render the page
Now we mount the middleware chain with app.UsePennington(), add a MapGet that hands each request to the content pipeline, and hand control to RunOrBuildAsync — the same host that serves live today will generate static HTML tomorrow with no code change.
- 1
Add
UsePenningtonandRunOrBuildAsyncUpdate
Program.csto match the snapshot below.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(); await app.RunOrBuildAsync(args);UsePenningtoninstalls static files, the response-processing middleware, live reload, and auto-registered endpoints like/sitemap.xml;RunOrBuildAsyncserves live when called with no args and generates static HTML when passed-- build. - 2
Add the page-rendering endpoint
The stage-3 snapshot registered services and middleware but didn't add a rendering endpoint. Here's the complete final
Program.cs. It adds aMapGetthat walks theIContentServiceset, finds the matching markdown, and returns rendered HTML.using Pennington.Content; using Pennington.FrontMatter; using Pennington.Infrastructure; using Pennington.Pipeline; using Pennington.Routing; var builder = WebApplication.CreateBuilder(args); // 1. Register the Pennington content pipeline. Point ContentRootPath at the // folder of markdown files and declare one markdown source. 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(); // 2. Wire the Pennington middleware (static files for Content/, live-reload, // response processing, and auto-registered endpoints like /sitemap.xml). app.UsePennington(); // 3. Serve any URL by walking the configured IContentService instances, parsing // the matching markdown file, and rendering it through the pipeline. This // is deliberately minimal: in later tutorials the DocSite template provides // its own Razor layout and routing. app.MapGet("/{*path}", async ( string? path, IEnumerable<IContentService> services, IContentParser parser, IContentRenderer renderer) => { var requested = new UrlPath("/" + (path ?? string.Empty).Trim('/')); 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> <article> <h1>{renderedItem.Metadata.Title}</h1> {renderedItem.Content.Html} </article> </body> </html> """; return Results.Content(html, "text/html"); } } return Results.NotFound(); }); // 4. Dev mode (`dotnet run`) serves live; build mode // (`dotnet run -- build <baseUrl> <outputDir>`) crawls the running app and // writes static HTML. Both args are optional; defaults are `/` and `output`. await app.RunOrBuildAsync(args);This
MapGetis deliberately minimal — in the DocSite and BlogSite tutorials the template ships its own Razor layout and routing, so this endpoint falls away once we move past the bare host.
Checkpoint — The page renders with its front-matter title
That's the working site. dotnet run serves live, and http://localhost:5000/ returns HTML whose <title> element and top-level <h1> both read Welcome to your first Pennington site, pulled straight from Content/index.md's front matter.
- Run
dotnet runfrom the project folder. - Open
http://localhost:5000/and confirm the page title in the browser tab readsWelcome to your first Pennington site. - View source and confirm the same string appears inside the
<title>tag and the article's<h1>.
4. Verify dev-mode hot reload
Let's confirm that UsePennington's file-watcher and live-reload WebSocket are working: restart under dotnet watch, edit the markdown file, and watch the browser reload without touching the terminal.
- 1
Run under
dotnet watchStop the previous
dotnet runwithCtrl+C, then start the watcher. Live reload is gated on theDOTNET_WATCHenvironment variable, whichdotnet watchsets automatically — no manual setup required. Leavehttp://localhost:5000/open in the browser.dotnet watch - 2
Edit the front-matter title
Open
Content/index.mdand change thetitle:value to something recognizable — for exampletitle: Hello, Pennington— then save. The browser tab updates on its own within a second. If it doesn't, hard-refresh once; stale HTML may be cached from the earlierdotnet run.
Checkpoint — Live reload fires
Without any terminal input, the browser tab updates to show the new title in both the <h1> and the tab title. The dotnet watch console logs a file-change line naming Content/index.md.
- Edit
Content/index.md'stitle:field and save. - The browser tab title and page heading update to match — no manual refresh needed.
dotnet watchlogs the change in the terminal.
Summary
- An ASP.NET host now serves a markdown page end-to-end through
AddPenningtonandUsePennington. - The content pipeline reads from a folder of markdown through
ContentRootPathplusAddMarkdownContent<DocFrontMatter>. RunOrBuildAsyncmeans the same host generates a static site ondotnet run -- buildwith no code change.- A front-matter
title:flows from YAML into the rendered<h1>, and dev-mode hot reload re-renders on save.