# Form Submission Think of a form submission as **sending a letter with a security seal**. The form is your letter, the fields are the contents, and the antiforgery token is the wax seal that proves the letter genuinely came from your site and not from a malicious third party trying to impersonate the user. This guide walks through wiring up a form POST from start to finish. --- ## What you want to achieve By the end of this guide you will have a working form that: - Posts data to a typed C# handler - Is protected against CSRF attacks with an antiforgery token - Registers its types with `AppJsonSerializerContext` so AOT compilation does not break it --- ## How a form POST works Forms use standard HTML `method="post"`. The browser serialises the form fields and sends them as a URL-encoded body. On the server, each field is read from that body and bound to a strongly-typed C# `record`. ```csharp // A handler for POST /contact public static class PostContactHandler { // The Command record maps exactly to your form field names public record Command( [property: FromForm] string Name, [property: FromForm] string Message ); public static void Map(IEndpointRouteBuilder app) { app.MapPost("/contact", Handle); } private static IResult Handle( [AsParameters] Command command, HttpContext ctx) { // command.Name and command.Message are already populated ctx.Response.Redirect("/"); return Results.Empty; } } ``` Key points: - `[property: FromForm]` on each record property tells the binder to read that value from the form body, not from the URL or route - `[AsParameters]` on the `Command` argument tells Minimal API to bind each property individually instead of trying to deserialize the whole body as a single JSON object - The handler is a plain static method — no special base class, no framework magic Register it in `Program.cs`: ```csharp PostContactHandler.Map(app); ``` --- ## Antiforgery tokens Every mutating form (POST, PUT, DELETE) must include an antiforgery token. This is the wax seal that proves the form was generated by your server — not forged by a third-party page trying to submit on the user's behalf (a CSRF attack). The middleware `app.UseAntiforgery()` in `Program.cs` validates this token automatically on every mutating request. If the token is missing or wrong, the request is rejected before your handler even runs. To include the token in a form, inject `IAntiforgery` into the GET handler that renders the page, then pass the token to the template: ```csharp // GET handler that renders the form page private static IResult HandleGet( HttpContext ctx, IAntiforgery antiforgery) { var tokens = antiforgery.GetAndStoreTokens(ctx); var page = new ContactPage(antiforgeryToken: tokens.RequestToken ?? ""); ctx.WriteHtmxPage(page, title: "Contact"); return Results.Empty; } ``` In the template or component, render the token as a hidden field: ```csharp // ContactPage.htmx.cs public ContactPage(string antiforgeryToken) { // HTML-encode the token value — it is user-visible in the source _tokenFieldData = $""" """.ToUtf8Bytes(); } ``` --- ## AppJsonSerializerContext — the AOT requirement The project uses `WebApplication.CreateSlimBuilder`, which generates endpoint binding code at compile time instead of using runtime reflection. For this to work, the serializer must know about every type it might encounter at compile time too. Every `Command` record you create must be registered in `AppJsonSerializerContext.cs`: ```csharp // AppJsonSerializerContext.cs [JsonSerializable(typeof(string))] [JsonSerializable(typeof(PostLoginHandler.Command), TypeInfoPropertyName = "LoginCommand")] [JsonSerializable(typeof(PostRegisterHandler.Command), TypeInfoPropertyName = "RegisterCommand")] [JsonSerializable(typeof(PostContactHandler.Command), TypeInfoPropertyName = "ContactCommand")] // ← add this internal partial class AppJsonSerializerContext : JsonSerializerContext { } ``` `TypeInfoPropertyName` gives the generated property a readable name and prevents collisions if two commands happen to share the same type name. ### What happens if you forget - `dotnet run` (Debug, JIT) — works fine, JIT fills in the gaps at runtime - `dotnet publish -c Release` (AOT) — either emits a trim warning during build, or throws `NotSupportedException` at runtime the first time the endpoint is hit This is the single most common mistake when adding a new form endpoint. Add the `[JsonSerializable]` entry at the same time as you write the `Command` record. --- ## Complete example — a contact form ### 1. The template ```html
``` ### 2. The page class ```csharp // Templates/ContactPage.htmx.cs namespace Htmx.ApiDemo.Templates; public sealed class ContactPage : ContactPageBase { private readonly byte[] _tokenData; public ContactPage(string antiforgeryToken) { _tokenData = string.IsNullOrEmpty(antiforgeryToken) ? [] : $"""""" .ToUtf8Bytes(); } protected override void RenderToken(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_tokenData); } ``` ### 3. The GET handler (renders the form) ```csharp public static class GetContactHandler { public static void Map(IEndpointRouteBuilder app) => app.MapGet("/contact", Handle); private static IResult Handle(HttpContext ctx, IAntiforgery antiforgery) { var tokens = antiforgery.GetAndStoreTokens(ctx); ctx.WriteHtmxPage(new ContactPage(tokens.RequestToken ?? ""), title: "Contact"); return Results.Empty; } } ``` ### 4. The POST handler (processes the form) ```csharp public static class PostContactHandler { public record Command( [property: FromForm] string Name, [property: FromForm] string Message ); public static void Map(IEndpointRouteBuilder app) => app.MapPost("/contact", Handle); private static IResult Handle([AsParameters] Command command, HttpContext ctx) { // process command.Name and command.Message here ctx.Response.Redirect("/"); return Results.Empty; } } ``` ### 5. Register both in `Program.cs` ```csharp GetContactHandler.Map(app); PostContactHandler.Map(app); ``` ### 6. Register the Command in `AppJsonSerializerContext.cs` ```csharp [JsonSerializable(typeof(PostContactHandler.Command), TypeInfoPropertyName = "ContactCommand")] ``` --- ## Checklist - [ ] `Command` record properties use `[property: FromForm]` - [ ] Handler method uses `[AsParameters]` on the `Command` parameter - [ ] `Command` type added to `AppJsonSerializerContext` with `[JsonSerializable]` - [ ] Form template includes the antiforgery hidden input - [ ] GET handler calls `antiforgery.GetAndStoreTokens(ctx)` and passes the token to the page - [ ] Both GET and POST handlers registered in `Program.cs` - [ ] Tested with `dotnet publish -c Release` before merging