Selling personalized products

Declared cart line properties end to end: define the field, collect it in the buy form, render it in the cart, target lines precisely.

Cart line properties carry per-line shopper input — engraving text, a gift message, a chosen date — from the buy form through the cart to the order. They are DECLARED data: every property is defined first (type, rules, visibility), then collected. There are no free-form keys; an undeclared key is rejected with a typed user error.

1 — The merchant defines the field#

Settings → Metafield definitions → Cart lines. A definition has a namespace and key (together the wire key, e.g. "custom.engraving"), a value type (text, integer, decimal, boolean, date, URL, or a fixed choice), optional rules (max length, pattern, choices), and buyer access: read_write (the shopper types and sees it) or hidden (machine/merchant data — never rendered to the shopper). Apps declare their own definitions in the developer portal under their app namespace; the merchant consents at install.

2 — The theme collects it#

vascula
{% form 'cart_add' %}
  <input type="hidden" name="product_id" value="{{ product.id }}">
  <input type="hidden" name="variant_id" value="{{ product.selected_variant.id }}">
  <label>{{ 'products.engraving' | t }}
    <input name="properties[custom.engraving]" maxlength="20">
  </label>
  <button type="submit">{{ 'products.add_to_cart' | t }}</button>
{% endform %}

Themes are not given the store's list of line properties, and should not render an input for each on every product: which products take input belongs to whatever collects it. An app does this from its own block and surface, choosing the products itself, and adds the lines with cart.add_lines. Merchant code that knows its keys can post them in a form like the one above, named properties[.]. The platform validates each key against the store's definitions and each value against the definition's type and rules; a failure comes back as a user error with the field path, never a silent drop.

3 — Lines split by personalization#

Line identity is (product, variant, properties): the same mug with two different engravings is TWO cart lines, each with a stable line_id. Adding the same product with the same properties merges quantities on that one line.

4 — The cart renders and targets lines#

vascula
{% for line in cart.items %}
  <h4>{{ line.product.title }}</h4>
  {% for prop in line.property_list %}
    <p class="line-note">{{ prop.name }}: {{ prop.display_value }}</p>
  {% endfor %}
  {% form 'cart_update' %}
    <input type="hidden" name="product_id" value="{{ line.product_id }}">
    <input type="hidden" name="line_id" value="{{ line.line_id }}">
    <input type="number" name="quantity" value="{{ line.quantity }}">
  {% endform %}
{% endfor %}

line.property_list gives each buyer-visible property in the merchant's field order with its key, name (the field's label), value, and display_value: a choice's label instead of its key, and a translated Yes or No for checkboxes. Show name and display_value to shoppers. line.properties holds the same properties as a map of key to value; looping over it gives each entry as a pair, prop[0] for the key and prop[1] for the value. Hidden definitions (app references, internal flags) never reach the theme or any shopper surface. At checkout, all properties snapshot onto the order line, where fulfillment sees them.