Co-authored-by: Copilot <copilot@github.com>
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 theCommandargument 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.Apissource generator — noapp.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 buildwill 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 throwsNotSupportedException: Serialization and deserialization of ... is not supportedat 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
Commandrecord properties use[property: FromForm]- Handler uses
[AsParameters]on the command Commandtype is registered inAppJsonSerializerContextwith[JsonSerializable]- Form template includes the antiforgery hidden input
- GET handler resolves
IAntiforgery, callsGetAndStoreTokens, and passes the token to the template - Tested with
dotnet publish -c Release(not justdotnet run) before considering it done