This site provides a machine-readable index at /llms.txt.

Skip to main content Skip to navigation

Render a Razor component as a page on a bare host

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 any DocSite or BlogSite layout machinery in between. This is the pattern to reach for when a custom IContentService discovers per-record routes (/instructors/{slug}/, /status/{slug}/, etc.) and the rendered output is too rich for string-interpolated HTML.

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] surface is 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 Pennington and MonorailCSS hosts. No SPA wiring is required — HtmlRenderer is a one-shot server-side renderer, distinct from the RazorIslandRenderer<T> shape used by Hydrate a Razor component as a client island.

using System.Collections.Immutable;
using BareHostRazorPageExample;
using BareHostRazorPageExample.Components;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Pennington.Content;
using Pennington.Infrastructure;
using Pennington.MonorailCss;
using Pennington.Pipeline;
using Pennington.Routing;
  
var builder = WebApplication.CreateBuilder(args);
  
// Bare AddPennington host. This example demonstrates rendering a Razor
// component as the entire response body of a MapGet route — no DocSite, no
// markdown pipeline.
builder.Services.AddPennington(penn =>
{
    penn.SiteTitle = "Bare-host Razor page";
    penn.ContentRootPath = "Content";
});
  
builder.Services.AddMonorailCss();
  
// HtmlRenderer needs Blazor's component services. AddRazorComponents wires
// them; AddHttpContextAccessor lets the HtmlRenderer resolve cascading values.
builder.Services.AddRazorComponents();
builder.Services.AddHttpContextAccessor();
  
// IContentService that publishes the per-status routes to the build crawler.
// On a bare host, EndpointSource is the case to use when a sibling MapGet
// produces the HTML — see how-to/extensibility/custom-content-service.
builder.Services.AddSingleton<StatusPagesContentService>();
builder.Services.AddSingleton<IContentService>(sp =>
    sp.GetRequiredService<StatusPagesContentService>());
  
var app = builder.Build();
  
app.UsePennington();
app.UseMonorailCss();
  
app.MapGet("/status/{slug}/", (string slug, StatusPagesContentService statuses, HtmlRenderer renderer)
    => 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));
  
await app.RunOrBuildAsync(args);
return;
  
// Render a Razor component to HTML inside a request handler. HtmlRenderer
// runs the component on the request thread via Dispatcher.InvokeAsync, then
// returns the HTML as a string. The component owns the whole document, so the
// result is a complete HTML page ready to flush to the response.
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");
}
  
namespace BareHostRazorPageExample
{
    /// <summary>One status page entry — the data StatusPage.razor binds.</summary>
    public sealed record StatusEntry(
        string Slug,
        string Title,
        string Summary,
        IReadOnlyList<KeyValuePair<string, string>> Facts);
  
    /// <summary>
    /// Publishes one route per status entry so the build crawler discovers
    /// them. <see cref="EndpointSource"/> tells the pipeline that the HTML is
    /// produced by a sibling <c>MapGet</c>; the crawler still fetches each
    /// URL through the live pipeline at build time.
    /// </summary>
    public sealed class StatusPagesContentService : IContentService
    {
        private readonly ImmutableArray<StatusEntry> _entries =
        [
            new("intro", "Intro", "How this example wires HtmlRenderer.",
                [new("Stage", "Production"), new("Owner", "Docs team")]),
            new("verify", "Verify", "Confirm the rendered page reaches the browser.",
                [new("Stage", "Beta"), new("Owner", "Docs team")]),
        ];
  
        public string DefaultSectionLabel => "Status";
        public int SearchPriority => 30;
  
        public StatusEntry? TryGet(string slug) =>
            _entries.FirstOrDefault(e => e.Slug == slug);
  
        public async IAsyncEnumerable<DiscoveredItem> DiscoverAsync()
        {
            foreach (var entry in _entries)
            {
                var route = ContentRouteFactory.FromUrl(new UrlPath($"/status/{entry.Slug}/"));
                yield return new DiscoveredItem(route, new EndpointSource());
            }
            await Task.CompletedTask;
        }
  
        public Task<ImmutableList<ContentToCopy>> GetContentToCopyAsync()
            => Task.FromResult(ImmutableList<ContentToCopy>.Empty);
  
        public Task<ImmutableList<ContentToCreate>> GetContentToCreateAsync()
            => Task.FromResult(ImmutableList<ContentToCreate>.Empty);
  
        public Task<ImmutableList<ContentTocItem>> GetContentTocEntriesAsync()
        {
            var builder = ImmutableList.CreateBuilder<ContentTocItem>();
            var order = 10;
            foreach (var entry in _entries)
            {
                builder.Add(new ContentTocItem(
                    Title: entry.Title,
                    Route: ContentRouteFactory.FromUrl(new UrlPath($"/status/{entry.Slug}/")),
                    Order: order,
                    HierarchyParts: ["status", entry.Slug],
                    SectionLabel: DefaultSectionLabel,
                    Locale: null));
                order += 10;
            }
            return Task.FromResult(builder.ToImmutable());
        }
  
        public Task<ImmutableList<CrossReference>> GetCrossReferencesAsync()
        {
            var builder = ImmutableList.CreateBuilder<CrossReference>();
            foreach (var entry in _entries)
            {
                var route = ContentRouteFactory.FromUrl(new UrlPath($"/status/{entry.Slug}/"));
                builder.Add(new CrossReference($"status-{entry.Slug}", entry.Title, route));
            }
            return Task.FromResult(builder.ToImmutable());
        }
    }
}

The RenderRazorPageAsync<TComponent> helper at the bottom of Program.cs is the only Blazor-specific code the host needs: it dispatches the render onto the renderer's dispatcher, materializes the output, and hands the HTML string to Results.Content. Reuse it for any other component-as-page route.

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. EndpointSource is the right case here (not RedirectSource); see Source content from outside the file system for why and what each case implies for sitemap.xml.

Verify

  • Run dotnet run --project examples/BareHostRazorPageExample and visit http://localhost:5000/status/intro/ and http://localhost:5000/status/verify/. 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.
  • View source on a rendered page — the markup ends with </html>, with no surrounding chrome injected by the framework.