Files
Htmx/docs/02-creating-a-page.md
T
2026-05-04 19:57:48 +05:00

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 .csproj AdditionalFiles glob 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;
    }
}

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 reload
  • hx-target="#main-view" — replaces only the content area, keeping the sidebar in place
  • hx-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.htmx created in Templates/
  • MyPage.htmx.cs created with a class inheriting MyPageBase
  • Each $$Slot$$ has a matching RenderSlot override
  • [Handler] + [MapGet(...)] (or MapPost etc.) on the handler class
  • ctx.WriteHtmxPage(...) called from HandleAsync
  • Build once — if a slot is missing its override, the compiler will tell you