Skip to content

form

The form tag creates a real storefront <form>, chooses its localized action, adds the platform fields it needs, and exposes a form object inside the block. Use it instead of hard-coding account, cart, contact, or language-switch URLs.

Sellerlane supports ten Shopify-compatible form types and eight namespaced form types for passwordless accounts and Sellerlane-specific cart/customer workflows. The namespaced types begin with sellerlane_ so a theme can distinguish platform extensions from portable Shopify behavior.

Syntax

{% form 'type', object,
id: 'ExampleForm',
class: 'form',
return_to: request.path
%}
<!-- fields -->
{% endform %}
  • type is a quoted form-type name from the tables below.
  • object supplies the resource being changed. It is required for product, cart, customer-address, customer-profile, article, quote-request, and stock-notification forms.
  • Named arguments such as id, class, data-*, and novalidate become attributes on the generated <form>. Structural attributes (action, method, enctype, and accept-charset) are renderer-controlled and cannot be overridden.
  • return_to is reserved. It becomes a validated hidden field rather than an HTML attribute. Use back or a same-store relative path such as request.path. back is resolved to the current same-origin request URL; external and protocol-relative URLs are rejected.

For example:

{% form 'contact', id: 'ContactForm', class: 'contact-form', return_to: request.path %}
<label for="ContactEmail">Email</label>
<input id="ContactEmail" name="contact[email]" type="email" required>
<label for="ContactBody">Message</label>
<textarea id="ContactBody" name="contact[body]" required></textarea>
<button type="submit">Send</button>
{% endform %}

Supported form types

Shopify-compatible

TypeObjectPurpose
productproduct (required)Add a variant and its typed configuration to the cart.
cartcart (required)Update cart quantities or its note.
contactNoneSend a contact enquiry.
customerNoneSubscribe an email address to marketing. This is not account creation.
customer_loginNoneStart passwordless email or phone OTP sign-in.
create_customerNoneStart passwordless customer enrollment.
customer_addressaddress (required)Create or update a signed-in customer’s address. Pass the empty address object supplied by the new-address page when creating.
localizationNoneChange the storefront language. Country/market switching is not supported.
new_commentarticle (required)Submit a blog comment.
storefront_passwordNoneEnter a password-protected storefront.

“Shopify-compatible” means the form type, common field names, and Liquid authoring model are portable. Sellerlane remains passwordless, and rich product configuration uses typed Sellerlane fields rather than Shopify line-item properties.

Sellerlane extensions

TypeObjectPurpose
sellerlane_otp_verifyNoneVerify the challenge created by login or enrollment.
sellerlane_cart_changecart (required)Change or remove one existing cart line.
sellerlane_cart_offercart (required)Add a typed recommendation, upsell, or bundle offer.
sellerlane_customer_logoutcustomer (required)End the signed-in customer session.
sellerlane_customer_contactcustomer (required)Start an additional email/phone contact workflow. Verification uses its separate API endpoint.
sellerlane_customer_profilecustomer (required)Update non-identity profile fields for a signed-in customer.
sellerlane_quote_requestproduct (required)Request a quote for the current product and variant.
sellerlane_stock_notificationproduct (required)Subscribe an email address to a product or variant back-in-stock alert.

Generated markup

Every supported type renders a UTF-8 POST form similar to:

<form method="post" action="/en/api/contact" accept-charset="UTF-8">
<input type="hidden" name="form_type" value="contact" />
<input type="hidden" name="form_id" value="contact_form" />
<input type="hidden" name="utf8" value="" />
<input type="hidden" name="_csrf" value="" />
<!-- your fields -->
</form>

Depending on the type and object, the tag can also add resource selectors such as Shopify’s product-id, a product handle, active locale, or address identifier. Product, cart, and localization forms also receive their documented multipart encoding and Shopify-compatible default classes/IDs; explicit safe id and class arguments still override those defaults. Hidden inputs are still browser-controlled: buyer routes use them only to select a resource and then reload its canonical store-scoped data. Never accept a store or customer identity from browser code.

The form object

Inside the block, form describes the current submission state:

