Co-authored-by: Copilot <copilot@github.com>
6.2 KiB
Creating a New Page
Think of a page as a form letter — the template is the letter with blanks left for personalisation, and your C# class is the person who fills those blanks in before the letter is sent. The build system generates all the plumbing between the two; you just write the template and the class.
What you want to achieve
By the end of this guide you will have a new page at a URL like /dashboard that:
- Shows your own custom HTML
- Loads instantly as a full page when you visit the URL directly
- Swaps in as a smooth partial update when navigated to from the sidebar
- Accepts data you pass to it from C#
The two files every page needs
| File | What it is |
|---|---|
Templates/MyPage.htmx |
The letter template — HTML with $$Slot$$ blanks |
Templates/MyPage.htmx.cs |
The person filling in the blanks — C# class + route handler |
The build system (Htmx.SourceGenerator) reads your .htmx file and generates an abstract C# class with one RenderXxx() method per $$Slot$$. Your job is to inherit that class and implement each method.
Step 1 — Write the 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 for slots:
- Names are PascalCase surrounded by
$$— e.g.$$MySlot$$ - A slot can contain plain text, HTML, or a rendered component
- The file must live inside
Templates/so the build picks it up automatically
After saving this file and building, the generator emits MyPageBase — a class you will never edit but will inherit from.
Step 2 — Write the code-behind
Create Htmx.ApiDemo/Templates/MyPage.htmx.cs:
namespace Htmx.ApiDemo.Templates;
public sealed class MyPage : MyPageBase
{
private readonly byte[] _headingData;
private readonly byte[] _descriptionData;
public MyPage(string heading, string description)
{
// Convert strings to UTF-8 bytes once in the constructor.
// The Render methods then just write those bytes — no allocations at request time.
_headingData = heading.ToUtf8Bytes();
_descriptionData = description.ToUtf8Bytes();
}
protected override void RenderHeading(HtmxRenderContext ctx)
=> ctx.Writer.WriteUtf8(_headingData);
protected override void RenderDescription(HtmxRenderContext ctx)
=> ctx.Writer.WriteUtf8(_descriptionData);
}
The pattern here is deliberate: do all string work (formatting, encoding) in the constructor, so that Render is nothing but memory writes. This keeps request handling fast.
Step 3 — Write the route handler
Route handlers live in the same .htmx.cs file. They are plain static methods registered with Minimal API — no special framework, no base class, no attributes from removed packages:
namespace Htmx.ApiDemo.Templates;
public static class MyPageEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
app.MapGet("/my-page", Handle);
}
private static IResult Handle(HttpContext ctx)
{
var page = new MyPage(
heading: "My New Page",
description: "This is a minimal example."
);
ctx.WriteHtmxPage(page, title: "My Page");
return Results.Empty;
}
}
Then register it in Program.cs alongside the other endpoint registrations:
MyPageEndpoints.Map(app);
Step 4 — Add a sidebar link (optional but typical)
Open Templates/MainLayout.htmx and add a nav entry inside the <nav> block:
<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">
My Page
</a>
The three HTMX attributes do the heavy lifting:
| Attribute | What it does |
|---|---|
hx-get="/my-page" |
Fetches the page as a partial instead of a full reload |
hx-target="#main-view" |
Drops the response into the content area, leaving the sidebar untouched |
hx-push-url="true" |
Updates the browser URL bar so bookmarks and back-button still work |
How the app knows whether to send a full page or just the fragment
When WriteHtmxPage is called it checks for the HX-Request header that HTMX sends on every HTMX-triggered request:
Direct browser visit (no HX-Request header)
→ full HTML: <html><head>...</head><body><sidebar/><main>YOUR PAGE</main></body></html>
HTMX sidebar click (HX-Request: true)
→ just your fragment: <div class="p-6 space-y-4">...</div>
→ plus an HX-Title header so the browser tab title still updates
You never need to branch on this yourself. WriteHtmxPage handles it.
Embedding a component inside a page
Slots are not limited to text. If you want to place a reusable component inside a slot, store it as a field and call Render from the override:
public sealed class MyPage : MyPageBase
{
private readonly byte[] _headingData;
private readonly IHtmxComponent _statusBadge;
public MyPage(string heading, string status)
{
_headingData = heading.ToUtf8Bytes();
_statusBadge = new Badge(status, variant: "secondary");
}
protected override void RenderHeading(HtmxRenderContext ctx)
=> ctx.Writer.WriteUtf8(_headingData);
protected override void RenderStatusBadge(HtmxRenderContext ctx)
=> _statusBadge.Render(ctx.Next()); // ctx.Next() tracks nesting depth
}
In the template:
<div class="flex items-center gap-3">
<h1>$$Heading$$</h1>
$$StatusBadge$$
</div>
See 03-creating-a-component.md for how to build your own components.
Checklist
MyPage.htmxsaved inTemplates/with$$PascalCase$$slotsMyPage.htmx.cshas a class inheritingMyPageBasewith allRenderXxxoverrides- Route handler registered in
Program.cs - Builds cleanly — the compiler will error if any slot override is missing
- Sidebar link added to
MainLayout.htmxif the page needs to be in the nav