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#
{% 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[
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#
{% 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.