Style the site with MonorailCSS
Layer MonorailCSS onto the Blazor-pages site through a routed `MainLayout.razor` and watch the stylesheet regenerate as new utility classes appear in the source.
By the end of this tutorial the Blazor-pages site from Serve markdown through a Blazor catch-all is styled with MonorailCSS — a Tailwind-compatible JIT compiler in pure .NET. Every routed @page renders through a MainLayout.razor that carries the utility classes. The MonorailCSS Discovery pipeline turns those classes into real CSS rules, served at /styles.css. The stylesheet regenerates whenever a new class appears in the source.
Prerequisites
- .NET 10 SDK installed
- Completed Serve markdown through a Blazor catch-all (or a Pennington project with
MapRazorComponents<App>()wired and a catch-allMarkdownPage.razor)
The finished code for this tutorial lives in examples/GettingStartedStylingExample. For a documentation site, the DocSite template ships this MonorailCSS-plus-MainLayout stack with a sidebar, search, and theme toggle already assembled — Scaffold a documentation site with DocSite covers it.
1. Wrap pages in a styled MainLayout.razor
Before MonorailCSS can do anything, the layout needs to carry the utility classes that will turn into CSS rules. The document shell moves out of App.razor and into a MainLayout.razor that holds those classes; App.razor shrinks to a bare router that wraps every routed page in the new layout.
Create Components/Layout/MainLayout.razor
Drop this file at Components/Layout/MainLayout.razor. Inheriting LayoutComponentBase makes it a Blazor layout — every routed page renders into the @Body placeholder. This component now owns the whole document shell — <!DOCTYPE>, <html>, <head> (with <HeadOutlet>), and <body> — moved here from App.razor. The <link rel="stylesheet" href="/styles.css"> tag points at an endpoint section 2 will mount.
@* Styled shell. Lives once and wraps every routed @page via App.razor's
DefaultLayout. The class strings (text-primary-700, bg-base-50, …) become
literal IL strings after Razor compiles, and Discovery's startup IL scan
picks them up to populate the class registry behind /styles.css. *@
@inherits LayoutComponentBase
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/styles.css" />
<HeadOutlet />
</head>
<body class="bg-base-50 text-base-900 min-h-screen">
<div class="max-w-3xl mx-auto px-6 py-10">
<header class="mb-8 border-b border-base-200 pb-4">
<a class="text-lg font-bold text-primary-700" href="/">My Styled Pennington Site</a>
</header>
<article class="prose">
@Body
</article>
<footer class="mt-12 pt-4 border-t border-base-200 text-xs text-base-500">
Styled with MonorailCSS.
</footer>
</div>
</body>
</html>
The classes — bg-base-50, text-primary-700, border-base-200, and so on — come from the named color palette configured in the next section.
Reference the layout namespace from _Imports.razor
App.razor refers to MainLayout by its bare name, so the project's _Imports.razor needs an @using for the layout's namespace — without it typeof(MainLayout) fails to compile. Add the Components.Layout line to the _Imports.razor at the project root:
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Pennington.Content
@using Pennington.Pipeline
@using Pennington.Routing
@using GettingStartedStylingExample.Components
@using GettingStartedStylingExample.Components.Layout
Note
The embeds use the finished example's root namespace, GettingStartedStylingExample (in _Imports.razor and the section 2 Program.cs snippet). Swap in your own.
Replace Components/App.razor
With the shell now in MainLayout.razor, App.razor is replaced wholesale: it drops the <!DOCTYPE>, <html>, <head>, and <HeadOutlet> it used to own and becomes just the <Router>. RouteView names MainLayout as the DefaultLayout for every matched page, and the LayoutView for the not-found case lets the same shell wrap the 404 message.
@* Root component. The Router scans this assembly for [@page] components and
wraps each match in MainLayout (the styled shell). <PageTitle> from each
routed page flows into <head> via <HeadOutlet>. *@
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
</Found>
<NotFound>
<LayoutView Layout="@typeof(MainLayout)">
<p>Not found.</p>
</LayoutView>
</NotFound>
</Router>
2. Register MonorailCSS and mount /styles.css
MonorailCSS ships in its own package, separate from the core Pennington package the previous tutorial added. Pull it in:
dotnet add package Pennington.MonorailCss
With the package referenced, wire MonorailCSS into the service container, pick a color scheme, and mount the JIT stylesheet endpoint. AddMonorailCss registers the services; each of PrimaryColorName, AccentColorName, and BaseColorName takes a ColorName constant (indigo/pink/slate here — any combination works). app.UseMonorailCss() mounts /styles.css as a real endpoint that regenerates on every request, matching the <link> tag in MainLayout.razor. Both highlighted blocks are new in this section; the using Pennington.MonorailCss; at the top is what makes AddMonorailCss, MonorailCssOptions, NamedColorScheme, and ColorName resolve.
using GettingStartedStylingExample.Components;
using Pennington.FrontMatter;
using Pennington.Infrastructure;
using Pennington.MonorailCss;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddPennington(penn =>
{
penn.SiteTitle = "My Styled Pennington Site";
penn.ContentRootPath = "Content";
penn.AddMarkdownContent<DocFrontMatter>(md =>
{
md.ContentPath = "Content";
md.BasePageUrl = "/";
});
});
// New in this stage: register MonorailCSS. Pick which named palettes
// back the `primary`, `accent`, and `base` utility prefixes. Any
// ColorName constant works — swap freely.
builder.Services.AddMonorailCss(_ => new MonorailCssOptions
{
ColorScheme = new NamedColorScheme
{
PrimaryColorName = ColorName.Indigo,
AccentColorName = ColorName.Pink,
BaseColorName = ColorName.Slate,
},
});
builder.Services.AddRazorComponents();
var app = builder.Build();
app.UsePennington();
// New in this stage: mount /styles.css. The default path matches the
// <link> tag in MainLayout.razor.
app.UseMonorailCss();
app.UseAntiforgery();
app.MapRazorComponents<App>();
await app.RunOrBuildAsync(args);
Checkpoint
- Run
dotnet run --urls http://localhost:5000and visithttp://localhost:5000/— the header, article, and footer now render with indigo accents, slate neutrals, and the layout spacing the utility classes describe - Visit
http://localhost:5000/styles.cssdirectly and a populated stylesheet appears, containing rules for every utility class the layout emits
3. Watch the stylesheet regenerate
Under dotnet run, MonorailCSS rescans your project for new utility classes on the next /styles.css request, so classes you add in source (Razor components and other compiled C#) appear without a restart. Markdown bodies are out of scope: a utility token added to a .md file will not produce a CSS rule. The MonorailCSS integration explanation covers why.
Add a new utility class to MainLayout.razor
Open Components/Layout/MainLayout.razor and wrap the footer's "MonorailCSS" word in an accented span:
<footer class="mt-12 pt-4 border-t border-base-200 text-xs text-base-500">
Styled with <span class="text-accent-600 italic">MonorailCSS</span>.
</footer>
The class text-accent-600 wasn't in the layout, so it doesn't yet exist in the stylesheet.
Reload and confirm the new rule
Reload any page in the browser. The footer's "MonorailCSS" word renders in pink italic because the .razor edit refreshed the class set, and the next /styles.css request picked up the new token. Reload /styles.css directly and the text-accent-600 rule is present.
Checkpoint
- The footer's "Monorail
CSS" word renders in pink italic on every page http://localhost:5000/styles.cssnow contains a rule fortext-accent-600that wasn't there before the edit- No server restart was required — the MonorailCSS file watcher refreshed the stylesheet under the running
dotnet run
Summary
MainLayout.razor(a BlazorLayoutComponentBase) holds the utility-class scaffold every routed@pagerenders into viaApp.razor'sDefaultLayout.AddMonorailCss(...)registers the service container;UseMonorailCss()mounts the/styles.cssendpoint that regenerates on every request.- A
NamedColorSchemeof threeColorNameconstants drives everyprimary-*,accent-*, andbase-*utility prefix. - Under
dotnet run, adding a new utility class to a.razoror.csfile regenerates the stylesheet on the next request without a restart — markdown edits do not participate.
The site is styled, but every page is an island with no way to reach the next. The final getting-started tutorial adds a navigation menu that links them.