Files
Htmx/docs/05-form-submission.md
T
2026-05-04 19:57:48 +05:00

7.4 KiB

Form Submission and AppJsonSerializerContext

This guide explains how to wire a form POST endpoint, why AppJsonSerializerContext must be kept up to date, and what happens if you forget.

How form POST endpoints work

Form submissions use standard HTML method="post" forms. The server-side handler receives the posted fields as a strongly-typed record using [FromForm] bindings:

[Handler]
[MapPost("/login")]
public static partial class PostLoginHandler
{
    public record Command(
        [property: FromForm] string Email,
        [property: FromForm] string Password
    );

    private static async ValueTask HandleAsync(
        [AsParameters] Command command,
        IHttpContextAccessor httpContextAccessor,
        IAntiforgery antiforgery,
        AuthService authService,
        CancellationToken token)
    {
        // ...
    }
}

Key parts:

  • [property: FromForm] on each record property tells the Minimal API binder to read the value from the form body, not from the route or query string
  • [AsParameters] on the Command argument tells Minimal API to bind the record's properties individually rather than deserializing the whole body as JSON
  • The handler is discovered and registered by the Immediate.Apis source generator — no app.MapPost(...) call is needed

Antiforgery tokens

All mutating form POST endpoints must be protected against CSRF. The middleware chain includes app.UseAntiforgery() (added in Program.cs), which validates the __RequestVerificationToken field automatically for any POST/PUT/DELETE form submission.

To include the token in a form rendered server-side:

// Inside a page/component constructor, inject IAntiforgery:
var afTokens = antiforgery.GetAndStoreTokens(ctx);
var tokenField = $"""<input type="hidden" name="__RequestVerificationToken" value="{System.Web.HttpUtility.HtmlAttributeEncode(afTokens.RequestToken)}" />""";

The MainLayout constructor and all auth page constructors already do this. Any new form page must follow the same pattern.


Why AppJsonSerializerContext is required

The project uses WebApplication.CreateSlimBuilder, which enables the Minimal API Request Delegate Generator. This generator produces the endpoint binding code at compile time instead of using runtime reflection.

For form-body binding to work under AOT, the JSON serializer's type resolver chain must know about every request/response type it will encounter. This is configured in Program.cs:

builder.Services.ConfigureHttpJsonOptions(options =>
{
    options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default);
});

AppJsonSerializerContext is a source-generated JsonSerializerContext. It is declared in AppJsonSerializerContext.cs:

[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(PostLoginHandler.Command),    TypeInfoPropertyName = "LoginCommand")]
[JsonSerializable(typeof(PostRegisterHandler.Command), TypeInfoPropertyName = "RegisterCommand")]
[JsonSerializable(typeof(PostLogoutHandler.Command),   TypeInfoPropertyName = "LogoutCommand")]
internal partial class AppJsonSerializerContext : JsonSerializerContext
{
}

The [JsonSerializable] attribute tells the source generator to emit the serialization metadata for that type at compile time. Without this, the runtime falls back to reflection — which is stripped under AOT and will throw at runtime.


What breaks if you forget to register a type

If you add a new form POST endpoint with a new Command record and do not register the record in AppJsonSerializerContext:

  • A dotnet build will succeed and may even run fine in Development with the JIT
  • A dotnet publish -c Release (AOT) will either emit a trim warning or silently produce a binary that throws NotSupportedException: Serialization and deserialization of ... is not supported at runtime when the endpoint is first hit

This is one of the most common mistakes when adding new endpoints.


Step-by-step: adding a new form POST

1. Define the command record

[Handler]
[MapPost("/contact")]
public static partial class PostContactHandler
{
    public record Command(
        [property: FromForm] string Name,
        [property: FromForm] string Message
    );

    private static ValueTask HandleAsync(
        [AsParameters] Command command,
        IHttpContextAccessor httpContextAccessor,
        CancellationToken token)
    {
        // handle the form submission
        var ctx = httpContextAccessor.HttpContext!;
        ctx.Response.Redirect("/");
        return ValueTask.CompletedTask;
    }
}

2. Register the command in AppJsonSerializerContext

Open Htmx.ApiDemo/AppJsonSerializerContext.cs and add a [JsonSerializable] entry:

[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(PostLoginHandler.Command),    TypeInfoPropertyName = "LoginCommand")]
[JsonSerializable(typeof(PostRegisterHandler.Command), TypeInfoPropertyName = "RegisterCommand")]
[JsonSerializable(typeof(PostLogoutHandler.Command),   TypeInfoPropertyName = "LogoutCommand")]
[JsonSerializable(typeof(PostContactHandler.Command),  TypeInfoPropertyName = "ContactCommand")]  // ← add this
internal partial class AppJsonSerializerContext : JsonSerializerContext
{
}

TypeInfoPropertyName is optional but recommended — it gives the generated type-info property a readable name and avoids collisions when two commands have the same type name in different namespaces.

3. Include the antiforgery token in the form template

<!-- Templates/Contact.htmx -->
<form method="post" action="/contact" class="space-y-4">
  $$AntiforgeryToken$$
  <input name="name"    type="text"     placeholder="Your name" />
  <input name="message" type="text"     placeholder="Message" />
  <button type="submit">Send</button>
</form>
// Templates/Contact.htmx.cs
public sealed class Contact : ContactBase
{
    private readonly byte[] _afTokenData;

    public Contact(string? afToken = null)
    {
        _afTokenData = string.IsNullOrEmpty(afToken)
            ? []
            : $"""<input type="hidden" name="__RequestVerificationToken" value="{System.Web.HttpUtility.HtmlAttributeEncode(afToken)}" />""".ToUtf8Bytes();
    }

    protected override void RenderAntiforgeryToken(HtmxRenderContext ctx)
        => ctx.Writer.WriteUtf8(_afTokenData);
}

4. Inject and pass the token from the GET handler

[Handler]
[MapGet("/contact")]
public static partial class GetContactHandler
{
    public record Query;

    private static ValueTask HandleAsync(
        Query _,
        IHttpContextAccessor httpContextAccessor,
        IAntiforgery antiforgery,
        CancellationToken token)
    {
        var ctx     = httpContextAccessor.HttpContext!;
        var afTokens = antiforgery.GetAndStoreTokens(ctx);
        ctx.WriteHtmxPage(new Contact(afToken: afTokens.RequestToken), title: "Contact");
        return ValueTask.CompletedTask;
    }
}

Checklist

  • Command record properties use [property: FromForm]
  • Handler uses [AsParameters] on the command
  • Command type is registered in AppJsonSerializerContext with [JsonSerializable]
  • Form template includes the antiforgery hidden input
  • GET handler resolves IAntiforgery, calls GetAndStoreTokens, and passes the token to the template
  • Tested with dotnet publish -c Release (not just dotnet run) before considering it done