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 %}typeis a quoted form-type name from the tables below.objectsupplies 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-*, andnovalidatebecome attributes on the generated<form>. Structural attributes (action,method,enctype, andaccept-charset) are renderer-controlled and cannot be overridden. return_tois reserved. It becomes a validated hidden field rather than an HTML attribute. Usebackor a same-store relative path such asrequest.path.backis 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
| Type | Object | Purpose |
|---|---|---|
product | product (required) | Add a variant and its typed configuration to the cart. |
cart | cart (required) | Update cart quantities or its note. |
contact | None | Send a contact enquiry. |
customer | None | Subscribe an email address to marketing. This is not account creation. |
customer_login | None | Start passwordless email or phone OTP sign-in. |
create_customer | None | Start passwordless customer enrollment. |
customer_address | address (required) | Create or update a signed-in customer’s address. Pass the empty address object supplied by the new-address page when creating. |
localization | None | Change the storefront language. Country/market switching is not supported. |
new_comment | article (required) | Submit a blog comment. |
storefront_password | None | Enter 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
| Type | Object | Purpose |
|---|---|---|
sellerlane_otp_verify | None | Verify the challenge created by login or enrollment. |
sellerlane_cart_change | cart (required) | Change or remove one existing cart line. |
sellerlane_cart_offer | cart (required) | Add a typed recommendation, upsell, or bundle offer. |
sellerlane_customer_logout | customer (required) | End the signed-in customer session. |
sellerlane_customer_contact | customer (required) | Start an additional email/phone contact workflow. Verification uses its separate API endpoint. |
sellerlane_customer_profile | customer (required) | Update non-identity profile fields for a signed-in customer. |
sellerlane_quote_request | product (required) | Request a quote for the current product and variant. |
sellerlane_stock_notification | product (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:
| Property | Meaning |
|---|---|
form.id | The explicit id, or the stable ID generated for this form instance. |
form.errors | Field errors from the most recent submission, otherwise blank. |
form.errors.messages | User-facing messages. Treat this as an iterable summary; it is not guaranteed to be keyed by field. |
form.errors.translated_fields | Optional 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].
Keep the generated form and submit its fields with Accept: application/json. Preserve duplicate names: multi-select modifier values
use repeated fields, so do not collapse FormData with
Object.fromEntries().
const form = document.querySelector('#ContactForm');
form.addEventListener('submit', async (event) => { event.preventDefault();
const encoded = new URLSearchParams(); for (const [name, value] of new FormData(form)) { if (typeof value === 'string') encoded.append(name, value); }
const response = await fetch(form.action, { method: 'POST', headers: { Accept: 'application/json' }, credentials: 'same-origin', body: encoded, });
const result = await response.json(); if (!response.ok) { throw new Error(result.errors?.[0] || result.message || 'Form submission failed'); }});Route-specific success payloads can contain useful data such as an OTP
challenge_id, cart sections, or a stock-subscription status. Treat any
non-2xx response as a failure and render its field errors beside the same
controls.
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 response | Meaning |
|---|---|
403 csrf_missing | Neither the hidden field nor header contained a token. |
403 csrf_mismatch | The submitted token does not match the cookie. |
403 csrf_invalid | The signed cookie pair was changed or belongs to another store. |
403 csrf_origin_blocked | The 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 value | Field name |
|---|---|
| Choice value | sellerlane[modifiers][MODIFIER_ID][values][] |
| Text | sellerlane[modifiers][MODIFIER_ID][text] |
| Number | sellerlane[modifiers][MODIFIER_ID][number] |
| Date | sellerlane[modifiers][MODIFIER_ID][date] |
| Uploaded file key | sellerlane[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: { senderName: "Asha", message: "Enjoy!", scheduledSendAt: "2026-08-25T03:30:00.000Z", }, digitalDeliveryDetails: { }, bundleChildSelections: [ { variantId: firstChildVariantId, modifiers: [ { modifierId: childColourId, modifierValueId: blueValueId }, ], }, { variantId: secondChildVariantId, digitalDeliveryDetails: { }, }, ], }, },};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.
{% form 'sellerlane_otp_verify', id: 'OtpVerify', return_to: routes.account_url %} {{ form.errors | default_errors }} <input type="hidden" name="challenge_id" value="{{ login_challenge_id | escape }}"> <label> Verification code <input name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]*" required > </label> <button type="submit">Verify</button>{% endform %}On the server-rendered verification page, login_challenge_id is the narrow,
trusted page-context value. The safe request object never exposes a query
map. With Ajax, use the challenge_id returned by login/start instead. Never
invent one or replace it with a customer ID. When constructing a JSON body
manually, also preserve the generated form_type and form_id; native and
JSON errors/results use that ID to target the exact form instance.
{% form 'create_customer', id: 'CreateCustomer' %} {{ form.errors | default_errors }} <input name="customer[first_name]" autocomplete="given-name" placeholder="First name"> <input name="customer[last_name]" autocomplete="family-name" placeholder="Last name"> <input name="customer[email]" type="email" autocomplete="email"> <input name="customer[phone]" type="tel" autocomplete="tel"> <label> <input name="customer[accepts_marketing]" type="checkbox" value="true"> Email me product news and offers </label> {% render 'hcaptcha-field', enabled: shop.spam_protection_accounts %} <button type="submit">Continue</button>{% endform %}Enrollment still completes through an OTP challenge. At least one supported contact method must be present; no password or password confirmation is accepted.
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:
| Type | Why it is unsupported | Use instead |
|---|---|---|
activate_customer_password | Customer accounts have no password to activate. | customer_login followed by sellerlane_otp_verify. |
recover_customer_password | There is no password-recovery flow. | Start a new OTP sign-in. |
reset_customer_password | Customer passwords are not stored. | Start a new OTP sign-in. |
guest_login | Checkout does not create a separate guest-login session. | Continue through checkout without an account, or use OTP sign-in. |
currency | Sellerlane does not expose Shopify Markets/currency switching. | Render the store currency and language localization only. |
| B2B company forms | Companies, 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
- Choose a supported type and pass its required object. 2. Give every
repeated form a unique
idand use canonical nested field names. 3. Renderform.errors,default_errors, andform.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.
Related reference
- Cart AJAX API — typed JSON cart payloads, section rendering, modifier-file uploads, and error behavior.
product,product_modifier, andevent_booking— fields used to build product forms.- Locales and translation — published languages and locale-aware URLs.
- Customer accounts — OTP sign-in and account behavior.