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 under /_llms/. 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.