Learn
Forms
POST in the same page.go, automatic CSRF protection and the redirect-after-write pattern.
A page can receive forms by exporting POST (or PUT, PATCH, DELETE) next to Page. Trilha verifies the CSRF token before calling your function.
The page with the form#
app/events/new/page.go:
package new
import (
"strings"
"github.com/emersonjoe/trilha"
"github.com/emersonjoe/trilha/h"
"agenda/internal/events"
)
func Page(c *trilha.Ctx) (h.Node, error) {
c.SetTitle("New event")
msg := c.Query("error")
return h.Fragment(
h.H1(h.Text("New event")),
h.If(msg != "", h.P(h.Class("error"), h.Text(msg))),
h.Form(h.Method("post"), h.Action("/events/new"),
trilha.CSRFInput(c),
h.Label(h.For("name"), h.Text("Name")),
h.Input(h.ID("name"), h.Name("name"), h.Required(), h.Autofocus()),
h.Label(h.For("city"), h.Text("City")),
h.Input(h.ID("city"), h.Name("city")),
h.Button(h.Type("submit"), h.Text("Publish")),
),
), nil
}
func POST(c *trilha.Ctx) error {
if err := c.FormErr(); err != nil {
return err // 400 on an invalid form, 413 if it exceeded the limit
}
name := strings.TrimSpace(c.Form("name"))
if name == "" {
return c.Redirect("/events/new?error=Enter+a+name")
}
ev := events.Create(name, c.Form("city"))
return c.Redirect("/events/" + ev.Slug)
}func Page(c *trilha.Ctx) (h.Node, error) {
return h.Form(h.Method("post"), h.Action("/events/new"),
trilha.CSRFInput(c),
h.Label(h.For("name"), h.Text("Event name")),
h.Input(h.ID("name"), h.Name("name"), h.Required()),
h.Button(h.Type("submit"), h.Text("Publish")),
), nil
}
func POST(c *trilha.Ctx) error {
ev := agenda.Create(c.Form("name"))
return c.Redirect("/events/" + ev.Slug)
}What happens on submit#
- The browser sends
POST /events/newwith the fields and the_csrf. - Trilha compares
_csrfwith thetrilha_csrfcookie (constant time). Different or missing: 403, andPOSTdoes not even run. POSTruns and returnsc.Redirect(...): a 303 See Other response. The browser does aGETon the new URL. Reloading the page does not resubmit the form.
trilha.CSRFInput(c) creates the cookie on the first render and the hidden field. JavaScript clients may send the same value in the X-CSRF-Token header.
Validation and messages#
The example above checks the name by hand and answers through the query string, which keeps the POST → redirect → GET pattern and works without JavaScript. As soon as a form has more than a couple of fields, put the rules on the struct instead: the validate tag sits next to the field it talks about, and Bind applies every rule before returning.
type entry struct {
Name string `form:"name" validate:"required,min=3,max=80"`
Email string `form:"email" validate:"required,email"`
Seats int `form:"seats" validate:"min=1,max=10"`
}
func POST(c *trilha.Ctx) error {
var in entry
if err := c.Bind(&in); err != nil {
if errs, ok := err.(trilha.FieldErrors); ok {
// Same page, 422, values kept, one message per field.
return c.Render(http.StatusUnprocessableEntity, form(c, in, errs))
}
return err
}
ev := events.Create(in.Name, in.Email, in.Seats)
return c.Redirect("/events/" + ev.Slug)
}FieldErrors is a map[string]string (field → message), so the form reads it straight: ui.Errors(errs, "email") prints the message and ui.InvalidIf(errs, "email") marks the input with aria-invalid. Nothing short-circuits — the person sees every mistake at once, not one per submit.
The rules are required, min, max, len, email, url, oneof and eqfield; the validation reference has what each one means per type. Two of them are worth spelling out here:
- Every rule but
requiredignores an empty value. An optional field withmin=3only answers for what somebody typed. requiredmeans "not the zero value". Where0orfalseis a real answer, declare the field as a pointer (*int): absent stays absent, and zero arrives as zero.
Messages come in English. An app that speaks another language calls trilha.UseValidationPTBR() in Setup, or writes its own into trilha.ValidationMessages.
When the tag is not enough#
A rule about the shape of a value belongs to the type, and then every form that uses the type is covered:
type Money string
func (m Money) Validate() error {
if v, err := ParseMoney(string(m)); err != nil || v <= 0 {
return errors.New("must be greater than zero")
}
return nil
}A rule that reads two fields belongs to the struct: give it a Validate() error and it runs at the end, only when no field failed. A rule you repeat across projects becomes a tag of your own:
trilha.AddRule("cep", func(f trilha.Field) bool { return validZIP(f.Text) })
trilha.ValidationMessages["cep"] = "invalid ZIP code"Here is where this stops: the tag says what a value accepts, not what the system accepts. "This account exists" and "this room is free that night" are questions for your data, and they stay in your package. Run them after Bind and merge the result into the same FieldErrors, so both kinds of message reach the person in the same response.
When the form grows#
Three dependants, five order rows, one permission per module: the number of fields is not known when you write the page. The name of the input carries the position, and Bind fills a slice of structs from it — items[0].name, items[1].name, in order:
type Row struct {
Name string `form:"name" validate:"required,max=40"`
Qty int `form:"qty" validate:"min=1"`
}
var in struct {
Items []Row `form:"items" validate:"minitems=1,maxitems=50"`
}Draw the rows you have plus one empty one at the end, and the person gets the next row without any JavaScript; an empty row is not an item, so drop it before saving. A key instead of an index gives a map — perm[docs]=2 fills a map[string]int. The name of the input is also the key of the message: ui.Errors(errs, "items[1].qty") puts it next to the field that is wrong, and BindJSON produces the very same key.
Sometimes the whole form is unknown — the step of a workflow, the settings of a tenant, a public form somebody built in an admin screen. Then the form is data:
values, err := trilha.BindSchema(c, schema)
errs, ok := err.(trilha.FieldErrors)
if err != nil && !ok {
return err
}
if ok {
return c.Render(http.StatusUnprocessableEntity, page(c, values, errs))
}schema is a trilha.Schema, which is a list of SchemaField — it decodes straight from JSON, so it can live in a table. The values come back as text, the messages come back in the same FieldErrors as any other form, and ui.SchemaForm(schema, values, errs) draws the fields inside a <form> that is still yours. examples/cadastro has both: a list of dependants and a screen whose schema is JSON.
Methods the browser does not send#
HTML forms only send GET and POST. For "delete", export DELETE for API clients and make the page's POST call the same logic:
func DELETE(c *trilha.Ctx) error {
if !events.Delete(c.Param("slug")) {
return trilha.ErrNotFound
}
return c.Redirect("/events")
}
func POST(c *trilha.Ctx) error { return DELETE(c) }Limits#
The request body is limited to 1 MiB by default (Config.MaxBodyBytes). Above that the response is 413 before your code runs.
Many languages#
A form that serves migrants is read by people who do not read the language the office writes in. Config.Locales makes the language a property of the request, and c.T says the form's own sentences in it.
// app/setup.go
//go:embed i18n
var messages embed.FS
func Setup(a *trilha.App) error {
cfg := a.Config()
cfg.Locales = []string{"pt-BR", "ht", "fr"} // the first one is the default
cfg.LocaleOf = sessao.Flow.LocaleOf // what the person picked, from the session
cat, err := trilha.LoadCatalog(messages, "i18n")
if err != nil {
return err
}
cat.Fallback = map[string]string{"ht": "fr"} // Creole reads French before Portuguese
cfg.Catalog = cat
return nil
}c.Locale() negotiates once per request: the preference in the session, then ?lang=ht (the link on the page, which leaves a trilha_lang cookie so the next click stays in the language), then that cookie, then Accept-Language — at a public counter the browser's language is the counter's, not the person's, which is why the session and the link come first — and then the default.
// i18n/ht.json: {"inscricao.titulo": "Enskripsyon", "inscricao.nome": "Non ou"}
func Page(c *trilha.Ctx) (h.Node, error) {
return ui.Form(h.Method("post"), trilha.CSRFInput(c),
ui.Legend(h.Text(c.T("inscricao.titulo"))),
ui.Field(ui.Label("nome", c.T("inscricao.nome")), ui.Input(h.Name("nome"))),
ui.Field(h.Text(c.T("inscricao.vagas", n))), // "1 vaga" / "4 vagas"
ui.Submit(h.Text(c.T("inscricao.enviar"))),
), nil
}The layout writes h.Lang(c.Locale()), so the <html> tag says the language the page is actually in — which is what a screen reader and a browser's translation both read. A key the locale has not translated falls through the chain (ht → fr → pt-BR) and never reaches the screen as a raw key; trilha i18n missing ht lists what is left to translate, and trilha check fails on a key that is missing from the default locale.
Challenge#
Add a numeric seats field to the form, accept only 1 to 10, and show the message next to the field instead of on the next page.
Show solution
type entry struct {
Name string `form:"name" validate:"required,min=3"`
City string `form:"city"`
Seats int `form:"seats" validate:"required,min=1,max=10"`
}
// In POST, c.Bind(&in) returns trilha.FieldErrors, and the page renders again
// with c.Render(http.StatusUnprocessableEntity, ...) and ui.Errors(errs, "seats").