PropertyMeaning
form.idThe explicit id, or the stable ID generated for this form instance.
form.errorsField errors from the most recent submission, otherwise blank.
form.errors.messagesUser-facing messages. Treat this as an iterable summary; it is not guaranteed to be keyed by field.
form.errors.translated_fieldsOptional human-readable names for fields when the route supplies them.
form.posted_successfully?true when a PRG-capable handler records success for this exact form instance.

Use the built-in default_errors filter for an accessible summary:

{% form 'contact', id: 'ContactForm' %}
{% if form.posted_successfully? %}
<p role="status">Thanks. Your message was sent.</p>
{% elsif form.errors %}
<div role="alert">
{{ form.errors | default_errors }}
</div>
{% endif %}
<input
type="email"
name="contact[email]"
required
>
<textarea name="contact[body]" required></textarea>
<button type="submit">Send</button>
{% endform %}

Give repeated forms unique IDs. Two newsletter sections on the same page must not share success or error state.

Native HTML and Ajax

Native-capable form types support progressively enhanced and no-JavaScript themes. The JSON-only exceptions are called out below.

For a native-capable form type, the hidden _csrf field is submitted automatically. After validation, its handler responds with 303 See Other to the safe, locale-aware return_to path (or the current resource). A signed, short-lived PRG state value lets the next render identify the exact form instance, status, and bounded error code.

The canonical OTP flow puts an opaque challenge_id, not the email or phone, in its verification URL. The legacy challenge-less OTP verification compatibility path is an exception: on an HTML validation error it can put identifier in the query string. New themes must always use the generated challenge flow.

sellerlane_customer_logout and sellerlane_customer_contact are JSON-only handlers today. They require JavaScript to process the response; submitting them as an ordinary document navigation displays JSON instead of performing PRG. Native sellerlane_cart_change and sellerlane_cart_offer submissions do redirect, but their form state is currently recorded under the base cart and product types respectively, not under the namespaced form type.

Browser-required attributes improve the first response, but server validation remains authoritative for route-declared requirements. Required product modifiers, event slots, bundle choices, newsletter consent, and quote messages are checked again even if JavaScript or HTML validation was bypassed. An ordinary contact enquiry is different: the route requires a valid email and at least one nonempty field, but does not independently require contact[body].

CSRF protection

Every state-changing storefront form is same-origin and CSRF-protected. The tag’s hidden _csrf value is compared with the signed storefront CSRF cookies. Do not remove it, cache it in theme settings, or copy a token between stores.

If JavaScript sends JSON instead of the generated form encoding, put the current token in x-csrf-token:

function csrfToken() {
return (
document.querySelector('meta[name="csrf-token"]')?.content ||
document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)?.[1] ||
""
);
}
await fetch("/cart/add.js", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"x-csrf-token": csrfToken(),
},
credentials: "same-origin",
body: JSON.stringify(payload),
});
HTTP responseMeaning
403 csrf_missingNeither the hidden field nor header contained a token.
403 csrf_mismatchThe submitted token does not match the cookie.
403 csrf_invalidThe signed cookie pair was changed or belongs to another store.
403 csrf_origin_blockedThe browser marked the request cross-site or its origin was not the storefront.

Fetch a fresh token from GET /api/csrf when a long-lived tab receives a CSRF failure, then retry once. Never exempt a contact, account, cart, comment, quote, or stock form from CSRF.

Spam protection

CSRF proves that a submission came from the storefront; it does not distinguish a person from a bot. When the seller enables form or account protection, render the hCaptcha widget inside the affected form so h-captcha-response is submitted with the other fields:

{% comment %}Contact, newsletter, comment, and quote forms{% endcomment %}
{% render 'hcaptcha-field', enabled: shop.spam_protection_forms %}
{% comment %}Login and account-enrollment forms{% endcomment %}
{% render 'hcaptcha-field', enabled: shop.spam_protection_accounts %}

The standard Sellerlane theme provides snippets/hcaptcha-field.liquid. A custom theme can provide an equivalent hCaptcha integration. Do not disable the widget after adding Ajax: preserve its response field and request a new challenge after a failed or completed submission.

product

Pass the current product. The tag posts to the localized cart-add route and binds the form to that product; the selected variant still comes from the id field.

Basic product form

