Render a Razor component as a page on a bare host
Use HtmlRenderer.
To render a Razor component as the whole response body for a custom route on a bare AddPennington host, render it through Blazor's server-side HtmlRenderer from inside a MapGet. The component owns the document — <html>, <head>, <body> — so the response is a complete HTML page without DocSite or BlogSite layout machinery. Use this pattern when a custom IContentService discovers per-record routes (/instructors/{slug}/, /status/{slug}/) and the rendered output is too complex for inline HTML strings.
Before you begin
- A working Pennington site on bare
AddPennington(see Create your first Pennington site if not). - A reference to
Microsoft.AspNetCore.Components.Web— already transitive throughPennington. - Familiarity with
IContentServicefor publishing the routes you'll render against (Source content from outside the markdown pipeline).
A working reference: examples/BareHostRazorPageExample — one Razor component plus a single MapGet that renders it.
Author the page component
Write a Razor component whose [Parameter] properties are everything the page needs — there is no ambient HttpContext, layout, or cascading state from a parent. The component renders the entire document so it includes <!DOCTYPE html> and the <link rel="stylesheet" href="/styles.css"> tag for MonorailCSS output.
@* StatusPage — a Razor component used as the entire page body for routes like
/status/{slug}. Program.cs renders it through HtmlRenderer.RenderComponentAsync
inside a MapGet, so the component owns the whole document including <html>,
<head>, and <body>. *@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>@Title</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body class="bg-base-50 text-base-900 dark:bg-base-950 dark:text-base-50">
<main class="mx-auto max-w-2xl px-6 py-12">
<header class="mb-8">
<p class="text-xs font-semibold uppercase tracking-wide text-accent-500">@Slug</p>
<h1 class="mt-1 font-display text-3xl font-bold">@Title</h1>
</header>
<p class="text-base-700 dark:text-base-300">@Summary</p>
<dl class="mt-8 grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-[10rem_1fr]">
@foreach (var item in Facts)
{
<dt class="text-xs font-semibold uppercase tracking-wide text-base-500 dark:text-base-400">@item.Key</dt>
<dd class="text-sm text-base-700 dark:text-base-300">@item.Value</dd>
}
</dl>
</main>
</body>
</html>
@code {
[Parameter, EditorRequired] public string Slug { get; set; } = string.Empty;
[Parameter, EditorRequired] public string Title { get; set; } = string.Empty;
[Parameter, EditorRequired] public string Summary { get; set; } = string.Empty;
[Parameter] public IReadOnlyList<KeyValuePair<string, string>> Facts { get; set; } = [];
}
Register the Blazor renderer services
HtmlRenderer needs Blazor's component services and an IHttpContextAccessor so cascading values can resolve. Register both alongside the AddPennington and AddMonorailCss hosts:
builder.Services.AddRazorComponents();
builder.Services.AddHttpContextAccessor();
AddRazorComponents registers HtmlRenderer and its dispatcher; AddHttpContextAccessor lets a rendered component resolve cascading values. There is no MapRazorComponents, no App.razor, and no _Host page — the bare host never starts the Blazor router. Components reach the response only through the MapGet below.
Render the component inside a MapGet
The route handler turns a slug into the component's [Parameter] values and hands them to a render helper. A missing record returns null parameters, which the helper turns into a 404:
public static async Task<IResult> RenderRazorPageAsync<TComponent>(
HtmlRenderer renderer,
IDictionary<string, object?>? parameters)
where TComponent : IComponent
{
if (parameters is null)
{
return Results.NotFound();
}
var html = await renderer.Dispatcher.InvokeAsync(async () =>
{
var output = await renderer.RenderComponentAsync<TComponent>(
ParameterView.FromDictionary(parameters));
return output.ToHtmlString();
});
return Results.Content(html, "text/html");
}
RenderRazorPageAsync<TComponent> is the only Blazor-specific code the host needs: it dispatches the render onto the renderer's dispatcher, materializes the output with ToHtmlString, and hands the complete HTML string to Results.Content. Reuse it for any other component-as-page route. The route wiring itself is a plain minimal-API endpoint:
app.MapGet("/status/{slug}/", (string slug, StatusPagesContentService statuses, HtmlRenderer renderer)
=> BareHostRenderer.RenderRazorPageAsync<StatusPage>(renderer, statuses.TryGet(slug) is { } entry
? new Dictionary<string, object?>
{
[nameof(StatusPage.Slug)] = entry.Slug,
[nameof(StatusPage.Title)] = entry.Title,
[nameof(StatusPage.Summary)] = entry.Summary,
[nameof(StatusPage.Facts)] = entry.Facts,
}
: null));
Why not a Blazor @page?
A routed @page component needs the Blazor router, an App.razor, and MapRazorComponents — the machinery Serve markdown through Blazor Pages stands up. A bare AddPennington host runs none of that, so a @page directive would never be routed. Rendering through HtmlRenderer inside a MapGet keeps the host minimal: the component is a render target, not a routed endpoint, and your IContentService owns route discovery.
Publish the routes through IContentService
A custom IContentService yields one EndpointSource per route so the build crawler discovers each URL and fetches it through the live pipeline — your MapGet produces the HTML the same way at build time as at request time. See Source content from outside the markdown pipeline for the per-record discovery pattern, including a worked EndpointSource example.
Verify
- Run
dotnet run --project examples/BareHostRazorPageExampleand open/status/intro/and/status/verify/at the URL the console prints (theNow listening on:line). Each renders theStatusPagecomponent as a full HTML page styled by/styles.css. - Confirm the static build picks up both routes:
dotnet run --project examples/BareHostRazorPageExample -- buildwritesoutput/status/intro/index.htmlandoutput/status/verify/index.html.
Related
- How-to: Source content from outside the file system
- Background: The content pipeline and union types