---
title: "Liquid forms guide"
source: https://support.storeconnect.com/articles/liquid-forms-guide
type: article
format: markdown
site: StoreConnect Support — product and developer documentation for StoreConnect
site_index: https://storeconnect.com/llms.txt
docs_index: https://support.storeconnect.com/llms.txt
note: Append .md to any page or article URL on this site to get its Markdown form.
---
# Liquid forms guide

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](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:

1. StoreConnect receives the form submission and validates the data
2. On **success**: the form submission is processed and the user is redirected or shown a success page
3. 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 %}
    <!-- Validation errors are automatically populated -->
  {% endif %}
  
  <!-- Field values are automatically preserved -->
  <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" %}
  <!-- login form fields -->
{% endform %}

{% form "add-to-cart", product: current_product %}
  <!-- add-to-cart form fields -->
{% endform %}

{% form "custom-form", custom_form: my_contact_form %}
  <!-- custom form questions -->
{% endform %}
```


Each form type has a specific set of fields and parameters. See [Liquid forms reference](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

<div class="form-group">
  <label for="{{ form.fields["email"].id }}">Email address</label>
  <input 
    type="email"
    id="{{ form.fields["email"].id }}"
    name="{{ form.fields["email"].name }}"
    value="{{ form.fields["email"].value }}"
    {% if form.fields["email"].required? %}required{% endif %}>

  {% assign email = form.fields["email"] %}
  {% if email.errors != blank %}
    <span class="error">{{ email.errors.messages | join: "; " }}</span>
  {% endif %}
</div>
```


