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
Getting Started

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 IPageResolver walkthrough. It does repeat the dotnet 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.

1

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.

bash
dotnet new web -n MyBlazorPenningtonSite
cd MyBlazorPenningtonSite
2

Add the Pennington package

Add the Pennington package so the AddPennington extension method resolves. The command writes the <PackageReference> into the project file:

bash
dotnet add package Pennington
xml
<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.

3

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.

markdown
---
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 build succeeds with no errors
  • dotnet run --urls http://localhost:5000 followed by visiting http://localhost:5000/ returns the literal text Hello World! — the bare web template's response. Pennington takes over in the next section
  • Stop the process with Ctrl+C before 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.

1

Replace Program.cs

csharp
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);
2

Add _Imports.razor at the project root

_Imports.razor provides the @using set every .razor file in the project sees.

razor
@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
3

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.

razor
@* 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>
4

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.

razor
@* 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:5000 and visit http://localhost:5000/ — the page renders Content/index.md.
  • View source. The <title> and <h1> both pull from index.md's front-matter title:.

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.

1

Add Content/about.md

Leave dotnet run going from the previous section and drop this file in.

markdown
---
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.
2

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 before app.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 through IPageResolver, and injects the rendered HTML via (MarkupString).
  • The file-path-to-URL convention from the markdown pipeline still holds — adding or renaming a .md file under Content/ is enough.