Scaffold a blog with BlogSite
By the end of this tutorial, a running BlogSite host titled "Scaffold Blog" serves a home listing, /archive, /blog/<slug>/, /tags/, /tags/<name>/ (plus the /topics aliases), and /rss.xml — all from a single placeholder post under Content/Blog/.
Along the way, you'll see how to swap any plain Pennington host for the BlogSite template in three calls and populate the core BlogSiteOptions surface, with a clear mental model of how ContentRootPath, BlogContentPath, BlogBaseUrl, and TagsPageUrl work together.
Prerequisites
No DocSite experience is required — BlogSite is a separate template. Before starting, gather the following:
- .NET 11 SDK installed
- Completed Create your first Pennington site
- Completed Add your first markdown page (so
Content/already has at least one markdown file)
The finished code for this tutorial lives in examples/BlogSiteScaffoldExample.
1. Start from the bare Pennington host
The host you built in the getting-started tutorials calls AddPennington, registers content with AddMarkdownContent<DocFrontMatter>, mounts UsePennington, and wires a hand-written MapGet fallback that walks IContentService to serve individual pages.
- 1
Review the pre-BlogSite host shape
Here is what that host looks like. The three moving parts are the DI registration, the
UsePenningtoncall, and the hand-rolledMapGetfallback. Notice what is absent: the home listing,/archive,/blog/<slug>pages,/tagsand/topicsaliases, the/rss.xmlfeed, and the MonorailCSS chrome. The next unit brings all of that in with a singleAddBlogSitecall.var builder = WebApplication.CreateBuilder(args); builder.Services.AddPennington(penn => { penn.SiteTitle = "Scaffold Blog"; penn.ContentRootPath = "Content"; penn.AddMarkdownContent<DocFrontMatter>(md => { md.ContentPath = "Content/Blog"; md.BasePageUrl = "/blog"; }); }); var app = builder.Build(); app.UsePennington(); 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; return Results.Content(renderedItem.Content.Html, "text/html"); } } return Results.NotFound(); }); await app.RunOrBuildAsync(args);
Checkpoint — The bare host runs
- Run
dotnet runand visithttp://localhost:5000/blog/hello-world - The page shows unstyled HTML for the markdown — no home listing, no archive, no tag pages, no RSS feed
2. Swap AddPennington for AddBlogSite
AddBlogSite is a single DI call that registers Pennington core, MonorailCSS, the Razor-component chrome (home, archive, post, and tag pages), the file-watched BlogContentResolver, and the BlogSiteContentService that yields per-tag routes and the /rss.xml feed — all driven from one options record.
- 1
Replace the registration call
AddBlogSitetakes aFunc<BlogSiteOptions>— the delegate constructs and returns a fresh options record rather than mutating one through anAction. Remove the earlierAddMarkdownContent<DocFrontMatter>call; the template registersAddMarkdownContent<BlogSiteFrontMatter>internally, and the next tutorial walks through that front-matter record.AddBlogSitealso callsAddPennington,AddMonorailCss, andAddRazorComponentsunder the hood, so a BlogSite project does not register those separately. See DI and middleware extension methods for the full signature. - 2
Populate the core
BlogSiteOptionsThe options this tutorial covers fall into three groups.
The identity trio drives metadata shared across every page and feed:
SiteTitleis the name that appears in the site header, RSS channel title, and JSON-LD;Descriptionpopulates the RSS channel description and the default meta description;CanonicalBaseUrlis the absolute origin (for examplehttps://myblog.example) used in RSS<link>elements, sitemaps, and JSON-LD@idvalues.The content-path quartet controls where posts live on disk and what URLs they produce:
ContentRootPathis the folder relative towwwrootthat contains all content (default"Content");BlogContentPathis the subfolder within that root where post files live (default"Blog", resolved againstContentRootPath);BlogBaseUrlis the route prefix for individual post pages (default"/blog");TagsPageUrlis the base route for the tag listing and per-tag pages (default"/tags").AuthorNameandAuthorBioprovide site-wide author defaults. They populate the RSS channel, JSON-LD article markup, and any post that omits its ownauthor:front-matter field.The full options surface — including the homepage-specific knobs
HeroContent,MyWork,Socials, andMainSiteLinks— is covered in Pennington.BlogSite.BlogSiteOptions. Those knobs are skipped here and introduced in the third tutorial of this section.
Checkpoint — Services registered, middleware not yet mounted
dotnet buildsucceedsdotnet runstarts the host, but/still returns whatever the pre-BlogSite pipeline produced — BlogSite services sit in DI while the middleware and endpoints remain unmounted
3. Mount UseBlogSite and swap RunBlogSiteAsync
UseBlogSite is the middleware counterpart to AddBlogSite — one call mounts antiforgery, static files, MonorailCSS, core Pennington middleware, and Razor-component routing for Home, Archive, Blog, Tag, and Tags in the correct order; when EnableRss is true (the default) it also maps /rss.xml.
- 1
Call
UseBlogSiteafterBuild()This single call replaces both the
UsePenningtonline and the hand-writtenMapGetfallback from stage 1. After it runs, the BlogSite Razor components own/,/archive,/blog/{*fileName},/tags,/tags/{TagEncodedName}, and the/topicsaliases, withBlogContentResolverhandling per-request rendering.app.UseBlogSite(); - 2
Swap
RunAsyncforRunBlogSiteAsyncRunBlogSiteAsyncdelegates toRunOrBuildAsync, so the same host serves live in development and generates static HTML when invoked asdotnet run -- build <baseUrl> <outputDir>. Both positional arguments are optional and default to/andoutputrespectively. For the full explanation of how unified dev and build paths work, see Dev mode and build mode share one code path.await app.RunBlogSiteAsync(args); - 3
See the fully-wired host
Here is the complete
Program.csafter the swap. Three calls replace the entire stage-1 setup — the diff says the rest.var builder = WebApplication.CreateBuilder(args); builder.Services.AddBlogSite(() => new BlogSiteOptions { SiteTitle = "Scaffold Blog", Description = "A minimal BlogSite scaffold showing AddBlogSite, UseBlogSite, and RunBlogSiteAsync.", CanonicalBaseUrl = "https://example.com", ContentRootPath = "Content", BlogContentPath = "Blog", BlogBaseUrl = "/blog", TagsPageUrl = "/tags", AuthorName = "Author Name", AuthorBio = "Writing about software, tools, and the occasional side project.", }); var app = builder.Build(); app.UseBlogSite(); await app.RunBlogSiteAsync(args);
Checkpoint — Full chrome renders
- Run
dotnet runand visithttp://localhost:5000/ - The BlogSite home layout appears: site title "Scaffold Blog", a recent-posts list with one entry, header chrome, and MonorailCSS styling
4. Drop in a placeholder post and verify every built-in route
Posts live under {ContentRootPath}/{BlogContentPath} — with the defaults from step 2, that is Content/Blog/. A single placeholder post here keeps the home listing, archive, and RSS feed non-empty until the next tutorial introduces the full BlogSiteFrontMatter surface.
- 1
Create
Content/Blog/hello-world.mdThe placeholder post uses four front-matter keys:
title,description,date, andauthor. These are the minimum the home listing and RSS feed need to render an entry. The next tutorial expands this to the fullBlogSiteFrontMattersurface, addingtags,series,repository,section, andredirectUrl.--- title: Hello world description: A placeholder post so the scaffold has something to render. Tutorial 1.3.20 teaches the real BlogSiteFrontMatter fields. date: 2026-04-13 author: Author Name --- # Hello world This post exists so the bare BlogSite scaffold has at least one entry on the home page and in the RSS feed. The next tutorial, **Author your first post with `BlogSiteFrontMatter`**, walks through the full set of post front-matter fields (tags, series, repository, section, redirectUrl, and more). - 2
Walk the built-in routes
Visit each URL in order and confirm the placeholder post's metadata appears on every page:
/— home listing withhello-worldas the only recent post/archive— full archive (one entry)/blog/hello-world— the post itself, rendered through the BlogSite post template/tags— empty tag list (placeholder post has no tags; the next tutorial adds them)/rss.xml— RSS 2.0 feed with one<item>carrying the post title, link, description, pub date, and author/topicsand/topics/<name>— aliases for/tagsand/tags/<name>(confirm one loads)
Checkpoint — Every built-in route responds
- Each URL above returns 200 and renders the placeholder post's metadata
/rss.xmlreturnsapplication/rss+xmlcontent with one item whose<guid>matches the canonical post URL
Summary
- The bare
AddPenningtonhost gave way toAddBlogSite+UseBlogSite+RunBlogSiteAsync, and the full BlogSite chrome now renders. - The core
BlogSiteOptionssurface —SiteTitle,Description,CanonicalBaseUrl,ContentRootPath,BlogContentPath,BlogBaseUrl,TagsPageUrl,AuthorName,AuthorBio— is populated, and each field flows through to the rendered output. - BlogSite binds posts through
AddMarkdownContent<BlogSiteFrontMatter>and defaults content paths toContent/Blogserved at/blog, which distinguishes it from theDocSitetemplate's area-driven layout. - Every built-in route the template ships responds:
/,/archive,/blog/<slug>,/tags(and/topicsaliases),/tags/<name>, and/rss.xml.