{% assign product_form_id = 'ProductForm-' | append: section.id %}
{% form 'product', product,
id: product_form_id,
class: 'product-form',
data-product-form: true
%}
<input
type="hidden"
name="id"
value="{{ product.selected_or_first_available_variant.id }}"
data-variant-id
>
<label for="Quantity-{{ section.id }}">Quantity</label>
<input
id="Quantity-{{ section.id }}"
type="number"
name="quantity"
value="1"
min="{{ product.min_order_qty | default: 1 }}"
>
<button type="submit" {% unless product.available %}disabled{% endunless %}>
{% if product.available %}Add to cart{% else %}Sold out{% endif %}
</button>
{% endform %}

Update the hidden id whenever the shopper changes options. The server verifies that the variant belongs to this store and product, is published and purchasable, and satisfies inventory and quantity rules.

Modifier fields

Each modifier is grouped by its immutable ID. Choice modifiers may submit one or more repeated values[]; free-form modifiers use exactly one typed field.

Modifier valueField name
Choice valuesellerlane[modifiers][MODIFIER_ID][values][]
Textsellerlane[modifiers][MODIFIER_ID][text]
Numbersellerlane[modifiers][MODIFIER_ID][number]
Datesellerlane[modifiers][MODIFIER_ID][date]
Uploaded file keysellerlane[modifiers][MODIFIER_ID][file_key]

This loop covers choice, number, date, text, and textarea modifiers:

{% for modifier in product.modifiers %}
{% assign kind = modifier.kind | downcase %}
{% assign constraints = modifier.constraints %}
<fieldset
data-modifier-id="{{ modifier.id }}"
data-required="{{ modifier.required }}"
>
<legend>
{{ modifier.name | escape }}
{% if modifier.required %}<span aria-hidden="true">*</span>{% endif %}
</legend>
{% if modifier.values.size > 0 %}
{% for value in modifier.values %}
<label>
<input
type="{% if modifier.multiple or modifier.max_selections > 1 %}checkbox{% else %}radio{% endif %}"
name="sellerlane[modifiers][{{ modifier.id }}][values][]"
value="{{ value.id }}"
{% if value.is_default %}checked{% endif %}
{% if modifier.required and modifier.multiple != true and forloop.first %}required{% endif %}
>
{{ value.label | escape }}
{% if value.price_delta_cents != 0 %}
({% if value.price_delta_cents > 0 %}+{% endif %}{{ value.price_delta_cents | money }})
{% endif %}
</label>
{% endfor %}
{% elsif kind contains 'file' %}
<input
type="file"
data-modifier-upload
{% if constraints.accept %}accept="{{ constraints.accept | escape }}"{% endif %}
{% if modifier.required %}required{% endif %}
>
<input
type="hidden"
name="sellerlane[modifiers][{{ modifier.id }}][file_key]"
data-modifier-file-key
>
{% elsif kind contains 'number' %}
<input
type="number"
name="sellerlane[modifiers][{{ modifier.id }}][number]"
value="{{ modifier.default_value | escape }}"
{% if constraints.min != blank %}min="{{ constraints.min }}"{% endif %}
{% if constraints.max != blank %}max="{{ constraints.max }}"{% endif %}
{% if constraints.step != blank %}step="{{ constraints.step }}"{% endif %}
{% if modifier.required %}required{% endif %}
>
{% elsif kind contains 'date' %}
<input
type="date"
name="sellerlane[modifiers][{{ modifier.id }}][date]"
value="{{ modifier.default_value | escape }}"
{% if constraints.min != blank %}min="{{ constraints.min }}"{% endif %}
{% if constraints.max != blank %}max="{{ constraints.max }}"{% endif %}
{% if modifier.required %}required{% endif %}
>
{% elsif kind contains 'textarea' or kind contains 'multi' %}
<textarea
name="sellerlane[modifiers][{{ modifier.id }}][text]"
{% if constraints.min_length != blank %}minlength="{{ constraints.min_length }}"{% endif %}
{% if constraints.max_length != blank %}maxlength="{{ constraints.max_length }}"{% endif %}
{% if modifier.required %}required{% endif %}
>{{ modifier.default_value | escape }}</textarea>
{% else %}
<input
type="text"
name="sellerlane[modifiers][{{ modifier.id }}][text]"
value="{{ modifier.default_value | escape }}"
{% if constraints.min_length != blank %}minlength="{{ constraints.min_length }}"{% endif %}
{% if constraints.max_length != blank %}maxlength="{{ constraints.max_length }}"{% endif %}
{% if modifier.required %}required{% endif %}
>
{% endif %}
</fieldset>
{% endfor %}

