---
title: Serve markdown through Blazor Pages
description: "Stand up a Pennington site whose markdown is served through a Blazor Server `@page` catch-all — the natural shape for a real app."
canonical_url: https://usepennington.net/tutorials/getting-started/first-page/
sidecar_url: https://usepennington.net/tutorials/getting-started/first-page.md
content_hash: sha256:1fee56f91823cfeef22a0e37aefbe5c73034e489498bc6969d6191a0c7f865a6
tokens: 2823
uid: tutorials.getting-started.first-page
reading_time_minutes: 5
---

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](https://usepennington.net/tutorials/getting-started/first-site.md) — 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](https://github.com/usepennington/pennington/tree/main/examples/GettingStartedBlazorPagesExample). The DocSite template pre-wires this same Blazor shape for documentation sites — see [Scaffold a documentation site with DocSite](https://usepennington.net/tutorials/docsite/scaffold.md) 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.

 
```bash
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:

 
```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.6-alpha.0.14" /> 
  </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.

 
```markdown:symbol
---
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`.

 
**Replace `Program.cs`**

 
```csharp:symbol
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.

 
```razor:symbol
@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.

 
```razor:symbol
@* 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.

 
```razor:symbol
@* 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.

 
**Add `Content/about.md`**

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

 
```markdown:symbol
---
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 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.
 
 
[Previous
                
                Create your first Pennington site](https://usepennington.net/tutorials/getting-started/first-site.md)[Next
                    
                Style the site with MonorailCSS](https://usepennington.net/tutorials/getting-started/styling.md)