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 builder runs in its own environment, so its internal calls to h, attr and friends reach the engine's escaping helpers even when your app defines a helper of the same name. 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. soli new writes this into .env; existing apps stay Origin-only until they set it. 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 -%>