required, min_selections, max_selections, constraints, allowed value IDs, price deltas, and inventory adjustments are checked again by the cart service. HTML required improves the experience but is not the business-rule boundary.

Event fields

Render an event availability picker, then submit the selected instants in UTC:

{% if product.is_event %}
<div data-event-booking>
<label>
Start
<input
type="datetime-local"
data-event-start-local
required
>
</label>
<input
type="hidden"
name="sellerlane[event][slot_start_utc]"
data-event-start-utc
>
<input
type="hidden"
name="sellerlane[event][slot_end_utc]"
data-event-end-utc
>
{% if product.event_booking.allowed_hosts.size > 0 %}
<label>
Host
<select
name="sellerlane[event][store_user_id]"
{% if product.event_booking.requires_host_selection %}required{% endif %}
>
{% if product.event_booking.allow_no_preference %}
<option value="">No preference</option>
{% endif %}
{% for host in product.event_booking.allowed_hosts %}
<option value="{{ host.id }}">{{ host.name | escape }}</option>
{% endfor %}
</select>
</label>
{% endif %}
</div>
{% endif %}

The hidden values must be ISO-8601 UTC instants such as 2026-08-21T09:30:00.000Z. Use the event availability API to create the picker; the cart service rechecks schedule, host, notice window, capacity, and start/end ordering when the form is submitted.

Gift-card fields

{% if product.gift_card %}
<label>
Recipient email
<input
type="email"
name="sellerlane[gift_card][recipient_email]"
>
</label>
<label>
Your name
<input type="text" name="sellerlane[gift_card][sender_name]">
</label>
<label>
Message
<textarea name="sellerlane[gift_card][message]" maxlength="500"></textarea>
</label>
<label>
Send on
<input type="datetime-local" data-gift-send-local>
</label>
<input
type="hidden"
name="sellerlane[gift_card][scheduled_send_at]"
data-gift-send-utc
>
<input
type="hidden"
name="sellerlane[gift_card][timezone]"
data-gift-timezone
>
{% endif %}

Convert a scheduled local wall time to an ISO UTC instant and include the buyer’s IANA time zone, for example Asia/Kolkata. A blank schedule means send according to the store’s normal gift-card delivery policy. The server validates recipient email, schedule, message length, and gift-card product eligibility.

Digital-delivery fields

{% if product.is_digital %}
<label>
Delivery email
<input
type="email"
name="sellerlane[digital_delivery][destination_email]"
autocomplete="email"
>
</label>
{% endif %}

This address controls delivery for this line; it does not change the customer’s login identity. Download limits, expiry, files, and license pools come from the variant and cannot be overridden by the form.

Bundle-child fields

A catalog bundle submits one selected variant for every component. The component ID groups browser fields; the server uses the product’s bundle definition to produce the ordered child-selection array and does not forward that component ID as buyer-controlled cart data.

{% if product.is_bundle %}
{% for component in product.bundle.items %}
{% assign child_variants = component.selectable_variants | default: component.allowed_variants %}
<fieldset>
<legend>{{ component.title | escape }}</legend>
<select
name="sellerlane[bundle_children][{{ component.id }}][variant_id]"
required
>
{% for child_variant in child_variants %}
<option
value="{{ child_variant.id }}"
{% if child_variant.id == component.default_variant_id %}selected{% endif %}
>
{{ child_variant.title | escape }}{{ child_variant.price | money }}
</option>
{% endfor %}
</select>
<!-- A child modifier uses the same typed shape inside this component. -->
{% assign child = component.product %}
{% for modifier in child.modifiers %}
{% for value in modifier.values %}
<label>
<input
type="checkbox"
name="sellerlane[bundle_children][{{ component.id }}][modifiers][{{ modifier.id }}][values][]"
value="{{ value.id }}"
>
{{ value.label | escape }}
</label>
{% endfor %}
{% endfor %}
</fieldset>
{% endfor %}
{% endif %}

