Co-authored-by: Copilot <copilot@github.com>
6.1 KiB
Creating a New Page
This guide explains the full lifecycle of adding a new page to the app: the template file, the code-behind class, the handler, and the sidebar link.
How pages work
Every page is a pair of files:
| File | Purpose |
|---|---|
Templates/MyPage.htmx |
HTML markup with $$SlotName$$ slots |
Templates/MyPage.htmx.cs |
C# class that fills slots + declares the Minimal API handler |
The Roslyn source generator (Htmx.SourceGenerator) reads every .htmx file at build time and generates an abstract base class for it. You then write a concrete class in the companion .htmx.cs file that inherits from that base.
How $$SlotName$$ becomes code
Take this simple template:
<!-- Templates/MyPage.htmx -->
<div class="p-6">
<h1>$$Title$$</h1>
<p>$$Body$$</p>
</div>
The generator splits the file on $$...$$ patterns and produces:
// auto-generated — do NOT edit
public abstract partial class MyPageBase : IHtmxComponent
{
protected abstract void RenderTitle(HtmxRenderContext context);
protected abstract void RenderBody(HtmxRenderContext context);
// static HTML segments stored as ReadOnlySpan<byte> for zero-allocation output
private static ReadOnlySpan<byte> _part0 => new byte[] { ... };
private static ReadOnlySpan<byte> _part1 => new byte[] { ... };
private static ReadOnlySpan<byte> _part2 => new byte[] { ... };
public void Render(HtmxRenderContext context)
{
context.Writer.WriteUtf8(_part0); // <div class="p-6"><h1>
RenderTitle(context.Next());
context.Writer.WriteUtf8(_part1); // </h1><p>
RenderBody(context.Next());
context.Writer.WriteUtf8(_part2); // </p></div>
}
}
Your job is to write the concrete class that implements each RenderXxx method.
Step 1 — Create the .htmx template
Create Htmx.ApiDemo/Templates/MyPage.htmx:
<div class="p-6 space-y-4">
<h1 class="text-2xl font-bold text-foreground">$$Heading$$</h1>
<p class="text-muted-foreground">$$Description$$</p>
</div>
Rules:
- Slot names are PascalCase and surrounded by
$$— e.g.$$MySlot$$ - A slot can hold plain text, HTML, or another rendered component
- The file must be saved in
Templates/(or a subfolder) so the.csprojAdditionalFilesglob picks it up
Step 2 — Create the .htmx.cs code-behind
Create Htmx.ApiDemo/Templates/MyPage.htmx.cs:
using Immediate.Apis.Shared;
using Immediate.Handlers.Shared;
namespace Htmx.ApiDemo.Templates;
// Concrete template — inherits from the generated base
public sealed class MyPage : MyPageBase
{
private byte[] _headingData = [];
private byte[] _descriptionData = [];
// Use `init`-only setters to pre-encode strings to UTF-8 bytes once
public required string Heading { init => _headingData = value.ToUtf8Bytes(); }
public required string Description { init => _descriptionData = value.ToUtf8Bytes(); }
protected override void RenderHeading(HtmxRenderContext context)
=> context.Writer.WriteUtf8(_headingData);
protected override void RenderDescription(HtmxRenderContext context)
=> context.Writer.WriteUtf8(_descriptionData);
}
// Minimal API handler — discovered and registered by the source generator
[Handler]
[MapGet("/my-page")]
public static partial class GetMyPageHandler
{
public record Query; // add route/query parameters here if needed
private static ValueTask HandleAsync(
Query query,
IHttpContextAccessor httpContextAccessor,
CancellationToken token)
{
var ctx = httpContextAccessor.HttpContext
?? throw new InvalidOperationException("HttpContext is not available.");
var page = new MyPage
{
Heading = "My New Page",
Description = "This is a minimal example page."
};
// WriteHtmxPage: full HTML shell for direct browser loads,
// bare fragment for HTMX partial swaps (HX-Request header present)
ctx.WriteHtmxPage(page, title: "My Page", appName: "HtmxApp", pageTitle: "My Page");
return ValueTask.CompletedTask;
}
}
Step 3 — Add a sidebar link (optional but typical)
Open Templates/MainLayout.htmx and add a nav entry inside the <nav> block. Existing entries look like this:
<a href="/my-page"
hx-get="/my-page" hx-target="#main-view" hx-push-url="true"
class="sidebar-link group flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium
text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground">
<!-- inline SVG icon here -->
My Page
</a>
Key HTMX attributes:
hx-get— makes the navigation a partial swap instead of a full page reloadhx-target="#main-view"— replaces only the content area, keeping the sidebar in placehx-push-url="true"— updates the browser URL bar so deep-links still work
How WriteHtmxPage decides what to render
Request has HX-Request header?
YES → render bare fragment + set HX-Title response header (browser tab title updates)
NO → wrap fragment in MainLayout (full HTML page with sidebar, navbar, etc.)
The logic lives in HtmxPageExtensions.WriteHtmxPage. You never need to fork on this yourself.
Slots that hold components
A slot does not have to render plain text. If you need to embed a reusable component, assign the component instance and call Render from the override:
public IHtmxComponent MyCard { get; }
public MyPage(IHtmxComponent myCard)
{
MyCard = myCard;
}
protected override void RenderMyCard(HtmxRenderContext context)
=> MyCard.Render(context);
See 03-creating-a-component.md for the full component pattern.
Checklist
MyPage.htmxcreated inTemplates/MyPage.htmx.cscreated with a class inheritingMyPageBase- Each
$$Slot$$has a matchingRenderSlotoverride [Handler]+[MapGet(...)](orMapPostetc.) on the handler classctx.WriteHtmxPage(...)called fromHandleAsync- Build once — if a slot is missing its override, the compiler will tell you