Liquid error (layouts/theme line 23): internal Skip to content
Log in

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:

  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 %} {% 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

<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 %} {{ email.errors.messages | join: "; " }} {% endif %}

```

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 %}

{% 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”] %}

<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 %} {{ email.errors.messages | join: "; " }} {% endif %}

```

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 %}

{{ current_request.flash.notice }}

{% endif %}

{% if current_request.flash.alert %}

{{ current_request.flash.alert }}

{% endif %} ```

Common patterns

### Pattern 1: simple login form

```liquid

{% form “login” %}

<input type="text" id="username" name="{{ form.fields["username"].name }}" value="{{ form.fields["username"].value }}" required>
<input type="password" id="password" name="{{ form.fields["password"].name }}" required>

{% 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 %}

<input type="number" id="quantity" name="{{ form.fields["quantity"].name }}" value="{{ form.fields["quantity"].value | default: 1 }}" min="1" max="{{ current_product.inventory_count }}">

{% render “form_errors”, errors: form.errors %}

{% endform %} ```

Pattern 3: newsletter signup

```liquid

{% form “custom-form”, custom_form: newsletter_form %}

<input type="email" id="email" name="{{ form.fields["email"].name }}" value="{{ form.fields["email"].value }}" placeholder="your@email.com" required>
<input type="checkbox" id="privacy" name="{{ form.fields["privacy_consent"].name }}" value="yes" required>

{% 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” %}

Account Information
<input type="text" id="firstname" name="{{ form.fields["firstname"].name }}" value="{{ form.fields["firstname"].value }}" {% if form.fields["firstname"].required? %}required{% endif %}>
<input type="text" id="lastname" name="{{ form.fields["lastname"].name }}" value="{{ form.fields["lastname"].value }}" {% if form.fields["lastname"].required? %}required{% endif %}>
<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 %} {{ email.errors.messages | join: "; " }} {% endif %}
<input type="password" id="password" name="{{ form.fields["password"].name }}" {% if form.fields["password"].required? %}required{% endif %}>
Billing Address
<input type="text" id="address_lines" name="{{ form.fields["billing_address_lines"].name }}" value="{{ form.fields["billing_address_lines"].value }}">
<input type="text" id="city" name="{{ form.fields["billing_city"].name }}" value="{{ form.fields["billing_city"].value }}">
<input type="text" id="state" name="{{ form.fields["billing_state"].name }}" value="{{ form.fields["billing_state"].value }}">
<input type="text" id="postal_code" name="{{ form.fields["billing_postal_code"].name }}" value="{{ form.fields["billing_postal_code"].value }}">

{% 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 %}

<input type="number" id="quantity" name="{{ form.fields["quantity"].name }}" value="{{ form.fields["quantity"].value | default: 1 }}" min="1">

{% 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] %}

<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 %} {{ field.errors.messages | join: "; " }} {% endif %}

```

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?

Was this article helpful?