Nested children use the same suffixes for configuration:

sellerlane[bundle_children][COMPONENT_ID][modifiers][MODIFIER_ID][values][]
sellerlane[bundle_children][COMPONENT_ID][modifiers][MODIFIER_ID][text]
sellerlane[bundle_children][COMPONENT_ID][event][slot_start_utc]
sellerlane[bundle_children][COMPONENT_ID][event][slot_end_utc]
sellerlane[bundle_children][COMPONENT_ID][event][store_user_id]
sellerlane[bundle_children][COMPONENT_ID][gift_card][recipient_email]
sellerlane[bundle_children][COMPONENT_ID][gift_card][sender_name]
sellerlane[bundle_children][COMPONENT_ID][gift_card][message]
sellerlane[bundle_children][COMPONENT_ID][gift_card][scheduled_send_at]
sellerlane[bundle_children][COMPONENT_ID][gift_card][timezone]
sellerlane[bundle_children][COMPONENT_ID][digital_delivery][destination_email]

Do not submit child quantity or price. Component units and pricing are fixed by the published bundle definition.

JSON product payload

For a JavaScript-enhanced product form, normalize the controls into the typed cart payload instead of serializing arbitrary nested objects:

const payload = {
item: {
product: {
variantId,
quantity: 1,
modifiers: [
{ modifierId: engravingId, textValue: "Happy birthday" },
{ modifierId: finishId, modifierValueId: matteValueId },
{ modifierId: uploadId, fileKey: uploadedFileKey },
],
eventDetails: {
slotStartUtc: "2026-08-21T09:30:00.000Z",
slotEndUtc: "2026-08-21T10:00:00.000Z",
storeUserId: hostId,
},
giftCardDetails: {
recipientEmail: "[email protected]",
senderName: "Asha",
message: "Enjoy!",
scheduledSendAt: "2026-08-25T03:30:00.000Z",
},
digitalDeliveryDetails: {
destinationEmail: "[email protected]",
},
bundleChildSelections: [
{
variantId: firstChildVariantId,
modifiers: [
{ modifierId: childColourId, modifierValueId: blueValueId },
],
},
{
variantId: secondChildVariantId,
digitalDeliveryDetails: {
destinationEmail: "[email protected]",
},
},
],
},
},
};

The JSON field names are camelCase. The bundle array contains only variantId, modifiers, eventDetails, giftCardDetails, and digitalDeliveryDetails; do not send component IDs, quantities, prices, store IDs, customer IDs, or calculated deltas.

cart

Use a cart form for quantity and note updates. A quantity of 0 removes the line.

{% form 'cart', cart, id: 'CartForm' %}
{% for item in cart.items %}
<label>
{{ item.product.title | escape }}
<input
type="number"
name="updates[{{ item.key }}]"
value="{{ item.quantity }}"
min="0"
>
</label>
{% endfor %}
<label>
Order note
<textarea name="note">{{ cart.note | escape }}</textarea>
</label>
<button type="submit">Update cart</button>
{% endform %}

Cart-level Shopify attributes[...] are not persisted. Use the order note and typed line configuration instead.

contact

Contact fields live under contact[...]. Custom labels are accepted within the same namespace; protocol fields such as _csrf, utf8, captcha tokens, and return_to are never forwarded as message content.

{% form 'contact', id: 'ContactForm', return_to: request.path %}
{{ form.errors | default_errors }}
<input name="contact[name]" autocomplete="name" placeholder="Name">
<input name="contact[email]" type="email" autocomplete="email" required>
<input name="contact[phone]" type="tel" autocomplete="tel">
<input name="contact[Order number]" placeholder="Order number">
<textarea name="contact[body]" required></textarea>
{% render 'hcaptcha-field', enabled: shop.spam_protection_forms %}
<button type="submit">Send</button>
{% endform %}

The route requires a valid contact[email] and at least one nonempty contact field. The required attribute on contact[body] is browser-side guidance; the server does not independently require a message body. Quote requests are different: their message body is server-required.

customer

In Shopify theme terminology, customer is the newsletter/marketing signup form. It does not create an account and is unrelated to the signed-in customer object.

