↑ ↓ to navigate
↵ to select
esc to close
Guides Intermediate

Forms with form_for

Build contact and signup forms in Liquid that save submissions as entries, with validation, repopulation and spam protection.

Overview

{% form_for %} renders a form on your site that saves each submission as an entry in one of your collections. You write all of the form's HTML yourself. The tag adds the <form> element, the security token, the submit URL and optional spam protection, and gives you a form object with the blueprint's fields, validation errors and the values the visitor entered.

The collection and blueprint are set on the Form in your account, not in the page. A visitor can't change where a submission is saved.

Setup

  1. Create a blueprint with the fields you want to collect, e.g. name (Text, required), email (Text, required) and message (Text, required). Mark a field as required in its settings to have it checked on the server.
  2. Create a collection to hold submissions and attach the blueprint to it.
  3. Create a Form under Forms in the dashboard, or with MCP forms_save or POST /v1/accounts/:account_id/forms (see Forms API). Pick the collection and blueprint, and give the form a handle, e.g. contact. You can also set a redirect URL, a success message, notification emails, the honeypot and AI spam detection.
  4. Make sure your layout outputs {{ content_for_header }} in <head>. It already loads the CSS and JavaScript that forms use. Only add {% cms_scripts %} if your layout doesn't output content_for_header.
  5. Add {% form_for %} to a template.

Syntax

{% form_for form: "contact" %}
  …your fields…
{% endform_for %}
OptionDescription
formRequired. The Form's handle.
classCSS class for the <form> element
idThe id of the <form> element. By default a random form_<n>_<hex> ID is generated on each render.
redirectThe URL to go to after a successful submission. Overrides the Form's redirect URL.
success_messageThe message shown after a successful submission. Overrides the Form's success message. The default is "Thank you for your submission!"

Option values must use double quotes. form: 'contact' doesn't work: the tag looks for a form named 'contact' (with the quotes), finds none, and renders nothing. The tag also renders nothing if the handle is missing, if no Form has that handle, or if the Form has no collection or blueprint. No other attributes are passed through to the <form> element.

{% form_for form: "contact", class: "space-y-4", id: "contact-form", redirect: "/thanks", success_message: "Thanks! We'll be in touch." %}

What the tag outputs

<form id="…" class="…" method="post" action="/cms/contact/submit"
      enctype="multipart/form-data"
      data-controller="collection-form" data-collection-form-target="form"
      data-action="submit->collection-form#submit">
  <input type="hidden" name="authenticity_token" value="…">
  <input type="hidden" name="redirect_url" value="…">      <!-- only if a redirect is set -->
  <input type="hidden" name="success_message" value="…">
  <!-- honeypot input, only if enabled on the Form -->
  …your block content…
</form>

Submissions go to POST /cms/<form handle>/submit on your site's own domain. Because the form uses multipart/form-data, file inputs for image fields work.

The form object

PropertyDescription
form.idThe <form> element's ID
form.collectionThe collection handle
form.blueprintThe blueprint handle
form.fieldsAll of the blueprint's fields, in order, as field objects
form.field['email'] or form.field.emailOne field by handle. Empty if the handle doesn't exist.
form.errors['email'] or form.errors.emailThe error message for a field after a failed submission
form.errors['_base']Errors that aren't tied to one field (an array)
form.errors.any, form.errors.all, form.errors.full_messagesWhether there are errors, all errors, and all messages
form.has_errorstrue after a failed submission
form.values['email'] or form.values.emailWhat the visitor entered, for refilling the form after an error

Field objects

PropertyDescription
handleField handle
labelThe field's display name
typeField type, e.g. text, rich_text, enumerate, toggle, image
requiredtrue if the field is marked required
placeholder, help_textFrom the field's settings (empty string if not set)
input_typeThe HTML input type from the field's settings (default text)
optionsThe choices for an enumerate field
display_asselect or radio for an enumerate field
max_itemsThe maximum number of items for fields that hold several
idA unique ID for the input, <form id>_<handle>. Use it for <label for>.
nameThe input name to use, fields[<handle>]

Always set name="{{ field.name }}", or write name="fields[handle]" by hand. The server also accepts a bare name="handle", but the fields[…] form can't clash with the form's hidden inputs.

Complete contact form

This example assumes a Form with the handle contact whose blueprint has the fields name, email and message.

{% if notice %}
  <p class="notice">{{ notice }}</p>
{% endif %}
{% if alert %}
  <p class="alert">{{ alert }}</p>
{% endif %}

