Forms & CSRF Protection
A Rails-style form builder that derives URLs and verbs from your records, prefills values, renders validation errors, and carries a verified per-session CSRF token.
Forms that know your records
form_with(post) POSTs a new record to /posts and PATCHes a persisted one to /posts/<key> — through the hidden _method field the server honors, since HTML forms can only express GET and POST. Every non-GET form embeds a per-session CSRF token that the server verifies with a constant-time compare.
1 Quick start
<%- form_with(post) do |f| -%>
<%- f.error_summary() %>
<%- f.label("title") %>
<%- f.text_field("title", {"placeholder": "Title"}) %>
<%- f.errors_for("title") %>
<%- f.text_area("body") %>
<%- f.check_box("published") %>
<%- f.submit("Save") %>
<%- end -%>
The do |f| block binds the builder (any name; a bare do gives an implicit f) and wraps the body in the <form> tag, the _method override, and the CSRF token. The opener works in any tag style, and a -%> closer swallows the newline after the tag (on any template tag). Use <%- (raw output) for every builder call — the helpers return HTML, and <%= would escape it into visible text; field values are always escaped by the builder itself. The explicit form (f = form_with(post) then f.open() / f.close()) remains available for forms assembled across non-contiguous markup.
2 form_with
Both arguments are optional. With no record, pass "url": form_with(null, {"url": "/search", "method": "get"}). GET forms skip both the CSRF token and the _method field.
| Option | Effect |
|---|---|
"url" | Override the derived action URL (required with no record) |
"method" | "post" / "patch" / "put" / "delete" / "get" — overrides the derived verb |
"multipart": true | Adds enctype="multipart/form-data" (required for file_field) |
| anything else | Becomes an attribute on the <form> tag ("class", "id", data-*, …) |
3 Field helpers
Every helper takes (field, options). Options become HTML attributes (true renders a bare attribute, false/nil skips it). Values prefill from record[field]; an errored field gains a field-error class and aria-invalid="true".
| Helper | Renders |
|---|---|
f.text_field / f.email_field / f.number_field | the matching <input> type |
f.password_field("password") | <input type="password"> — never prefills |
f.date_field / f.datetime_field | date / datetime-local inputs |
f.hidden_field / f.file_field | hidden input / file input (pair with "multipart": true) |
f.text_area("body") | <textarea> with the escaped value as content |
f.check_box("published") | checkbox, value="true", checked when the field is true/"true" |
f.radio_button("size", "xl") | radio, checked when the field equals the value |
f.select("status", choices) | <select> — strings or [label, value] pairs, current value selected |
f.label("title", text?, opts?) | <label> — text defaults to a humanized field name |
f.submit("Save", opts?) | <button type="submit"> |
f.error_summary(opts?) / f.errors_for(field) | validation-error list / inline per-field messages (empty when valid) |
Top-level names are flat (name="title" → params["title"]), and bracket names nest: the server parses author[name] into params["author"]["name"], tags[] into an ordered array, and items[][sku] into an array of hashes — Rack-style, for form bodies, multipart bodies, and query strings alike (see Nested Parameters). select takes {"multiple": true} (its name gains []), and every field helper accepts a {"name": "..."} override. An unchecked check_box submits nothing; read it as params["published"] == "true". For select pairs, build the choices in a <% %> code block and put a space between the brackets — a leading [[ lexes as a Lua-style raw string, not a nested array:
<% choices = [ ["On time", "up"], ["Late", "late"] ] %>
<%- f.select("status", choices) %>
Nested forms: SoliDB documents nest, and so do forms. f.fields_for("author") yields a sub-builder whose fields render as author[name] and prefill from record["author"]["name"] — the form mirrors the document shape 1:1. Collections take an index: f.fields_for("items", 0) renders items[0][sku].
<%- form_with(post) do |f| -%>
<%- f.text_field("title") %>
<%- f.fields_for("author") do |author| -%>
<%- author.label("name") %>
<%- author.text_field("name") %>
<%- end -%>
<%- f.submit("Save") %>
<%- end -%>
Whitelist the nested shape in the controller with permit(params, {"title": true, "author": {"name": true}, "tags": []}) — SoliDB is schemaless, so unfiltered mass-assignment would persist anything a client posts. soli generate scaffold writes _permit_params in this style; the full rules live in Request Parameters → permit().
4 button_to & method override
State-changing links belong in forms, not <a> tags. button_to renders a one-button form with the CSRF token and _method override built in:
<%- button_to("Delete", "/posts/" + post["_key"].to_s, {
"method": "delete", "confirm": "Are you sure?",
"class": "btn-danger", "form_class": "inline"
}) %>
A POST whose form body carries _method=PUT|PATCH|DELETE is routed — and dispatched to your controller — as that verb, so resources("posts") update/destroy routes work from plain HTML forms. Only those three verbs are honored, only on POST, and only for form content types — JSON APIs are never affected.
5 CSRF tokens
The baseline protection is the Origin/Referer same-site gate (Routing — CSRF). Forms add a second, Rails-style layer: any state-changing request that carries a token — the _csrf_token field or the X-CSRF-Token header — must match the session's token (constant-time compare) or the request is rejected with 403. Requests without a token keep the Origin/Referer posture, so existing apps and JSON APIs are unaffected.
| Helper | Use |
|---|---|
csrf_token() | the per-session token — builtin, controllers and views, created on first use |
csrf_field() | hidden input — f.open() and button_to embed it automatically |
csrf_meta_tag() | <meta name="csrf-token"> for layouts, so fetch/htmx can send the header |
To make tokens mandatory for browser form posts, set SOLI_CSRF_TOKENS=require — form posts without a valid token get a 403. JSON traffic is never token-gated; skip_csrf("/path") and SOLI_DISABLE_CSRF opt-outs apply to both layers. For htmx, wire the header once:
<body hx-headers='{"X-CSRF-Token": "<%= csrf_token() %>"}'>
6 Validation errors & partials
A failed create/save leaves _errors on the record — re-render the form and the builder does the rest: f.error_summary() lists every message, f.errors_for("title") renders inline spans, and errored fields get the field-error class. Partials render in a fresh scope, so pass the builder explicitly:
<%- form_with(post) do |f| -%>
<%- partial("posts/form", { "post": post, "f": f }) %>
<%- end -%>