{% form 'customer', id: 'NewsletterForm', return_to: request.path %}
{% if form.posted_successfully? %}
<p role="status">Check your inbox to confirm your subscription.</p>
{% else %}
{{ form.errors | default_errors }}
<input type="hidden" name="contact[tags]" value="newsletter">
{% assign consent_text = 'I agree to receive marketing email from ' | append: shop.name %}
<input type="hidden" name="marketing_consent_text" value="{{ consent_text | escape }}">
<input type="hidden" name="newsletter_id" value="NewsletterForm">
<label>
Email
<input name="contact[email]" type="email" autocomplete="email" required>
</label>
<label>
<input name="email_marketing_consent" type="checkbox" value="true" required>
{{ consent_text }}
</label>
{% render 'hcaptcha-field', enabled: shop.spam_protection_forms %}
<button type="submit">Subscribe</button>
{% endif %}
{% endform %}

Visible consent is mandatory; explanatory prose without the checked email_marketing_consent=true field is not consent. The server stamps the consent source and notice version and records the submitted notice text as a hash. A successful response means the request was accepted, not necessarily that the address is immediately subscribed: double opt-in and protected re-subscriptions can remain pending until the email owner confirms them. Do not emit “subscribed” analytics from this public form response alone.

Passwordless customer accounts

Sellerlane accounts use email or phone OTP. There is no customer password field.

{% form 'customer_login', id: 'CustomerLogin' %}
{{ form.errors | default_errors }}
<label>
Email or mobile number
<input
name="customer[identifier]"
autocomplete="username"
required
>
</label>
{% render 'hcaptcha-field', enabled: shop.spam_protection_accounts %}
<button type="submit">Send code</button>
{% endform %}

customer[email] and customer[phone] are accepted when a theme uses separate controls. mobile is a compatibility alias; normalize stored phone numbers to E.164, for example +919876543210.

sellerlane_customer_profile

This form updates non-identity profile fields for the signed-in customer. Login email and phone changes require their dedicated verified-contact flow and must not be placed in this form.

{% form 'sellerlane_customer_profile', customer, id: 'CustomerProfile' %}
{{ form.errors | default_errors }}
<input
name="profile[first_name]"
value="{{ customer.first_name | escape }}"
autocomplete="given-name"
>
<input
name="profile[last_name]"
value="{{ customer.last_name | escape }}"
autocomplete="family-name"
>
<input
type="date"
name="profile[birthday]"
value="{{ customer.birthday | date: '%Y-%m-%d' }}"
>
<label>
<input
type="hidden"
name="profile[email_marketing_consent]"
value="false"
>
<input
type="checkbox"
name="profile[email_marketing_consent]"
value="true"
{% if customer.accepts_marketing %}checked{% endif %}
>
Receive marketing email
</label>
<button type="submit">Save profile</button>
{% endform %}

Sellerlane cart and customer workflow forms

Use sellerlane_cart_change for quantity/removal controls and sellerlane_cart_offer for the typed recommendation payloads rendered by the standard theme. Both require the current cart. Use sellerlane_customer_logout and sellerlane_customer_contact only in an authenticated customer context and pass the current customer.

{% form 'sellerlane_cart_change', cart, id: 'CartLineChange' %}
<input type="hidden" name="line" value="{{ forloop.index }}">
<input type="number" name="quantity" value="{{ item.quantity }}" min="0">
<button type="submit">Update</button>
{% endform %}
{% form 'sellerlane_customer_logout', customer, id: 'CustomerLogout', data-customer-logout-form: true %}
<button type="submit">Log out</button>
{% endform %}

These namespaced forms exist so a theme never reads request.csrf_token or hard-codes a mutation URL. The renderer owns the action and injects the CSRF field. They are Sellerlane extensions, not portable Shopify form types.

sellerlane_customer_logout and sellerlane_customer_contact return JSON, so JavaScript must intercept submission. Logout returns a safe redirect_to for the script to follow. The contact form posts flat type (email or phone) and value fields to /account/contacts/add; success returns step, type, value, and contact_id. Verification is not another form-tag action: post type, value, and code as JSON to /account/contacts/verify with the page’s CSRF token.

Native sellerlane_cart_change and sellerlane_cart_offer requests do use PRG, but the stored form state is currently keyed to the base cart and product types, respectively. Do not expect namespaced form.errors or form.posted_successfully? state for those two forms after the redirect.

