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.

Selling personalized products#

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#

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

One input per property, named properties[.]. The platform validates the key against the store's definitions and the value against the definition's type and rules — a failure comes back as a user error with the field path, never a silent drop. Headless clients pass the same data through the Storefront GraphQL addToCart input.

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#

vein
{% for line in cart.items %}
  <h4>{{ line.product.title }}</h4>
  {% for key, value in line.properties %}
    <p class="line-note">{{ 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.properties contains only buyer-visible properties — 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.