Liquid forms guide
On this page
The {% form %} tag generates HTML forms with automatic submission handling, CSRF protection, field definitions, and validation error tracking. This guide explains how to register and use forms in your StoreConnect theme.
For a list of all available form types and their fields, see Liquid forms reference.
When to use the form tag
Use the {% form %} tag whenever you need to:
- Collect user input — registration, login, profile updates, checkouts, custom contact forms
- Handle validation — display per-field and form-level errors automatically
- Protect against CSRF — the tag includes automatic CSRF token generation
- Preserve user input — field values are automatically restored after validation failures
- Built-in form types — use one of StoreConnect’s 40+ pre-built forms for accounts, cart, checkout, payments, and more
For purely local UI interactions (opening modals, toggling tabs, showing/hiding elements), use client-side JavaScript instead.
How forms work
When a form is submitted:
- StoreConnect receives the form submission and validates the data
- On success: the form submission is processed and the user is redirected or shown a success page
- On failure: the page re-renders with the same form, validation errors are populated, and user-entered values are preserved
This means every form automatically supports the re-display pattern:
```liquid
{% form “register” %} {% if form.errors.size > 0 %} {% endif %}
<input name=”{{ form.fields[“email”].name }}” value=”{{ form.fields[“email”].value }}”> {% endform %} ```
Form registration and names
StoreConnect provides pre-built forms for common workflows. These forms are registered in the platform and can be used by name:
```liquid
{% form “login” %} {% endform %}
{% form “add-to-cart”, product: current_product %} {% endform %}
{% form “custom-form”, custom_form: my_contact_form %} {% endform %} ```
Each form type has a specific set of fields and parameters. See Liquid forms reference for the complete catalog.
Form structure and field access
### The form drop
Inside a {% form %} block, the form drop provides access to:
| Attribute | Description |
|---|---|
fields |
The form’s fields, accessed by name as form.fields["email"] |
errors |
Form-level validation errors |
path |
The form’s submission URL |
These three are the whole drop. A field is only ever reached through fields.
Accessing fields
Reach every field through fields, with the field name as the key:
```liquid
{{ form.fields[“email”].value }} ```
A bare form.username is not a field. The drop has no such attribute, so the expression renders blank and the rest of the page renders normally. Nothing in the page marks the spot; undefined method username is reported only to the Console.
The key can be a variable, which is what makes it worth using a hash rather than named attributes:
```liquid
{% assign field_name = “email” %} {{ form.fields[field_name].value }} ```
Field properties
Each field has these properties:
| Property | Type | Description |
|---|---|---|
name |
String | HTML name attribute for use in <input> elements |
id |
String | HTML id attribute (auto-generated) |
value |
String | Current value (preserved after validation failure) |
original_value |
String | Value before the current request |
required? |
Boolean | Whether the field is required |
errors |
Array | Validation error messages for this field |
Example: rendering a field
```liquid
```
Validation and error display
Forms validate on submission. Where the errors live is covered in the form tag reference; this section is about putting them on the page.
Two things shape every example below. form.errors holds one FormError per field that failed, not one per message, so displaying them takes a nested loop: iterate the errors, then iterate that error’s full_messages. And a FormError is an object, not a string, so {{ error }} renders the drop rather than the text.
The error that belongs to the form as a whole rather than any input carries the field name base.
A form-level summary
Filter to base so field problems are not repeated at the top of the form:
```liquid
{% if form.errors.size > 0 %}
-
{% for error in form.errors %}
{% if error.field == "base" %}
{% for message in error.full_messages %}
- {{ message }} {% endfor %} {% endif %} {% endfor %}
{% endif %} ```
full_messages prefixes each message with the field name, which is what a summary wants. Use messages where the field is already obvious from context.
The base theme ships this as a snippet, so a theme built on it can render the whole summary in one line:
```liquid
{% render “form_errors”, errors: form.errors %} ```
Pass include_fields: true to list field errors in the summary as well.
Messages against a single input
Read the field’s own errors, testing it with != blank:
```liquid
{% assign email = form.fields[“email”] %}
```
form.fields["email"].errors is a single FormError rather than a list, so != blank is the test; the .size that works on form.errors raises here.
Show both levels together. A summary alone leaves the customer hunting for which input is wrong, and messages on inputs alone bury a failure that belongs to no single field.
Security considerations
### CSRF protection
The {% form %} tag automatically includes a hidden CSRF token field. This token is required for form submission and protects against cross-site request forgery attacks. You do not need to add it manually; the tag handles this automatically.
Sensitive data
Never expose sensitive data in:
- HTML attributes (use form.fields["field"].value inside <input> elements, not in data attributes)
- Client-side JavaScript (which can be logged or leaked)
- Form field names (avoid custom names that reveal intent)
Field value preservation
When a form fails validation and re-renders, user-entered values are restored. For sensitive fields like passwords, StoreConnect does not preserve the value, so users must re-enter it. This is intentional for security.
```liquid
<input name=”{{ form.fields[“email”].name }}” value=”{{ form.fields[“email”].value }}”>
<input type=”password” name=”{{ form.fields[“password”].name }}”> ```
Flash messages
Flash messages (notices and alerts) can be displayed after a form submission redirects. In a controller, use the {% redirect %} tag with notice or alert options:
```liquid
{% after %} {% if form.fields[“valid?”] %} {% redirect to: “/account”, notice: “Profile updated successfully” %} {% endif %} {% endafter %} ```
On the target page, display flash messages from the current request:
```liquid
{% if current_request.flash.notice %}
{% endif %}
{% if current_request.flash.alert %}
{% endif %} ```
Common patterns
### Pattern 1: simple login form
```liquid
{% form “login” %}
{% render “form_errors”, errors: form.errors %}
Create account {% endform %} ```
Pattern 2: product add-to-cart with variants
```liquid
{% form “add-to-cart”, product: current_product, id: “AddToCartForm” %} {% if current_product.has_variants? %} {% for option in current_product.options %} <div class="form-group"> </div> {% endfor %} {% endif %}
{% render “form_errors”, errors: form.errors %}
{% endform %} ```
Pattern 3: newsletter signup
```liquid
{% form “custom-form”, custom_form: newsletter_form %}
{% render “form_errors”, errors: form.errors %}
{% endform %} ```
Pattern 4: contact form with dynamic questions
```liquid
{% if contact_form %} {% form “custom-form”, custom_form: contact_form, id: “ContactFormSection” %} {% render “form_errors”, errors: form.errors %}
{% for question in contact_form.questions %}
<div class="form-group {% if question.is_required %}required{% endif %}">
<label for="q_{{ question.id }}">
{{ question.label }}
{% if question.is_required %}<span class="text-danger">*</span>{% endif %}
</label>
{% case question.question_type %}
{% when "text" %}
<input
type="text"
id="q_{{ question.id }}"
name="{{ question.input_name }}"
value="{{ question.answer_value }}"
{% if question.is_required %}required{% endif %}>
{% when "text_area" %}
<textarea
id="q_{{ question.id }}"
name="{{ question.input_name }}"
rows="5"
{% if question.is_required %}required{% endif %}>{{ question.answer_value }}</textarea>
{% when "picklist" %}
<select
id="q_{{ question.id }}"
name="{{ question.input_name }}"
{% if question.is_required %}required{% endif %}>
<option value="">-- Select one --</option>
{% for option in question.picklist_values %}
<option value="{{ option }}" {% if question.answer_value == option %}selected{% endif %}>
{{ option }}
</option>
{% endfor %}
</select>
{% when "multi_picklist" %}
<fieldset>
{% for option in question.picklist_values %}
<label class="checkbox">
<input
type="checkbox"
name="{{ question.input_name }}[]"
value="{{ option }}"
{% if question.answer_values contains option %}checked{% endif %}>
{{ option }}
</label>
{% endfor %}
</fieldset>
{% when "date" %}
<input
type="date"
id="q_{{ question.id }}"
name="{{ question.input_name }}"
value="{{ question.answer_value }}"
{% if question.is_required %}required{% endif %}>
{% when "integer" %}
<input
type="number"
id="q_{{ question.id }}"
name="{{ question.input_name }}"
value="{{ question.answer_value }}"
{% if question.is_required %}required{% endif %}>
{% endcase %}
</div>
{% endfor %}
<button type="submit" class="btn btn-primary">Send</button> {% endform %} {% endif %} ```
Pattern 5: registration with address fields
```liquid
{% form “register”, id: “RegisterForm” %}
{% render “form_errors”, errors: form.errors %}
{% endform %} ```
Pattern 6: “Buy Now” button (redirect to checkout)
For add-to-cart forms, you can use formaction on a submit button to redirect directly to checkout after adding to the cart:
```liquid
{% form “add-to-cart”, product: current_product %}
{% endform %} ```
The form.path | params: after: 'checkout' filter modifies the form’s submission URL to include a redirect parameter.
Handling custom form questions
When using the custom-form type, you iterate over the form’s questions and render fields based on their type:
```liquid
{% form “custom-form”, custom_form: my_form %} {% for question in my_form.questions %} {% case question.question_type %} {% when “text” %}
{% when "text_area" %}
<textarea name="{{ question.input_name }}"></textarea>
{% when "picklist" %}
<select name="{{ question.input_name }}">
{% for option in question.picklist_values %}
<option>{{ option }}</option>
{% endfor %}
</select>
{% when "date" %}
<input type="date" name="{{ question.input_name }}">
{% endcase %} {% endfor %} {% endform %} ```
Each question has:
- label — the question’s display text
- input_name — the form field name to use
- question_type — the type of input (text, text_area, picklist, multi_picklist, integer, decimal, date, datetime, file, hidden)
- picklist_values — available options (for picklist and multi_picklist)
- answer_value — the user’s previous answer (if re-rendering after validation)
- is_required — whether the field is required
Advanced: reusable form components
To keep your theme DRY, extract form fields into reusable components:
```liquid
{%- comment -%} components/form_field.liquid
Parameters: - form: the form drop - field_name: the name of the field to render - type: input type (default: “text”) {%- endcomment -%}
{% assign field = form.fields[field_name] %}
```
Then use it in your forms:
```liquid
{% form “register” %} {% render “form_field”, form: form, field_name: “firstname” %} {% render “form_field”, form: form, field_name: “email”, type: “email” %} {% render “form_field”, form: form, field_name: “password”, type: “password” %} {% endform %} ```
Was this article helpful?
Thanks for your feedback! It helps us improve our docs.