customer_address

Pass the address-form object supplied by the account page: an existing address updates that address, while the empty object on the new-address page creates one. The object is required in both cases. Use nested address[...] fields.

{% form 'customer_address', address, id: 'AddressForm' %}
{{ form.errors | default_errors }}
<input name="address[first_name]" value="{{ address.first_name | escape }}" autocomplete="given-name">
<input name="address[last_name]" value="{{ address.last_name | escape }}" autocomplete="family-name">
<input name="address[company]" value="{{ address.company | escape }}" autocomplete="organization">
<input name="address[address1]" value="{{ address.address1 | escape }}" autocomplete="address-line1" required>
<input name="address[address2]" value="{{ address.address2 | escape }}" autocomplete="address-line2">
<input name="address[city]" value="{{ address.city | escape }}" autocomplete="address-level2" required>
<input name="address[province]" value="{{ address.province | escape }}" autocomplete="address-level1">
<input name="address[province_code]" value="{{ address.province_code | escape }}">
<input name="address[country]" value="{{ address.country | escape }}" autocomplete="country-name" required>
<input name="address[country_code]" value="{{ address.country_code | escape }}" autocomplete="country">
<input name="address[zip]" value="{{ address.zip | escape }}" autocomplete="postal-code" required>
<input name="address[phone]" value="{{ address.phone | escape }}" type="tel" autocomplete="tel">
<label>
<input name="address[default]" type="checkbox" value="true" {% if address.default %}checked{% endif %}>
Make this my default address
</label>
<button type="submit">Save address</button>
{% endform %}

Prefer ISO country_code and province_code values from platform selectors; the server validates country/province consistency.

localization

Sellerlane’s localization form switches published languages. It does not switch Markets, countries, or currency.

{% form 'localization', id: 'LanguageForm', return_to: request.path %}
<label for="LanguageCode">Language</label>
<select id="LanguageCode" name="locale_code" onchange="this.form.submit()">
{% for language in localization.available_languages %}
<option
value="{{ language.iso_code }}"
{% if language.iso_code == localization.language.iso_code %}selected{% endif %}
>
{{ language.endonym_name | default: language.name | escape }}
</option>
{% endfor %}
</select>
<noscript><button type="submit">Change language</button></noscript>
{% endform %}

locale_code must be one of the store’s published locales. External or protocol-relative return_to values are rejected. Use ordinary localized links instead when a no-JavaScript language menu is a better experience.

new_comment

Pass the current article and use Shopify’s nested comment fields:

{% form 'new_comment', article, id: 'ArticleComment', return_to: article.url %}
{% if form.posted_successfully? %}
<p role="status">
{% if article.moderated? %}
Your comment was submitted for moderation.
{% else %}
Your comment was published.
{% endif %}
</p>
{% endif %}
{{ form.errors | default_errors }}
<input name="comment[author]" autocomplete="name" required>
<input name="comment[email]" type="email" autocomplete="email" required>
<textarea name="comment[body]" required></textarea>
{% render 'hcaptcha-field', enabled: shop.spam_protection_forms %}
<button type="submit">Post comment</button>
{% endform %}

The action and redirect retain the active locale. Comment moderation settings, spam protection, and duplicate detection are server-authoritative. An explicit Accept: application/json request returns JSON rather than following a redirect:

{
"success": true,
"comment": { "id": "", "status": "pending_moderation" },
"redirect_to": "/en/blogs/news/example?new_comment=posted"
}

Validation responses use a non-2xx status and { "errors": ["…"], "code": "invalid_email" }. A normal browser form submission continues to use signed, PII-free PRG state.

storefront_password

{% form 'storefront_password', id: 'StorefrontPassword', return_to: routes.root_url %}
{{ form.errors | default_errors }}
<label>
Store password
<input name="password" type="password" autocomplete="current-password" required>
</label>
<button type="submit">Enter store</button>
{% endform %}

This is the merchant’s storefront-access password, not a customer-account password. Attempts are rate-limited. With Accept: application/json, a correct password returns { "success": true, "redirect_to": "/safe/path" } while an incorrect password returns 401 with { "errors": ["Invalid password"], "code": "invalid_password" }. Without an explicit JSON request, the form keeps native 303 PRG behavior.

