ESC
Type to search...
S
Soli Docs

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

app/views/posts/new.html.slv
<%- 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": trueAdds enctype="multipart/form-data" (required for file_field)
anything elseBecomes 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_fieldthe matching <input> type
f.password_field("password")<input type="password"> — never prefills
f.date_field / f.datetime_fielddate / datetime-local inputs
f.hidden_field / f.file_fieldhidden 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 -%>