ASP.NET Integration
Wire MonorailCSS into an ASP.NET app at build time, at runtime, or both
MonorailCSS is a JIT compiler — it only emits CSS for the classes your app actually uses. Two packages bring that into ASP.NET:
MonorailCss.Build.Tasksscans duringdotnet buildand writes CSS to disk. The output ships as a static asset; no work happens at runtime.MonorailCss.Discoveryscans at startup, watches your source tree, and serves CSS from middleware. CSS regenerates as you edit.
You can pick either. You can also wire both so the build task produces the deployable file and Discovery keeps it fresh while you're running dotnet watch. Both packages read the same app.css and produce equivalent output from equivalent inputs.
The shared input: app.css
Both packages read a Tailwind v4-style CSS file. The same directives work in either:
/* wwwroot/app.css */
@import "tailwindcss";
@theme {
--color-brand: oklch(60% 0.2 250);
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.1);
--radius-card: 0.625rem;
}
@custom-variant dark (&:where(.dark, .dark *));
@utility scrollbar-hide {
scrollbar-width: none;
}
@layer base {
body {
@apply text-foreground bg-background;
}
}
@import follows imports recursively, @theme defines design tokens, @utility registers custom utilities, @custom-variant registers custom variants, @apply resolves utility composition, and plain CSS passes through. Build.Tasks adds @source, @source not, and @source inline(...) for explicit source configuration.
The CSS-file-as-config angle has a second benefit: editor tooling like the Tailwind CSS IntelliSense extension parses the same file and gives you autocomplete, hover previews, and color squares for every theme token in your .razor and .cs source.
MonorailCss.Build.Tasks — build time
Install:
dotnet add package MonorailCss.Build.Tasks
The package auto-imports its targets. Add to your .csproj:
<PropertyGroup>
<MonorailCssEnabled>true</MonorailCssEnabled>
</PropertyGroup>
<ItemGroup>
<MonorailCss Include="wwwroot/app.css" />
</ItemGroup>
The default OutputFile is wwwroot/css/%(Filename).css, so wwwroot/app.css becomes wwwroot/css/app.css. UseStaticFiles serves it at /css/app.css:
<link rel="stylesheet" href="/css/app.css" />
The task runs during dotnet build, scans the same source files, DLLs, and package static web assets Discovery does, and writes the file before content packaging. Rebuilds are incremental: unchanged inputs don't get rescanned. The Clean target removes the generated file.
Note:
dotnet watchdoes not re-trigger MSBuild targets. Build.Tasks alone won't keep your CSS fresh during a watch session — see the hybrid section for how to combine it with Discovery.
Driving source configuration from CSS
The build task picks up source paths from @source and @import directives in your CSS:
/* Widen auto-detection from wwwroot/ (where this file lives) up to the project root. */
@import "tailwindcss" source("..");
/* Disable auto-detection; only explicit @source directives below get scanned. */
@import "tailwindcss" source(none);
@source "../Components";
@source not "../Components/Legacy";
/* Scan a referenced library's DLL. $(...) syntax avoids clashing with glob {Pages,Components}. */
@source "../bin/$(Configuration)/$(TargetFramework)/MyComponentLibrary.dll";
/* Safelist runtime-built classes. Brace expansion supported. */
@source inline("bg-{red,blue}-{500,600}");
The placeholders inside @source paths ($(Configuration), $(TargetFramework), $(RuntimeIdentifier)) are resolved at build time from MSBuild properties.
MSBuild configuration
| Property/Item | Purpose |
|---|---|
<MonorailCssEnabled> |
On/off; default true. Gate on '$(Configuration)' == 'Release' if you're combining with Discovery. |
<MonorailCssExcludeAssemblies> |
Semicolon-delimited assembly names to skip (e.g. FluentValidation;BadIdeas.Icons.FontAwesome). |
<MonorailCssScanStaticWebAssets> |
On/off; default true. Scans .js/.mjs shipped by referenced packages as static web assets (e.g. _content/Pennington.UI/scripts.js). Razor/Web SDK projects only. |
<MonorailCss> Include |
The entry CSS file. Multiple items produce multiple outputs. |
<MonorailCss> OutputFile metadata |
Override the default wwwroot/css/%(Filename).css. |
Framework assemblies (MonorailCss, MonorailCss.Build.Tasks, MonorailCss.Discovery) and the BCL are excluded automatically. You only need to list libraries that bake class-shaped strings into their IL.
MonorailCss.Discovery — runtime
Install:
dotnet add package MonorailCss.Discovery
Wire it up in Program.cs:
using MonorailCss.Discovery;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
builder.Services.AddMonorailCss();
var app = builder.Build();
app.UseStaticFiles();
app.UseMonorailCss();
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
app.Run();
Point your layout at the served stylesheet:
<link rel="stylesheet" href="/_monorail/app.css" />
With no configuration AddMonorailCss auto-detects wwwroot/app.css, scans every non-BCL referenced assembly for class strings, scans JavaScript shipped by component packages as static web assets, watches your source tree for changes in Development, and serves the result at /_monorail/app.css. Edit a class in a .razor file under dotnet watch and the browser sees the new CSS on the next HEAD poll.
Configuration
Pass a callback to AddMonorailCss to override defaults:
builder.Services.AddMonorailCss(opt =>
{
opt.ExcludeAssemblies.Add("BadIdeas.Icons.FontAwesome");
opt.ExtraSafelist.Add("bg-red-500");
opt.CssEndpoint = "/css/app.css";
});
The options you'll actually reach for:
ExcludeAssemblies— skip libraries whose IL strings would inflate the candidate set without contributing real utilities. Icon packs that bake thousands of class-shaped tokens into metadata are the usual culprits. MonorailCSS itself and BCL assemblies (System.*,Microsoft.*) are excluded automatically.ExtraSafelist— force-include classes static scanning can't reconstruct, e.g. anything built at runtime via$"bg-{color}-500".ScanStaticWebAssets— on by default; reads classes out of JavaScript that referenced packages/RCLs ship under_content/<Package>/. Those files live in the NuGet cache — outside your source tree and the assembly IL — so nothing else reaches them; a component whose modal markup is built inscripts.jsneeds this. Narrow what's read withStaticWebAssetExtensions(default.js,.mjs), or suppress a package's assets by adding it toExcludeAssemblies.SourceCssPath— path to your entry CSS file. Auto-detected aswwwroot/app.csswhen unset.CssEndpoint— the URL the middleware serves CSS at, default/_monorail/app.css. Change it to share a URL with a build-time static file (see hybrid below).Framework— supply a pre-configuredCssFrameworkwhen you need to seed prose configuration or register utilities programmatically. See configuration. The CSS file processing layers on top.
There are a couple of less-common options (SourceCss for in-memory CSS, WriteToFile to mirror the output to disk, WatchSourceDirectories for non-standard layouts) on MonorailDiscoveryOptions; the defaults are right for most projects.
Owning the endpoint
The built-in middleware handles ETag, If-None-Match, and HEAD, and exposes a JSON diagnostics view at {CssEndpoint}/diagnostics (handy when a class isn't appearing and you want to confirm whether it failed to discover or failed to compile).
When the built-in middleware isn't enough — you want auth in front of the CSS, custom cache directives, or to mirror to a CDN — register discovery without the middleware and inject IClassRegistry into your own endpoint:
builder.Services.AddMonorailClassDiscovery(opt =>
{
opt.ExcludeAssemblies.Add("BadIdeas.Icons.FontAwesome");
});
var app = builder.Build();
app.MapGet("/css/app.css", (IClassRegistry registry) =>
Results.Text(registry.Css, "text/css", Encoding.UTF8));
IClassRegistry exposes Css (the assembled stylesheet), Version (a content-derived ETag, already wrapped in quotes per RFC 7232 — don't re-quote it), and GetClasses() (the validated class set).
Hybrid: build time + dotnet watch
Build.Tasks produces a deployable static file but doesn't see edits during a dotnet watch session. Discovery does see those edits but adds a hosted service and a startup IL scan to your app. You can wire both: Discovery only in Development, Build.Tasks only in Release. One <link> URL in your layout, two pipelines feeding it depending on environment.
Program.cs — register Discovery and the endpoint only in Development. In Production the request falls through to UseStaticFiles:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
if (builder.Environment.IsDevelopment())
{
builder.Services.AddMonorailClassDiscovery(opt =>
{
opt.ExcludeAssemblies.Add("BadIdeas.Icons.FontAwesome");
});
}
var app = builder.Build();
app.UseStaticFiles();
if (app.Environment.IsDevelopment())
{
app.MapGet("/css/app.css", (IClassRegistry registry) =>
Results.Text(registry.Css, "text/css", Encoding.UTF8));
}
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
app.Run();
.csproj — gate the build task on Release, point the output at the URL the dev endpoint serves on:
<PropertyGroup>
<MonorailCssEnabled Condition="'$(Configuration)' == 'Release'">true</MonorailCssEnabled>
<MonorailCssExcludeAssemblies>BadIdeas.Icons.FontAwesome</MonorailCssExcludeAssemblies>
</PropertyGroup>
<ItemGroup>
<MonorailCss Include="wwwroot/app.css">
<OutputFile>$(MSBuildProjectDirectory)/wwwroot/css/app.css</OutputFile>
</MonorailCss>
</ItemGroup>
Layout — one stylesheet link, no environment branching:
<link rel="stylesheet" href="/css/app.css" />
How a request for /css/app.css resolves:
- Development. Build task is off, so
wwwroot/css/app.cssdoesn't exist.UseStaticFilesfinds nothing, the request falls through to the routing endpoint, and the liveIClassRegistryserves it. - Production. Discovery isn't registered. The Release build produced
wwwroot/css/app.css, whichUseStaticFilesserves directly with its ownETag.
A couple of things to watch out for:
- Keep the exclusion list in both places synced. Both pipelines walk the same assembly set; an icon pack that inflates one inflates the other.
dotnet cleanafter switching configurations. If you build-c Releaseand then go back to Debug, the static file persists inwwwroot/css/and shadows the dev endpoint.dotnet cleanremoves it.- Components that inject
IClassRegistrywill throw in Production. Resolve it viaIServiceProvider.GetService<IClassRegistry>()instead of@injectso a null in Production is a no-op.
The TryMonorail project in this repo is a worked example.
Rolling your own
Both packages are wrappers around CssFramework.Process. If neither scanning model fits — you have your own class collector, you're generating CSS in a non-ASP.NET host, you're driving everything from a build step that produces inputs by some other route — call the framework directly:
var framework = new CssFramework();
var classes = GetClassesSomehow();
var css = framework.Process(classes);
See getting started for the bare-API walkthrough. The hard part isn't calling MonorailCSS — it's reliably discovering which classes your application uses. Discovery and Build.Tasks solve that for the ASP.NET case.