Heimdall Docs

Pages, Routing, and Middleware

A Heimdall page is just a minimal API-style GET endpoint that returns HTML. You map it with MapHeimdallPage(...), render with your existing HTML helpers, and let the normal ASP.NET Core pipeline run before your page handler executes.

That means authentication, authorization, rate limiting, and other middleware work the same way they do for any other ASP.NET Core endpoint.

1. The Mental Model

Heimdall does not introduce a separate routing system. Pages are endpoint-mapped HTML responses built on top of ASP.NET Core routing.

app.MapHeimdallPage("/dashboard", () =>
{
    return FluentHtml.Div(d =>
    {
        d.H1(h => h.Text("Dashboard"));
    });
});

Think of a Heimdall page like this:

HTTP Request
    ↓
ASP.NET Core Middleware Pipeline
    ↓
Endpoint Routing
    ↓
Heimdall Page Handler
    ↓
IHtmlContent → HTML Response

If you already understand minimal APIs, this should feel familiar. The main difference is that the endpoint returns IHtmlContent instead of JSON.

2. The Available Overloads

Heimdall provides several MapHeimdallPage overloads so you can keep simple pages simple while still supporting pages that need HttpContext, services, or async work.

Func<IHtmlContent> — for fully static pages.
Func<HttpContext, IHtmlContent> — for synchronous pages that need request context.
Func<HttpContext, Task<IHtmlContent>> — for asynchronous pages that need request context.
Func<IServiceProvider, HttpContext, IHtmlContent> — for synchronous pages that need both services and context.
Func<IServiceProvider, HttpContext, Task<IHtmlContent>> — the full async overload and core primitive.

3. Route Patterns and Route Values

MapHeimdallPage(...) uses standard ASP.NET Core route templates because it ultimately maps a GET endpoint under the hood.

That means route parameters, constraints, and multi-segment patterns all work exactly the same as minimal APIs.

app.MapHeimdallPage("/pages/{id}", ctx => ...);
app.MapHeimdallPage("/pages/{id:int}", ctx => ...);
app.MapHeimdallPage("/blog/{slug}", ctx => ...);
app.MapHeimdallPage("/fleets/{fleetId}/trailers/{trailerId}", ctx => ...);

Reading Route Values

Route values are available through HttpContext.Request.RouteValues.

app.MapHeimdallPage("/pages/{id}", ctx =>
{
    var id = ctx.Request.RouteValues["id"]?.ToString();

    return FluentHtml.Div(d =>
    {
        d.H1(h => h.Text("Page Detail"));
        d.P(p => p.Text($"Route id: {id}"));
    });
});

Route Constraints

You can apply standard ASP.NET Core route constraints to ensure values match expected types.

app.MapHeimdallPage("/pages/{id:int}", ctx =>
{
    var raw = ctx.Request.RouteValues["id"]?.ToString();
    var id = int.Parse(raw!);

    return FluentHtml.Div(d =>
    {
        d.H1(h => h.Text("Typed Route Example"));
        d.P(p => p.Text($"Page id: {id}"));
    });
});

4. Fully Static Pages

Use the simplest overload when the page does not need HttpContext or services.

app.MapHeimdallPage("/about", () =>
{
    return FluentHtml.Div(d =>
    {
        d.H1(h => h.Text("About"));
        d.P(p => p.Text("This page is fully static."));
    });
});

5. Pages That Need HttpContext

Use the HttpContext overload when rendering depends on request information like the current user, headers, route values, query string, or request path.

app.MapHeimdallPage("/profile", ctx =>
{
    var name = ctx.User.Identity?.Name ?? "Anonymous";

    return FluentHtml.Div(d =>
    {
        d.H1(h => h.Text("Profile"));
        d.P(p => p.Text($"Hello, {name}."));
    });
});

6. Async Pages with HttpContext

Use the async HttpContext overload when the page needs asynchronous work but does not need direct access to IServiceProvider.

app.MapHeimdallPage("/dashboard", async ctx =>
{
    await Task.Delay(10);

    return FluentHtml.Div(d =>
    {
        d.H1(h => h.Text("Dashboard"));
        d.P(p => p.Text("Rendered asynchronously."));
    });
});

7. Pages That Need Services and Context

Use the IServiceProvider + HttpContext overload when the page needs access to registered services while still remaining an explicit route-level function.

app.MapHeimdallPage("/account", (sp, ctx) =>
{
    var clock = sp.GetRequiredService<ISystemClock>();

    return FluentHtml.Div(d =>
    {
        d.H1(h => h.Text("Account"));
        d.P(p => p.Text($"Rendered at {clock.UtcNow:u}."));
    });
});

8. The Full Async Overload

This is the core primitive behind the other overloads. Use it when you need both request context and asynchronous service-driven rendering.