## Validation and error display
Forms validate on submission. Where the errors live is covered in the [form tag reference](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 %}
  <div class="alert alert-danger" role="alert">
    <ul>
      {% for error in form.errors %}
        {% if error.field == "base" %}
          {% for message in error.full_messages %}
            <li>{{ message }}</li>
          {% endfor %}
        {% endif %}
      {% endfor %}
    </ul>
  </div>
{% 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"] %}

<div class="form-group {% if email.errors != blank %}has-error{% endif %}">
  <label for="email">Email</label>
  <input
    type="email"
    id="email"
    name="{{ form.fields["email"].name }}"
    value="{{ form.fields["email"].value }}"
    {% if email.errors != blank %}aria-invalid="true"{% endif %}>
  {% if email.errors != blank %}
    <small class="error-feedback">{{ email.errors.messages | join: "; " }}</small>
  {% endif %}
</div>
```


`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

<!-- Acceptable: regular field values are preserved -->
<input name="{{ form.fields["email"].name }}" value="{{ form.fields["email"].value }}">

<!-- Password fields: value is NOT preserved for security -->
<input type="password" name="{{ form.fields["password"].name }}">
<!-- form.fields["password"].value is empty -->
```


## 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 %}
  <div class="alert alert-success">{{ current_request.flash.notice }}</div>
{% endif %}

{% if current_request.flash.alert %}
  <div class="alert alert-danger">{{ current_request.flash.alert }}</div>
{% endif %}
```


## Common patterns
### Pattern 1: simple login form

```liquid

{% form "login" %}
  <div class="form-group">
    <label for="username">Username or Email</label>
    <input 
      type="text"
      id="username"
      name="{{ form.fields["username"].name }}"
      value="{{ form.fields["username"].value }}"
      required>
  </div>

  <div class="form-group">
    <label for="password">Password</label>
    <input 
      type="password"
      id="password"
      name="{{ form.fields["password"].name }}"
      required>
  </div>

  {% render "form_errors", errors: form.errors %}

  <button type="submit" class="btn btn-primary">Log in</button>
  <a href="/auth/sign_up">Create account</a>
{% 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">
        <label for="option_{{ option.position }}">{{ option.name }}</label>
        <select id="option_{{ option.position }}" name="option{{ option.position }}">
          <option value="">Select {{ option.name }}</option>
          {% for value in option.values %}
            <option value="{{ value }}">{{ value }}</option>
          {% endfor %}
        </select>
      </div>
    {% endfor %}
  {% endif %}

  <div class="form-group">
    <label for="quantity">Quantity</label>
    <input 
      type="number"
      id="quantity"
      name="{{ form.fields["quantity"].name }}"
      value="{{ form.fields["quantity"].value | default: 1 }}"
      min="1"
      max="{{ current_product.inventory_count }}">
  </div>

  {% render "form_errors", errors: form.errors %}

  <button type="submit" class="btn btn-success btn-lg">Add to Cart</button>
{% endform %}
```


### Pattern 3: newsletter signup

```liquid

{% form "custom-form", custom_form: newsletter_form %}
  <div class="form-group">
    <label for="email">Email</label>
    <input 
      type="email"
      id="email"
      name="{{ form.fields["email"].name }}"
      value="{{ form.fields["email"].value }}"
      placeholder="your@email.com"
      required>
  </div>

  <div class="form-group form-check">
    <input 
      type="checkbox"
      id="privacy"
      name="{{ form.fields["privacy_consent"].name }}"
      value="yes"
      required>
    <label for="privacy">
      I agree to receive marketing emails
    </label>
  </div>

  {% render "form_errors", errors: form.errors %}

  <button type="submit" class="btn btn-primary">Subscribe</button>
{% 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" %}
  <fieldset>
    <legend>Account Information</legend>
    
    <div class="row">
      <div class="col-md-6">
        <div class="form-group">
          <label for="firstname">First name</label>
          <input 
            type="text"
            id="firstname"
            name="{{ form.fields["firstname"].name }}"
            value="{{ form.fields["firstname"].value }}"
            {% if form.fields["firstname"].required? %}required{% endif %}>
        </div>
      </div>
      <div class="col-md-6">
        <div class="form-group">
          <label for="lastname">Last name</label>
          <input 
            type="text"
            id="lastname"
            name="{{ form.fields["lastname"].name }}"
            value="{{ form.fields["lastname"].value }}"
            {% if form.fields["lastname"].required? %}required{% endif %}>
        </div>
      </div>
    </div>

    <div class="form-group">
      <label for="email">Email address</label>
      <input 
        type="email"
        id="email"
        name="{{ form.fields["email"].name }}"
        value="{{ form.fields["email"].value }}"
        {% if form.fields["email"].required? %}required{% endif %}>
      {% assign email = form.fields["email"] %}
      {% if email.errors != blank %}
        <small class="form-text text-danger">{{ email.errors.messages | join: "; " }}</small>
      {% endif %}
    </div>

    <div class="form-group">
      <label for="password">Password</label>
      <input 
        type="password"
        id="password"
        name="{{ form.fields["password"].name }}"
        {% if form.fields["password"].required? %}required{% endif %}>
    </div>
  </fieldset>

  <fieldset>
    <legend>Billing Address</legend>
    
    <div class="form-group">
      <label for="address_lines">Street Address</label>
      <input 
        type="text"
        id="address_lines"
        name="{{ form.fields["billing_address_lines"].name }}"
        value="{{ form.fields["billing_address_lines"].value }}">
    </div>

    <div class="row">
      <div class="col-md-6">
        <div class="form-group">
          <label for="city">City</label>
          <input 
            type="text"
            id="city"
            name="{{ form.fields["billing_city"].name }}"
            value="{{ form.fields["billing_city"].value }}">
        </div>
      </div>
      <div class="col-md-2">
        <div class="form-group">
          <label for="state">State</label>
          <input 
            type="text"
            id="state"
            name="{{ form.fields["billing_state"].name }}"
            value="{{ form.fields["billing_state"].value }}">
        </div>
      </div>
      <div class="col-md-4">
        <div class="form-group">
          <label for="postal_code">Postal code</label>
          <input 
            type="text"
            id="postal_code"
            name="{{ form.fields["billing_postal_code"].name }}"
            value="{{ form.fields["billing_postal_code"].value }}">
        </div>
      </div>
    </div>
  </fieldset>

  {% render "form_errors", errors: form.errors %}

  <button type="submit" class="btn btn-primary">Create Account</button>
{% 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 %}
  <div class="form-group">
    <label for="quantity">Quantity</label>
    <input 
      type="number"
      id="quantity"
      name="{{ form.fields["quantity"].name }}"
      value="{{ form.fields["quantity"].value | default: 1 }}"
      min="1">
  </div>

  <div class="button-group">
    <button type="submit" class="btn btn-outline">Add to Cart</button>
    
    <!-- "Buy Now" button redirects to checkout after adding to cart -->
    <button 
      type="submit" 
      formaction="{{ form.path | params: after: 'checkout' }}"
      class="btn btn-primary">
      Buy Now
    </button>
  </div>
{% 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" %}
      <input type="text" name="{{ question.input_name }}">
    
    {% 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] %}
<div class="form-group {% if field.errors != blank %}has-error{% endif %}">
  <label for="{{ form.fields[field_name].id }}">
    {{ field_label }}{%- comment -%} supply your own label text; fields expose no label {%- endcomment -%}
    {% if form.fields[field_name].required? %}<span class="required">*</span>{% endif %}
  </label>
  
  <input 
    type="{{ type }}"
    id="{{ form.fields[field_name].id }}"
    name="{{ form.fields[field_name].name }}"
    value="{{ form.fields[field_name].value }}"
    {% if form.fields[field_name].required? %}required{% endif %}
    class="{% if field.errors != blank %}is-invalid{% endif %}">
  
  {% if field.errors != blank %}
    <small class="error-message">{{ field.errors.messages | join: "; " }}</small>
  {% endif %}
</div>
```


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" %}
  <button type="submit">Register</button>
{% endform %}
```

---

## Follow StoreConnect

- [Email Newsletter](https://storeconnect.com/c/lp-newsletter)
- [LinkedIn Newsletter](https://www.linkedin.com/build-relation/newsletter-follow?entityUrn=7444956928444862464)
- [YouTube](https://www.youtube.com/channel/UCngKdP2x8l1wcbAKW3tvU8g)
- [LinkedIn](https://www.linkedin.com/company/storeconnect)
- [X / Twitter](https://x.com/storeconnecthq)

## Popular Links

- [Partners](https://storeconnect.com/partners)
- [Become a Partner](https://storeconnect.com/become-a-partner)
- [News](https://storeconnect.com/articles/news)
- [Events](https://storeconnect.com/articles/events)
- [Live Events](https://storeconnect.com/live-events)
- [Feature Comparison](https://storeconnect.com/how-we-compare)
- [Download a free trial](https://appexchange.salesforce.com/appxListingDetail?listingId=a0N3A00000FMkeKUAT)
- [Book a Demo](https://storeconnect.com/contact)

## Documentation

- [Help documentation](https://support.storeconnect.com/help-documentation)
- [AI agents](https://support.storeconnect.com/ai)
- [Videos & tutorials](https://support.storeconnect.com/videos-tutorials)
- [Developer reference](https://support.storeconnect.com/developer-reference)
- [Release notes](https://support.storeconnect.com/release-notes)
- [Troubleshooting](https://support.storeconnect.com/troubleshooting)
- [Trust Center](https://trust.getstoreconnect.com/)
- [Status Page](https://status.storeconnect.com/)

## Contact

- info@getstoreconnect.com
- US +1 415 745 3230
- AUS +61 2 8365 2308

100 S Ashley Dr, Suite 600-2461
Tampa FL 33602-600 USA

Level 22, Sydney Place
180 George Street
Sydney, NSW, 2000, AUS

## Machine-readable

- [Site index for agents](https://storeconnect.com/llms.txt): curated map of the StoreConnect site in llms.txt format
- [Documentation index for agents](https://support.storeconnect.com/llms.txt): full technical and product documentation map

Every page and article on this site has a Markdown rendering: append `.md` to its URL.

Continue in Markdown: [Help documentation](https://support.storeconnect.com/help-documentation.md) · [Developer reference](https://support.storeconnect.com/developer-reference.md) · [Videos & tutorials](https://support.storeconnect.com/videos-tutorials.md) · [Release notes](https://support.storeconnect.com/release-notes.md)

---

StoreConnect Support — https://support.storeconnect.com/articles/liquid-forms-guide