MVC Integration
Heimdall can sit inside an MVC application without asking you to abandon controllers, Razor views, partials, or constructor injection.
The usual shape is simple: MVC renders pages, Heimdall handles selected HTML-returning interactions, and MVC partials can be reused on both sides.
1. Start From The MVC Template
For a new MVC application, start with the MVC template. It includes controllers, Razor views, partials, content invocations, Bootstrap assets, and the Heimdall runtime wiring.
dotnet new install HeimdallFramework.Templates.MvcApp
dotnet new heimdall-mvc -n MyHeimdallMvcApp2. Register MVC Support Manually
For an existing MVC application, AddHeimdallMvc registers the MVC view services Heimdall needs to render Razor partials from content actions. It also registers IHttpContextAccessor and IHeimdallMvcRenderer.
builder.Services.AddControllersWithViews();
builder.Services.AddAntiforgery();
builder.Services.AddHeimdall(options =>
{
options.EnableDetailedErrors = true;
});
builder.Services.AddHeimdallMvc();3. Map Both Pipelines
MVC routes and Heimdall endpoints are separate endpoint shapes. Configure MVC normally, then enable Heimdall endpoints for content actions, CSRF, and optional SSE.
var app = builder.Build();
app.UseRouting();
app.UseAntiforgery();
app.UseAuthorization();
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
app.UseHeimdall();4. Add The Runtime
MVC views can use Heimdall attributes directly. Include the Heimdall browser runtime in the layout that contains those attributes. Layout chrome can be normal Razor partials; reserve content invocations for regions that actually need server-driven updates.
<body>
@await Html.PartialAsync("_ToastManager")
@await Html.PartialAsync("_Menu")
@RenderBody()
<script src="~/_content/HeimdallFramework.Web/heimdall-bundle.min.js"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>5. Use Native Attributes in Razor Views
In MVC, you can place Heimdall attributes directly in Razor markup. The runtime only sees the rendered DOM, so a Razor partial, MVC view, or fluent Heimdall page all use the same attribute contract.
Razor Markup
@model OrderFilter
<form
id="order-filter"
heimdall-content-submit="orders.filter"
heimdall-payload-from="closest-form"
heimdall-content-target="#orders-table"
heimdall-content-swap="inner">
<label for="order-search">Search</label>
<input
id="order-search"
name="Search"
type="search"
value="@Model.Search"
autocomplete="off"
heimdall-content-input="orders.filter"
heimdall-payload-from="closest-form"
heimdall-debounce="300"
heimdall-content-target="#orders-table"
heimdall-content-swap="inner" />
<button type="submit">Apply</button>
</form>
<div id="orders-table">
@await Html.PartialAsync("_OrderList", Model)
</div>The submit and input triggers both call the same content action, send the closest form as the payload, and update the orders table with returned partial HTML.
6. Place Actions Where They Fit
A content action can live in a small action class, or beside the related MVC controller. Both are valid. The controller-local form is often easier to discover in MVC-heavy apps.
Controller-local action
[ContentInvocationPrefix("orders")]
public sealed class OrdersController(
IOrderRepository orders,
IHeimdallMvcRenderer views) : Controller
{
public IActionResult Index()
{
return View();
}
[NonAction]
[ContentInvocation("filter")]
public async Task<IHtmlContent> FilterRows(
OrderFilter filter,
CancellationToken ct)
{
var results = await orders.SearchAsync(filter, ct);
return await views.PartialAsync("_OrderList", results, ct);
}
}Use NonAction when a content action lives on a controller. Heimdall still discovers it, but MVC routing will not expose it as a normal controller action.
Separate action class
[ContentInvocationPrefix("orders")]
public sealed class OrderActions(
IOrderRepository orders,
IHeimdallMvcRenderer views)
{
[ContentInvocation("filter")]
public async Task<IHtmlContent> FilterRows(
OrderFilter filter,
CancellationToken ct)
{
var results = await orders.SearchAsync(filter, ct);
return await views.PartialAsync("_OrderList", results, ct);
}
}7. What The Renderer Does
IHeimdallMvcRenderer uses the real MVC view engine to render partials. It is MVC partial rendering from a Heimdall request, not a clone of rendering from inside an already-running Razor view.
Works well:
- shared partials like _Header and _OrderList
- application-relative partial paths when needed
- models
- ViewData supplied to the renderer
- TempData
- tag helpers
- Razor dependency injection
- nested partials
Not inherited from a parent MVC view:
- parent ViewContext
- parent ViewData
- parent ModelState
- current controller/action route values
- HtmlFieldPrefix8. Prefer Shared Partials
Named partial lookup uses MVC conventions. Shared partials are the cleanest fit because they do not rely on a controller/action route value being present on the Heimdall request.
// Good default: Views/Shared/_OrderList.cshtml
return await views.PartialAsync("_OrderList", results, ct);
// Useful fallback when lookup is intentionally explicit.
return await views.PartialAsync(
"~/Views/Orders/_OrderList.cshtml",
results,
ct);9. Passing ViewData
Use the overload with a ViewData callback when a partial expects extra values in ViewData in addition to the model.
return await views.PartialAsync(
"_OrderList",
results,
viewData => viewData["mode"] = "compact",
ct);10. MVC And Heimdall Pages Can Coexist
You can use MVC routes for conventional pages and MapHeimdallPage for HTML-first pages in the same app. The decision is per route, not per project.
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
app.MapHeimdallPage("/docs", ctx =>
{
return DocsPage.Render(ctx);
});
app.UseHeimdall();11. When To Use This
The MVC wrapper is best when you already have Razor partials and want Heimdall interactions to reuse them. It is also useful during migration because you can introduce Heimdall one partial at a time.
Good fit:
- existing MVC app
- existing Razor partials
- server-rendered filters, rows, cards, panels
- incremental adoption
Less ideal:
- pure Heimdall app with no Razor views
- partials that depend on parent ModelState
- partials that assume ambient controller/action route values
- client-owned component state updatesNext Steps
Once MVC rendering is wired in, content action behavior is the same as the rest of Heimdall: payloads bind, auth metadata applies, and the browser swaps returned HTML into the target.