sellerlane_quote_request

Pass the current product. The generated product ID and your currently selected variant ID are browser selectors, not authority. The server reloads the product from the current store’s live catalog, requires it to be online and configured for RFQ, verifies that the variant belongs to it, and replaces product/variant titles and URLs from canonical catalog data.

{% form 'sellerlane_quote_request', product,
id: 'QuoteRequest',
return_to: product.url
%}
{{ form.errors | default_errors }}
<input
type="hidden"
name="variant_id"
value="{{ product.selected_or_first_available_variant.id }}"
data-quote-variant-id
>
<input name="contact[name]" autocomplete="name" placeholder="Name">
<input name="contact[email]" type="email" autocomplete="email" required>
<input name="contact[phone]" type="tel" autocomplete="tel">
<input name="quantity" type="number" value="1" min="1">
<textarea name="contact[body]" placeholder="What do you need?" required></textarea>
{% render 'hcaptcha-field', enabled: shop.spam_protection_forms %}
<button type="submit">Request quote</button>
{% endform %}

Synchronize variant_id with the product picker; omitting it is rejected when the product has variants. Quantity must be a positive whole number and respect the product’s configured minimum/maximum. Email and message are required. Product title, variant title, store, canonical localized URL, and active request locale are resolved by the server; submitting lookalike hidden metadata cannot change them.

sellerlane_stock_notification

{% form 'sellerlane_stock_notification', product,
id: 'StockNotification',
return_to: product.url
%}
{{ form.errors | default_errors }}
<input
type="hidden"
name="variant_id"
value="{{ product.selected_or_first_available_variant.id }}"
data-stock-variant-id
>
<label>
Email me when available
<input name="email" type="email" autocomplete="email" required>
</label>
<button type="submit">Notify me</button>
{% endform %}

The tag supplies the product ID/handle and request locale. The server requires a live product in a supported purchase mode. When a variant is supplied, it verifies that the variant belongs to the store and product; the variant does not have to be currently purchasable or inventory-tracked. Untracked inventory is treated as already available, while a missing inventory item is treated as out of stock and can create a subscription. A product-level subscription without a variant is also valid. After an Ajax success, clear only the email input; calling form.reset() can restore a stale initial variant ID after the shopper has changed variants.

Unsupported form types

Any type not listed above raises a template error. In particular:

TypeWhy it is unsupportedUse instead
activate_customer_passwordCustomer accounts have no password to activate.customer_login followed by sellerlane_otp_verify.
recover_customer_passwordThere is no password-recovery flow.Start a new OTP sign-in.
reset_customer_passwordCustomer passwords are not stored.Start a new OTP sign-in.
guest_loginCheckout does not create a separate guest-login session.Continue through checkout without an account, or use OTP sign-in.
currencySellerlane does not expose Shopify Markets/currency switching.Render the store currency and language localization only.
B2B company formsCompanies, locations, and buyers are created in the seller admin; the storefront’s purchase-identity switcher, company screen, quick order, and invoice payment are built-in account features, not Liquid forms.Rely on the built-in account pages; build against supported customer/address forms.

Also keep these ordinary workflows out of the Liquid form registry:

  • Search, collection filtering, and sorting use normal method="get" forms.
  • Reviews, Q&A, votes, reports, and review media use the review widget/JSON API.
  • Returns, cancellations, delivery tracking, and digital-download redemption use their eligibility-aware system components.
  • Cookie consent, analytics, pixels, loyalty actions, referrals, and verified customer contact changes use their dedicated APIs.
  • Metaobject definitions, entries, and default theme content are edited in the seller admin, not through buyer storefront forms.

Implementation checklist

  1. Choose a supported type and pass its required object. 2. Give every repeated form a unique id and use canonical nested field names. 3. Render form.errors, default_errors, and form.posted_successfully?. 4. Keep the generated CSRF and trusted hidden fields intact. 5. Render every required modifier or capability field the product exposes. 6. Preserve duplicate form fields when enhancing with Ajax. 7. Update variant-bound quote, stock, and product inputs whenever selection changes. 8. Test native HTML and Ajax submissions in both the primary and a prefixed locale.