Serve markdown through Blazor Pages
Stand up a Pennington site whose markdown is served through a Blazor Server `@page` catch-all — the natural shape for a real app.
By the end of this tutorial a runnable ASP.NET project — MyBlazorPenningtonSite — serves markdown from Content/ through a Blazor Server @page "/{*Path}" catch-all at http://localhost:5000/. The previous tutorial used a hand-rolled MapGet; this one swaps it for the production-shape Blazor catch-all a real app stays in.
Prerequisites
- .NET 10 SDK installed
- Create your first Pennington site — this tutorial builds on its
IPageResolverwalkthrough. It does repeat thedotnet new web+ Pennington package bootstrap from scratch, so you can also start here cold if you prefer.
The finished code for this tutorial lives in examples/GettingStartedBlazorPagesExample. The DocSite template pre-wires this same Blazor shape for documentation sites — see Scaffold a documentation site with DocSite if that is exactly what you are building.
1. Set up the project shell
Start from an empty ASP.NET web project and add the Pennington package. No Pennington code yet — the shell Program.cs stays untouched until section 2.
Create the web project
Run these two commands in a working folder. The web template produces a minimal top-level-statement Program.cs that returns Hello World! — the starting shape we'll replace in the next section.
dotnet new web -n MyBlazorPenningtonSite
cd MyBlazorPenningtonSite
Add the Pennington package
Add the Pennington package so the AddPennington extension method resolves. The command writes the <PackageReference> into the project file:
dotnet add package Pennington
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Pennington" Version="0.1.2" />
</ItemGroup>
</Project>
Important
Pennington is in alpha — check NuGet for the current prerelease and pin every Pennington.* package to that same version.
Create Content/index.md
Create a Content/ folder beside Program.cs and add index.md. The catch-all you wire up in the next section serves anything under Content/ — this is the file / will resolve to.
---
title: Welcome
description: The home page of the Blazor-pages tutorial site.
---
This page is `Content/index.md`. The browser asked for `/`; the Blazor catch-all
in `Components/Pages/MarkdownPage.razor` matched, walked the configured
`IContentService` instances to find this file, ran it through the parser and
renderer, and dropped the rendered HTML into the page's `<article>` element.
Add a second markdown file under `Content/` and its file path becomes its URL —
no router-table edit required.
Checkpoint
dotnet buildsucceeds with no errorsdotnet run --urls http://localhost:5000followed by visitinghttp://localhost:5000/returns the literal textHello World!— the bare web template's response. Pennington takes over in the next section- Stop the process with
Ctrl+Cbefore continuing
2. Wire Pennington, Blazor, and the markdown page
Replace the Program.cs body with the host below, then add three Razor files: a _Imports.razor for shared @using lines, an App.razor root component that owns the document shell, and a MarkdownPage.razor catch-all that renders any URL to a markdown file. Two service registrations (AddPennington for the content pipeline, AddRazorComponents for Blazor SSR) and three middleware calls (UsePennington, UseAntiforgery, MapRazorComponents<App>()) are all the host needs.
Important
app.UsePennington() must run before app.MapRazorComponents<App>(). The Blazor catch-all @page "/{*Path}" would otherwise swallow Pennington's redirect, sitemap, and llms.txt routes.
Note
The embeds come from the finished example, so they use its root namespace GettingStartedBlazorPagesExample (in Program.cs and _Imports.razor). Swap in your own — here, MyBlazorPenningtonSite.
Replace Program.cs
using GettingStartedBlazorPagesExample.Components;
using Pennington.FrontMatter;
using Pennington.Infrastructure;
var builder = WebApplication.CreateBuilder(args);
// 1. Same Pennington wiring as the minimal-site tutorial: register the content
// pipeline and point one markdown source at Content/.
builder.Services.AddPennington(penn =>
{
penn.SiteTitle = "My First Pennington Site";
penn.ContentRootPath = "Content";
penn.AddMarkdownContent<DocFrontMatter>(md =>
{
md.ContentPath = "Content";
md.BasePageUrl = "/";
});
});
// 2. Add Blazor Server's static-rendering services. This is what unlocks
// `MapRazorComponents<App>()` below.
builder.Services.AddRazorComponents();
var app = builder.Build();
// 3. Order matters: UsePennington registers redirect routes, llms.txt, and
// sitemap endpoints. The Blazor catch-all `@page "/{*Path}"` would swallow
// those routes if MapRazorComponents ran first.
app.UsePennington();
// 4. Antiforgery middleware is required by MapRazorComponents — Blazor's
// routed components opt into the [RequireAntiforgeryToken] metadata even
// when no form ships in the page.
app.UseAntiforgery();
// 5. Hand routing to Blazor. Components/App.razor's <Router> finds the
// matching @page component (in this project: Components/Pages/MarkdownPage.razor).
app.MapRazorComponents<App>();
await app.RunOrBuildAsync(args);
Add _Imports.razor at the project root
_Imports.razor provides the @using set every .razor file in the project sees.
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Pennington.Content
@using Pennington.Pipeline
@using Pennington.Routing
@using GettingStartedBlazorPagesExample.Components
Add Components/App.razor
App.razor is the root component MapRazorComponents<App>() mounts. It owns the entire HTML document — <!DOCTYPE>, <html>, <head> (with <HeadOutlet> so each routed page's <PageTitle> flows in), and <body>. The <Router> inside <body> scans the assembly for @page components and routes each request to the matching one.
@* Root component. Owns the entire HTML document — no MainLayout in this
tutorial. The Router scans this assembly for [@page] components and routes
each request to the matching one. <PageTitle> from each routed page flows
into <head> via <HeadOutlet>. *@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<HeadOutlet />
</head>
<body>
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" />
</Found>
<NotFound>
<p>Not found.</p>
</NotFound>
</Router>
</body>
</html>
Add Components/Pages/MarkdownPage.razor
MarkdownPage.razor is the @page "/{*Path}" catch-all. Blazor binds the request path to the Path parameter; the component asks IPageResolver to resolve that URL to a rendered page and injects the HTML via (MarkupString). It's the same IPageResolver the MapGet host used in the previous tutorial — only the call site has moved into a component.
@* Catch-all that matches every URL and asks IPageResolver to resolve it to a
markdown file. It's the same IPageResolver the MapGet host injected in the
previous tutorial — the only thing that's changed is where it's called from. *@
@page "/{*Path}"
@inject IPageResolver Resolver
@if (_html is not null)
{
<PageTitle>@_title</PageTitle>
<article>
<h1>@_title</h1>
@((MarkupString)_html)
</article>
}
else
{
<PageTitle>Not found</PageTitle>
<p>No content matches @Path.</p>
}
@code {
[Parameter] public string? Path { get; set; }
private string? _title;
private string? _html;
protected override async Task OnInitializedAsync()
{
var requested = new UrlPath(Path ?? string.Empty).EnsureLeadingSlash();
if (await Resolver.ResolveAsync(requested) is { } page)
{
_title = page.Metadata.Title;
_html = page.Content.Html;
}
}
}
Checkpoint
dotnet run --urls http://localhost:5000and visithttp://localhost:5000/— the page rendersContent/index.md.- View source. The
<title>and<h1>both pull fromindex.md's front-mattertitle:.
3. Add a second markdown file
The file-path-to-URL convention is unchanged by routing through Blazor. Pennington's file watcher picks up new files in Content/ while the host runs — no restart, no router-table edit.
Add Content/about.md
Leave dotnet run going from the previous section and drop this file in.
---
title: About
description: Proves that adding a markdown file is enough to expose a new URL.
---
This file is `Content/about.md` and the catch-all serves it at `/about`. The
Blazor router didn't gain a new entry — `MarkdownPage.razor` matches every URL
through `@page "/{*Path}"` and asks the content pipeline whether anything on
disk corresponds to the requested path.
Rename this file to `reach-out.md` and `/reach-out` works on the next request.
The only thing routing the URL is the file's name.
Navigate to /about
Open http://localhost:5000/about in the browser. The catch-all serves the new file on the first request — no restart needed.
Checkpoint
- Visit
/about— the page renders, served through the same catch-all as/.
Summary
- A Pennington host plus a Blazor Server router is two service registrations (
AddPennington,AddRazorComponents) and three middleware calls (UsePennington,UseAntiforgery,MapRazorComponents<App>()). app.UsePennington()must run beforeapp.MapRazorComponents<App>()— the catch-all would otherwise swallow Pennington's redirect, sitemap, and llms.txt routes.- A single
@page "/{*Path}"component (MarkdownPage.razor) handles every URL, resolves it throughIPageResolver, and injects the rendered HTML via(MarkupString). - The file-path-to-URL convention from the markdown pipeline still holds — adding or renaming a
.mdfile underContent/is enough.