This documentation is also published as Markdown for efficient machine reading: the whole site is indexed at /llms.txt, and every page has a clean Markdown copy at the same URL with .md appended. These are generated from the same source and cost far fewer tokens to read than this rendered HTML.

Skip to main content Skip to navigation
Guides

Render a Razor component as a page on a bare host

Use HtmlRenderer.RenderComponentAsync inside a MapGet to make a Razor component the entire response body, no DocSite layout pipeline required.

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 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.

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

csharp
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:

csharp
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:

csharp
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/BareHostRazorPageExample and open /status/intro/ and /status/verify/ at the URL the console prints (the Now listening on: line). Each renders the StatusPage component as a full HTML page styled by /styles.css.
  • Confirm the static build picks up both routes: dotnet run --project examples/BareHostRazorPageExample -- build writes output/status/intro/index.html and output/status/verify/index.html.