app.MapHeimdallPage("/reports", async (sp, ctx) =>
{
    var repository = sp.GetRequiredService<IReportRepository>();
    var reports = await repository.GetRecentAsync(ctx.RequestAborted);

    return FluentHtml.Div(d =>
    {
        d.H1(h => h.Text("Reports"));

        d.Ul(ul =>
        {
            foreach (var report in reports)
            {
                ul.Li(li => li.Text(report.Title));
            }
        });
    });
});

9. Pages Run Inside the ASP.NET Core Pipeline

Because page setup wraps a minimal API endpoint, middleware behaves exactly the way you would expect in a normal ASP.NET Core app.

app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();

app.MapHeimdallPage("/dashboard", ctx =>
{
    return FluentHtml.Div(d =>
    {
        d.H1(h => h.Text("Dashboard"));
    });
});

By the time the page handler runs, middleware has already had a chance to inspect, reject, enrich, or authorize the request.

Authentication can populate HttpContext.User before your page renders.
Authorization can block access before your render function executes.
Rate limiting can accept or reject requests before your page logic runs.
Any other middleware you add to the app pipeline applies normally.

10. Authentication and Authorization

Pages are still endpoints, so endpoint metadata and authorization policies apply the same way they do with minimal APIs.

app.MapHeimdallPage("/admin", ctx =>
{
    return FluentHtml.Div(d =>
    {
        d.H1(h => h.Text("Admin"));
    });
})
.RequireAuthorization();

Inside the page, you can read the authenticated user from HttpContext just like any other ASP.NET Core endpoint.

app.MapHeimdallPage("/me", ctx =>
{
    var name = ctx.User.Identity?.Name ?? "Anonymous";

    return FluentHtml.Div(d =>
    {
        d.H1(h => h.Text("Current User"));
        d.P(p => p.Text(name));
    });
})
.RequireAuthorization();

Heimdall does not replace ASP.NET Core auth. It relies on it.

11. Rate Limiting

Because pages are mapped endpoints, rate limiting can be applied globally through middleware or per endpoint through endpoint configuration.

app.MapHeimdallPage("/search", async ctx =>
{
    await Task.Delay(10);

    return FluentHtml.Div(d =>
    {
        d.H1(h => h.Text("Search"));
    });
})
.RequireRateLimiting("default");

This is useful for expensive pages, sensitive routes, or endpoints that should behave differently under load. Since the page is just an endpoint, the same rate limiting tools apply.

12. Pages Return HTML

Every overload ultimately returns IHtmlContent. Heimdall renders that content to an HTML response with a text/html content type.

Page Method

public static class RoutingPage
{
    public static IHtmlContent Render()
    {
        return FluentHtml.Main(main =>
        {
            main.Div(d =>
            {
                d.H1(h => h.Text("Pages"));
                d.P(p => p.Text("Rendered on the server."));
            });
        });
    }
}

Mapped Route

app.MapHeimdallPage("/pages", () => RoutingPage.Render());

13. Pages Usually Use a Layout

In a real app, pages usually render inside a shared layout. The layout provides the document shell, navigation, scripts, styles, and global targets like toast containers.

public static IHtmlContent Render(HttpContext ctx)
{
    return MainLayout.Render(ctx, body =>
    {
        body.Main(main =>
        {
            main.Class(Bootstrap.Spacing.Py(5));

            main.Div(container =>
            {
                container.Class(Bootstrap.Layout.Container);
                container.H1(h1 => h1.Text("Pages"));
            });
        });
    });
}

14. Pages vs Content Invocations

This is one of the most important distinctions in Heimdall.

Pages

Mapped with MapHeimdallPage(...). Used for route-level rendering when a browser requests a page.

  • Handle URLs like /, /pages, or /forms
  • Return full HTML for the request
  • Usually wrapped in a layout
  • Participate in the normal ASP.NET Core endpoint pipeline

Content Invocations

Marked with [ContentInvocation]. Used for triggered interactions that return fresh HTML fragments.

  • Triggered by Heimdall attributes
  • Return fragment HTML
  • Typically swapped into an existing target
  • Still run inside ASP.NET Core, but usually serve interaction flows instead of route-level page loads

15. A Typical Setup

A common pattern is to configure the ASP.NET Core pipeline in Program.cs, then map Heimdall pages the same way you would map other minimal API endpoints.

app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();

app.MapHeimdallPage("/", () => HomePage.Render());
app.MapHeimdallPage("/pages", () => RoutingPage.Render());
app.MapHeimdallPage("/admin", ctx => AdminPage.Render(ctx))
   .RequireAuthorization();

That setup keeps page rendering simple while letting the application pipeline stay fully standard and fully predictable.

Next Steps

Once pages make sense, the next thing to learn is how interactions work.