{"title":"Theme forms","slug":"theme-forms","url":"https://support.storeconnect.com/articles/theme-forms","url_markdown":"https://support.storeconnect.com/articles/theme-forms.md","subtitle":null,"summary":"The form tag generates HTML forms with correct action URLs, CSRF protection, and field definitions: form tag syntax, the form drop, error handling, CSRF requirements, all form types by category, and extending standard forms with custom parameters.","type":"Developer_Documentation","video_url":"","keywords":"theme forms, form tag, form types, CSRF, authenticity_token, form drop, form errors, add-to-cart, checkout forms, contact form, custom form parameters, liquid forms","last_modified":"2026-08-21T07:12:35+0000","body_markdown":"Forms are the primary interaction mechanism in StoreConnect. The `{% form %}` tag generates HTML forms with the correct action URL, CSRF protection, and field definitions — so you never need to write a raw `\u003cform\u003e` element for user interactions.\n\n## The form tag\n\n\n```liquid\n\n{% form \"form-type\" [, option: value, id: \"form-id\", class: \"form-class\"] %}\n  {{ form.field_name.label }}\n  \u003cinput name=\"{{ form.field_name.name }}\" value=\"{{ form.field_name.value }}\"\u003e\n  \u003cbutton type=\"submit\"\u003eSubmit\u003c/button\u003e\n{% endform %}\n```\n\n\nThe `{% form %}` tag automatically:\n1. Creates a `\u003cform\u003e` HTML element with the correct `action` URL and `method`.\n2. Includes a hidden `authenticity_token` field for CSRF protection.\n3. Makes a `form` drop available inside the block with field definitions and errors.\n4. Passes through extra options as HTML attributes on the `\u003cform\u003e` element.\n\n## HTML attributes on forms\n\nAny option that is not consumed by the form type itself becomes an HTML attribute on the generated `\u003cform\u003e` element. This includes `id`, `class`, and `data-*` attributes:\n\n\n```liquid\n\n{% form \"add-to-cart\", product_id: product.id,\n    class: \"SC-ProductCard_action\",\n    id: \"add-to-cart-form\",\n    data-cart-form: true %}\n  ...\n{% endform %}\n```\n\n\nGenerates:\n\n```html\n\n\u003cform action=\"/products/abc123/add\" method=\"post\"\n      class=\"SC-ProductCard_action\"\n      id=\"add-to-cart-form\"\n      data-cart-form=\"true\"\u003e\n  \u003cinput type=\"hidden\" name=\"authenticity_token\" value=\"...\"\u003e\n  ...\n\u003c/form\u003e\n```\n\n**Common HTML options:**\n\n| Option | Example | Purpose |\n|--------|---------|---------|\n| `class` | `class: \"SC-Panel\"` | CSS class on the form |\n| `id` | `id: \"checkout-form\"` | HTML id attribute |\n| `data-*` | `data-cart-form: true` | Custom data attributes for JavaScript hooks |\n\n**Reserved options** (consumed internally, not passed to HTML): `url`, `method`, `format`, `scope`, `model`, `authenticity_token`, `local`, `builder`, `data`, `html`, `remote`, `data-remote`.\n\n**Form-specific options** (consumed by the form type): For example, `product_id` for `add-to-cart`, `provider` for payment forms, `custom_form` for custom forms. These are extracted by the form handler and do not appear as HTML attributes.\n\n## The form drop\n\nInside a `{% form %}` block, the `form` variable provides:\n\n| Property | Type | Description |\n|----------|------|-------------|\n| `form.errors` | Array | Validation error messages |\n| `form.[field_name]` | FieldDrop | Access to individual form fields |\n| `form.[field_name].name` | String | The input `name` attribute value |\n| `form.[field_name].value` | Any | The current or default value |\n| `form.[field_name].label` | String | Human-readable label |\n| `form.[field_name].id` | String | HTML id attribute |\n| `form.[field_name].errors` | Array | Field-specific errors |\n\n## Error handling\n\nAfter a failed form submission, the form is re-displayed with errors. The `form.errors` array contains error messages:\n\n\n```liquid\n\n{% form \"login\" %}\n  {% render \"form_errors\", errors: form.errors %}\n\n  \u003clabel for=\"email\"\u003e{{ form.username.label }}\u003c/label\u003e\n  \u003cinput type=\"email\"\n         id=\"email\"\n         name=\"{{ form.username.name }}\"\n         value=\"{{ form.username.value }}\"\n         {% if form.errors.size \u003e 0 %}aria-invalid=\"true\"{% endif %}\n         required\u003e\n\n  \u003clabel for=\"password\"\u003e{{ form.password.label }}\u003c/label\u003e\n  \u003cinput type=\"password\"\n         id=\"password\"\n         name=\"{{ form.password.name }}\"\n         required\u003e\n\n  \u003cbutton type=\"submit\"\u003eLog in\u003c/button\u003e\n{% endform %}\n```\n\n\nStoreConnect stashes form data on failed submissions so the page re-renders with fields pre-filled and `form.errors` populated. This happens automatically with `{% form %}`.\n\n## CSRF protection\n\nAll forms require a CSRF token. The `{% form %}` tag includes it automatically as a hidden `authenticity_token` field. Your layout must include `{{ csrf_meta_tags }}` in the `\u003chead\u003e` for AJAX requests:\n\n```javascript\n\nconst token = document.querySelector('meta[name=\"csrf-token\"]').content;\n\nfetch('/cart/items', {\n  method: 'POST',\n  headers: {\n    'X-CSRF-Token': token,\n    'Content-Type': 'application/x-www-form-urlencoded'\n  },\n  body: new URLSearchParams({ product_id: '123', quantity: '1' })\n});\n```\n\n:::warning\nNever write a raw `\u003cform\u003e` element for user interactions. Without the `{% form %}` tag, the CSRF token is missing and all submissions will fail with a security error.\n:::\n\n## Form submission flow\n\n1. User fills in the form and clicks submit.\n2. Browser sends a POST request with `application/x-www-form-urlencoded` data, including the `authenticity_token`.\n3. The platform verifies the CSRF token.\n4. The platform validates the data.\n5. **On success:** the platform performs the action and redirects, usually with a flash notice.\n6. **On failure:** the platform re-renders the page with the form pre-filled and `form.errors` populated.\n\n## Form types by category\n\nEvery form type StoreConnect registers, grouped the way the platform groups them.\nUse the name exactly as shown. `{% form %}` resolves the action URL, method, and\nCSRF token for you, so a theme never needs to know the endpoint.\n\n### Accounts\n\n| Form type | Purpose |\n|-----------|---------|\n| `register` | Create an account |\n| `account` | Edit the signed-in account |\n| `account-missing-details` | Supply details missing from an account |\n| `forgot-password` | Request a password reset |\n| `reset-password` | Set a new password |\n| `resend-confirmation` | Resend the confirmation email |\n| `accept-invitation` | Accept an invitation to an account |\n\n### Session\n\n| Form type | Purpose |\n|-----------|---------|\n| `login` | Sign in |\n| `sso-login` | Sign in through single sign-on |\n| `single-sign-on` | Alias of `sso-login`; both resolve to the same form |\n\n### Cart\n\n| Form type | Purpose |\n|-----------|---------|\n| `add-to-cart` | Add a product to the cart |\n| `add-bundle-to-cart` | Add a configured bundle to the cart |\n| `add-preset-bundle` | Add a preset bundle to the cart |\n| `cart` | Update quantities or remove items |\n\n### Checkout\n\n| Form type | Purpose |\n|-----------|---------|\n| `checkout-customer-information` | Customer details step |\n| `checkout-shipping-information` | Shipping method step |\n| `checkout-accept-terms` | Terms acceptance step |\n| `checkout-set-password` | Set a password during checkout |\n| `payment` | Submit payment |\n| `payment-not-required` | Complete an order with nothing to pay |\n| `apply-promo-code` | Apply a promotion code |\n| `remove-promo-code` | Remove an applied promotion code |\n| `apply-voucher` | Apply a voucher |\n| `remove-voucher` | Remove an applied voucher |\n| `activate-voucher` | Activate a voucher |\n| `apply-account-credit` | Apply account credit |\n| `remove-account-credit` | Remove applied account credit |\n\n### Payments and subscriptions\n\n| Form type | Purpose |\n|-----------|---------|\n| `subscription-payment` | Pay a subscription |\n| `update-subscription-payment-details` | Change the card a subscription bills to |\n| `additional-payment-billing-address` | Billing address for an additional payment |\n\n### Bookings\n\n| Form type | Purpose |\n|-----------|---------|\n| `booking-attendee-add` | Add an attendee to a booking |\n| `booking-attendee-edit` | Edit a booking attendee |\n\n### Privacy\n\n| Form type | Purpose |\n|-----------|---------|\n| `privacy-settings` | Save cookie and privacy choices |\n| `privacy-accept-all` | Accept all cookie categories |\n| `privacy-reject-all` | Reject all optional cookie categories |\n\n### Geolocation\n\n| Form type | Purpose |\n|-----------|---------|\n| `geolocation-select` | Choose a location |\n| `geolocation-dismiss` | Dismiss the location prompt |\n\n### Custom forms\n\n| Form type | Purpose |\n|-----------|---------|\n| `custom-form` | Submit a Custom Form defined in Salesforce |\n\n## Common form patterns\n\n### Add to cart\n\n\n```liquid\n\n{% form \"add-to-cart\", product_id: product.id %}\n  \u003cinput type=\"hidden\" name=\"{{ form.variant_id.name }}\" value=\"{{ product.default_variant.id }}\"\u003e\n\n  \u003clabel for=\"quantity\"\u003eQuantity\u003c/label\u003e\n  \u003cinput type=\"number\" id=\"quantity\" name=\"{{ form.quantity.name }}\" value=\"1\" min=\"1\"\u003e\n\n  \u003cbutton type=\"submit\"\u003eAdd to cart\u003c/button\u003e\n{% endform %}\n```\n\n\n### Login\n\n\n```liquid\n\n{% form \"login\", class: \"login-form\" %}\n  {% render \"form_errors\", errors: form.errors %}\n\n  \u003cdiv class=\"field\"\u003e\n    \u003clabel for=\"email\"\u003e{{ form.username.label }}\u003c/label\u003e\n    \u003cinput type=\"email\" id=\"email\" name=\"{{ form.username.name }}\" value=\"{{ form.username.value }}\" required\u003e\n  \u003c/div\u003e\n\n  \u003cdiv class=\"field\"\u003e\n    \u003clabel for=\"password\"\u003e{{ form.password.label }}\u003c/label\u003e\n    \u003cinput type=\"password\" id=\"password\" name=\"{{ form.password.name }}\" required\u003e\n  \u003c/div\u003e\n\n  \u003cbutton type=\"submit\"\u003eLog in\u003c/button\u003e\n\n  \u003cp\u003e\u003ca href=\"/password/forgot\"\u003eForgot password?\u003c/a\u003e\u003c/p\u003e\n  \u003cp\u003e\u003ca href=\"/register\"\u003eCreate account\u003c/a\u003e\u003c/p\u003e\n{% endform %}\n```\n\n\n### Contact form\n\nThere is no built-in contact form type. Build the form as a **Custom Form** in\nSalesforce, then render it with `custom-form` as shown below.\n\n### Custom form\n\nCustom forms are defined in the CMS and rendered from a form object:\n\n\n```liquid\n\n{% form \"custom-form\", custom_form: my_form %}\n  {% for question in my_form.questions %}\n    \u003cdiv class=\"field\"\u003e\n      \u003clabel\u003e{{ question.label }}\u003c/label\u003e\n      {% case question.question_type %}\n      {% when \"text\" %}\n        \u003cinput type=\"text\" name=\"{{ question.input_name }}\" value=\"{{ question.answer_value }}\"\u003e\n      {% when \"text_area\" %}\n        \u003ctextarea name=\"{{ question.input_name }}\"\u003e{{ question.answer_value }}\u003c/textarea\u003e\n      {% when \"picklist\" %}\n        \u003cselect name=\"{{ question.input_name }}\"\u003e\n          {% for option in question.picklist_values %}\n            \u003coption value=\"{{ option }}\" {% if question.answer_value == option %}selected{% endif %}\u003e{{ option }}\u003c/option\u003e\n          {% endfor %}\n        \u003c/select\u003e\n      {% when \"date\" %}\n        \u003cinput type=\"date\" name=\"{{ question.input_name }}\" value=\"{{ question.answer_value }}\"\u003e\n      {% endcase %}\n    \u003c/div\u003e\n  {% endfor %}\n  \u003cbutton type=\"submit\"\u003eSubmit\u003c/button\u003e\n{% endform %}\n```\n\n\n## Advanced: extending forms with custom parameters\n\nYou can add any extra `\u003cinput\u003e` elements inside a `{% form %}` block. The standard form handler ignores unrecognized fields, but they are still submitted and available via `current_request.params` in Liquid controllers. This opens up powerful patterns for custom logic.\n\n### How it works\n\n1. Add custom hidden inputs or visible fields inside any `{% form %}` block.\n2. The form submits them alongside the standard fields.\n3. The platform's built-in form handler processes the standard fields and ignores the extras.\n4. A Liquid controller (`before` / `after` / `final`) can read the extras via `current_request.params`.\n\n### Example: post-action data capture\n\nCapture a gift message from the add-to-cart form and save it to the cart:\n\n\n```liquid\n\n{% form \"add-to-cart\", product_id: current_product.id %}\n  \u003cinput type=\"hidden\" name=\"{{ form.variant_id.name }}\" value=\"{{ current_product.default_variant.id }}\"\u003e\n  \u003cinput type=\"number\" name=\"{{ form.quantity.name }}\" value=\"1\" min=\"1\"\u003e\n\n  \u003clabel\u003eGift message (optional)\u003c/label\u003e\n  \u003ctextarea name=\"gift_message\" maxlength=\"200\"\u003e\u003c/textarea\u003e\n\n  \u003cbutton type=\"submit\"\u003eAdd to cart\u003c/button\u003e\n{% endform %}\n```\n\n\nIn a Liquid controller (`controllers/carts/add.liquid`):\n\n\n```liquid\n\n{% after %}\n  {%- assign params = current_request.params -%}\n  {% if params.gift_message != blank %}\n    {% update current_cart, field: \"gift_message__c\", value: params.gift_message %}\n  {% endif %}\n{% endafter %}\n```\n\n\n### Example: client-side validation with server redirect\n\nJavaScript sets a hidden input to flag an invalid state. The `before` controller checks it and redirects with an error before the form is processed:\n\n\n```liquid\n\n{% form \"checkout-shipping-information\" %}\n  \u003cinput type=\"hidden\" name=\"js_validation_failed\" id=\"js-validation\" value=\"\"\u003e\n  {% render \"checkout/shipping_information/form\", form: form %}\n  \u003cbutton type=\"submit\"\u003eContinue\u003c/button\u003e\n{% endform %}\n```\n\n\nIn `controllers/checkout/steps/shipping/update.liquid`:\n\n\n```liquid\n\n{% before %}\n  {%- assign params = current_request.params -%}\n  {% if params.js_validation_failed != blank %}\n    {% redirect to: \"/checkout/shipping_information\", alert: \"Please enter a valid shipping address\" %}\n  {% endif %}\n{% endbefore %}\n```\n\n\n### Key points for custom parameters\n\n- Any input name works — the platform ignores fields it does not recognize, so extra inputs are safe.\n- `current_request.params` contains all submitted form data as a Map, including your custom fields.\n- `before` runs first — use it to validate or redirect before the standard action.\n- `after` runs second — use it to persist extra data after the standard action succeeds.\n- `{% redirect %}` stops execution — once a redirect is issued in `before`, the standard action and `after` are skipped.\n\n### The `{% redirect %}` tag\n\n\n```liquid\n\n{% redirect to: \"/path\" %}\n{% redirect to: \"/path\", notice: \"Operation completed\" %}\n{% redirect to: \"/path\", alert: \"Something went wrong\" %}\n{% redirect to: \"/path\", status: 301 %}\n```\n\n\n| Option | Description |\n|--------|-------------|\n| `to` | URL path to redirect to (required) |\n| `notice` | Flash notice message (informational) |\n| `alert` | Flash alert message (error) |\n| `status` | HTTP status code (default: 302) |"}