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