{% form_for form: "contact", class: "contact-form" %}
  {% assign f = form.field['name'] %}
  <label for="{{ f.id }}">{{ f.label }}</label>
  <input type="text" id="{{ f.id }}" name="{{ f.name }}"
         value="{{ form.values['name'] | escape }}" {% if f.required %}required{% endif %}>
  {% if form.errors['name'] %}<p class="error">{{ form.errors['name'] }}</p>{% endif %}

  {% assign f = form.field['email'] %}
  <label for="{{ f.id }}">{{ f.label }}</label>
  <input type="email" id="{{ f.id }}" name="{{ f.name }}"
         value="{{ form.values['email'] | escape }}" {% if f.required %}required{% endif %}>
  {% if form.errors['email'] %}<p class="error">{{ form.errors['email'] }}</p>{% endif %}

  {% assign f = form.field['message'] %}
  <label for="{{ f.id }}">{{ f.label }}</label>
  <textarea id="{{ f.id }}" name="{{ f.name }}" rows="5"
            {% if f.required %}required{% endif %}>{{ form.values['message'] | escape }}</textarea>
  {% if form.errors['message'] %}<p class="error">{{ form.errors['message'] }}</p>{% endif %}

  <button type="submit">Send</button>
{% endform_for %}

Generating inputs from the blueprint

{% form_for form: "contact" %}
  {% for field in form.fields %}
    <label for="{{ field.id }}">{{ field.label }}</label>
    {% if field.type == "enumerate" %}
      <select id="{{ field.id }}" name="{{ field.name }}">
        {% for option in field.options %}
          <option {% if form.values[field.handle] == option %}selected{% endif %}>{{ option }}</option>
        {% endfor %}
      </select>
    {% else %}
      <input type="{{ field.input_type }}" id="{{ field.id }}" name="{{ field.name }}"
             placeholder="{{ field.placeholder }}" value="{{ form.values[field.handle] | escape }}"
             {% if field.required %}required{% endif %}>
    {% endif %}
    {% if field.help_text != "" %}<small>{{ field.help_text }}</small>{% endif %}
    {% if form.errors[field.handle] %}<p class="error">{{ form.errors[field.handle] }}</p>{% endif %}
  {% endfor %}
  <button type="submit">Send</button>
{% endform_for %}

What happens on submit

Success

  1. A new entry is created in the Form's collection with the Form's blueprint. Its title is "<Form title> - <date and time>", it gets a generated handle, and it is left out of the sitemap. It is saved unpublished, so it has no public page and doesn't appear in {% query %} results or feeds. You see it in the dashboard and through the API and MCP.
  2. Notification emails and contact (CRM) ingestion run if they're set up on the Form. If AI spam detection is on, they wait until the submission is judged not to be spam.
  3. The visitor is redirected to the redirect option, else the Form's redirect URL, else back to the page they came from.
  4. The success message is shown on the next page as {{ notice }}.

If your site is meant to show submissions, such as a guestbook or testimonials, turn on Publish submissions on the Form (publish_submissions: true with forms_save or the Forms API). Its submissions are then saved published. Leave it off for contact forms and sign-ups, where a published submission would put someone's message behind a public URL.

Failure

If a required field is empty or a value can't be saved, nothing is stored. The visitor is sent back to the form page, where:

  • {{ alert }} has all the error messages joined together (e.g. "Email is required").
  • form.errors and form.values are filled in for that one page load, so you can show errors next to each field and refill what the visitor typed.

Output {{ notice }} and {{ alert }} on the page that has the form (or in your layout). If you don't, visitors get no feedback.

Spam protection

  • Honeypot (opt-in): turn on "Enable spam protection (honeypot)" on the Form, or set honeypot_enabled: true with forms_save or the Forms API. form_for then adds a hidden decoy input that people never see. If a bot fills it in, the submission is thrown away without saving, and the bot still sees the normal success message.
  • AI spam detection (opt-in): turn on "Enable AI spam detection" (spam_check_enabled). Each submission is still saved, and gets a spam_status field (pending, ham, spam or maybe). Notification emails and CRM ingestion wait for the result and are skipped for spam. spam_status is never included in form.fields.

Scripts and styles

Forms work without JavaScript. The CMS form script, which {{ content_for_header }} loads automatically, adds:

  • Inline validation of required inputs when the visitor leaves each field
  • Protection against double submission, and a loading state while the form is being sent: the form gets opacity-75 pointer-events-none, and a button marked with data-collection-form-target="submit" is disabled and relabelled "Submitting..."
  • Previews for image file inputs

If your layout doesn't output {{ content_for_header }}, add {% cms_scripts %} instead. You can load only one of the two assets with {% cms_scripts css: "false" %} or {% cms_scripts js: "false" %}.

To get the loading state on your button, add the target to it:

<button type="submit" data-collection-form-target="submit">Send</button>

Don't add data-action="click->collection-form#submit" to the button. The form already runs the handler on submit. Running it on the click as well disables the button while the click is still being handled, and browsers then cancel the submission.

Troubleshooting

  • Nothing renders: check that the handle is double-quoted and matches the Form exactly, and that the Form has both a collection and a blueprint.
  • Submission saved but fields are empty: the input names don't match the blueprint's field handles. Use {{ field.name }}.
  • No success or error message: output {{ notice }} and {{ alert }} in the template or layout.

Related