Has anyone solved passing custom line item properties via a Theme App Extension? (Stuck on form payload binding)

Has anybody gone through this or might know a solution.

I’m building a custom Theme App Extension that displays a dropdown of team rosters (pulled from product metafields) on the product page. Everything looks and works great on the frontend UI, and we are tracking hidden player IDs behind the scenes.

The Problem: When a customer selects an option (or types in a custom name when choosing “Other”) and clicks “Add to Cart,” the data isn’t saving to the line_item custom properties. It just disappears before reaching the cart, checkout, or order details.

What I’m running into:

  • Almost every tutorial or guide I can find online only talks about hardcoding solutions directly into theme files (main-product.liquid, etc.). However, I really need this to be handled cleanly through an app extension block for better modularity and synchronization with our external dashboard app.

  • Apps like Magical Form Fields pull this off seamlessly and even display the selected athlete name right underneath the item in the cart/checkout.

  • I tested out code provided by the Shopify AI assistant. While the UI renders and behaves properly, it still fails to assign the entered values to the actual line item properties on submit.

Here is my code:

{% assign team_data = product.metafields.custom.team_athletes.value %}

<div class="athlete-selector-block" style="margin-top: {{ block.settings.margin_top }}px; margin-bottom: {{ block.settings.margin_bottom }}px;">
  <label for="AthleteSelect-{{ block.id }}" style="font-weight: 600; display: block; margin-bottom: 6px;">
    {{ block.settings.label_text }} {% if block.settings.is_required %}<span style="color: red;">*</span>{% endif %}
  </label>

  {% comment %} 
    Main dropdown attached to the product form. 
  {% endcomment %}
  <select 
    id="AthleteSelect-{{ block.id }}" 
    name="properties[{{ block.settings.property_name }}]"
    form="product-form-{{ section.id }}"
    {% if block.settings.is_required %}required{% endif %}
    onchange="handleAthleteChange_{{ block.id | replace: '-', '_' }}(this)"
    style="width: 100%; padding: 10px; border-radius: 4px; border: 1px solid #ccc; font-size: 14px; background-color: #fff;"
  >
    <option value="" disabled selected>{{ block.settings.placeholder_text }}</option>
    <option value="OTHER" data-player-id="" data-team-id="">Can't find my athlete / Other</option>

    {% if team_data and team_data.teams %}
      {% for team in team_data.teams %}
        <optgroup label="--- {{ team.teamName }} ---">
          {% for athlete in team.athletes %}
            {% if athlete.id %}
              {% comment %} Structured JSON with Backend IDs {% endcomment %}
              <option 
                value="{{ athlete.name }}" 
                data-player-id="{{ athlete.id }}" 
                data-team-id="{{ team.teamId | default: '' }}"
              >
                {{ athlete.name }}
              </option>
            {% else %}
              {% comment %} Legacy Plain String Fallback {% endcomment %}
              <option 
                value="{{ athlete }} ({{ team.teamName }})" 
                data-player-id="" 
                data-team-id=""
              >
                {{ athlete }}
              </option>
            {% endif %}
          {% endfor %}
        </optgroup>
      {% endfor %}
    {% else %}
      {% comment %} Demo Fallback {% endcomment %}
      <optgroup label="--- Team 1 ---">
        <option value="John (Team 1)" data-player-id="101" data-team-id="1">John</option>
        <option value="Seth (Team 1)" data-player-id="102" data-team-id="1">Seth</option>
      </optgroup>
    {% endif %}
  </select>

  {% comment %} Hidden backend properties (_playerId and _teamId are sent to Webhooks/GraphQL) {% endcomment %}
  <input type="hidden" id="HiddenPlayerId-{{ block.id }}" name="properties[_playerId]" value="" form="product-form-{{ section.id }}" />
  <input type="hidden" id="HiddenTeamId-{{ block.id }}" name="properties[_teamId]" value="" form="product-form-{{ section.id }}" />

  {% comment %} Custom Athlete Container when OTHER is selected {% endcomment %}
  <div id="CustomAthleteContainer-{{ block.id }}" style="display: none; margin-top: 10px;">
    <label for="CustomAthleteInput-{{ block.id }}" style="font-size: 13px; font-weight: 600; display: block; margin-bottom: 4px;">
      Type Athlete Name <span style="color: red;">*</span>
    </label>
    <input 
      type="text" 
      id="CustomAthleteInput-{{ block.id }}" 
      name="properties[{{ block.settings.custom_property_name }}]"
      placeholder="First & Last Name"
      form="product-form-{{ section.id }}"
      style="width: 100%; padding: 8px; border-radius: 4px; border: 1px solid #ccc; font-size: 14px;"
    />
  </div>
</div>

<script>
  function handleAthleteChange_{{ block.id | replace: '-', '_' }}(selectEl) {
    const blockId = '{{ block.id }}';
    const customContainer = document.getElementById('CustomAthleteContainer-' + blockId);
    const customInput = document.getElementById('CustomAthleteInput-' + blockId);
    const hiddenPlayerInput = document.getElementById('HiddenPlayerId-' + blockId);
    const hiddenTeamInput = document.getElementById('HiddenTeamId-' + blockId);

    const selectedOption = selectEl.options[selectEl.selectedIndex];
    const playerId = selectedOption.getAttribute('data-player-id') || '';
    const teamId = selectedOption.getAttribute('data-team-id') || '';

    // Pass IDs to hidden properties
    if (hiddenPlayerInput) hiddenPlayerInput.value = playerId;
    if (hiddenTeamInput) hiddenTeamInput.value = teamId;

    if (selectEl.value === 'OTHER') {
      customContainer.style.display = 'block';
      customInput.required = true;
      customInput.focus();
    } else {
      customContainer.style.display = 'none';
      customInput.required = false;
      customInput.value = '';
    }
  }

  // Ensure form attribute binds to product form on DOM ready
  document.addEventListener('DOMContentLoaded', function() {
    const form = document.querySelector('form[action*="/cart/add"]');
    if (form && !form.id) {
      form.id = 'product-form-{{ section.id }}';
    }
  });
</script>

{% schema %}
{
  "name": "Supported Athlete",
  "target": "section",
  "settings": [
    {
      "type": "text",
      "id": "label_text",
      "label": "Field Label",
      "default": "Select Athlete to Support"
    },
    {
      "type": "text",
      "id": "placeholder_text",
      "label": "Placeholder Text",
      "default": "-- Select an Athlete --"
    },
    {
      "type": "text",
      "id": "property_name",
      "label": "Cart Line Item Property Name",
      "default": "Name of athlete you are supporting"
    },
    {
      "type": "text",
      "id": "custom_property_name",
      "label": "Custom Name Property Name",
      "default": "Custom Athlete Name"
    },
    {
      "type": "checkbox",
      "id": "is_required",
      "label": "Make Selection Mandatory",
      "default": true
    },
    {
      "type": "range",
      "id": "margin_top",
      "min": 0,
      "max": 40,
      "step": 2,
      "unit": "px",
      "label": "Margin Top",
      "default": 12
    },
    {
      "type": "range",
      "id": "margin_bottom",
      "min": 0,
      "max": 40,
      "step": 2,
      "unit": "px",
      "label": "Margin Bottom",
      "default": 12
    }
  ]
}
{% endschema %}

Here is a code example Shopify AI assistant gave me which is facing the same issue:

{% comment %}
App block: Custom line item property
File: blocks/properties_example.liquid
{% endcomment %}

{%- if request.page_type == 'product' -%}
  {%- comment -%} Capture the parent section's form ID {%- endcomment -%}
  {%- assign product_form_id = 'product-form-' | append: section.id -%}
  
  <div class="custom-line-item-property">
    <label for="custom_property_input-{{ block.id }}">
      {{ block.settings.label }}
    </label>
    <input
      id="custom_property_input-{{ block.id }}"
      type="text"
      name="properties[{{ block.settings.property_key }}]"
      form="{{ product_form_id }}" 
      value=""
    >
  </div>
{%- endif -%}

{% schema %}
{
  "name": "Custom line item property",
  "target": "section",
  "templates": ["product"],
  "settings": [
    {
      "type": "text",
      "id": "label",
      "label": "Input label",
      "default": "Enter a custom value"
    },
    {
      "type": "text",
      "id": "property_key",
      "label": "Property name (key)",
      "default": "Custom value"
    }
  ]
}
{% endschema %}

Any input or help will be greatly appreciated.

How sure are you that the form ID matches the section ID? I’d probably start there.

Before touching the code again, open the product page and run this in the console.

document.querySelector('[name^="properties["]').form

If that comes back null, the input is not attached to any form and the rest of your setup is probably fine. That single line is the whole bug in most of these cases, and it matches your symptom exactly. A form attribute pointing at an id that does not exist on the page gets silently ignored by the browser. No error, no warning, the input just never gets submitted.

The reason that AI generated snippet fails is that product-form-{{ section.id }} guesses two things at the same time. First that your theme names its product form the way Dawn does, which is a convention and not a platform contract. Second that the section holding your app block is the same section holding the Add to cart button. section.id inside an app block resolves to whichever section the merchant dropped the block into, so as soon as that block sits outside the main product section, those two ids can never line up.

So stop guessing the name and find the real form at runtime.

const form = document.querySelector('form[action*="/cart/add"]');
const input = document.getElementById('custom_property_input-{{ block.id }}');
if (form && input) {
  if (!form.id) form.id = 'app-product-form';
  input.setAttribute('form', form.id);
}

If .form already returns the correct form and the values still disappear, then it is not a binding problem, it is the theme JS. Plenty of themes build the add to cart payload by hand as {items:[{id, quantity}]} rather than passing new FormData(form), and that drops properties even when your markup is perfect. Open the Network tab, add to cart, and look at the request to /cart/add.js to see whether properties are in the body. That check tells you which of the two halves you are actually fighting, which is worth doing before you rewrite anything.

Two smaller ones that catch people here. A property with an empty value does not get stored, so your “Other” path with a blank text box will look identical to a binding failure even when the wiring is correct. And properties[_player_id] with the leading underscore is hidden from the customer at checkout by design, so those hidden player IDs can be saving perfectly while you are checking a place they were never going to appear. Admin order details still show them.

What theme are you building against, and what does .form come back as?

Hello,

Use this code,

{% if request.page_type == ‘product’ %}
{% assign product_form_id = ‘product-form-’ | append: section.id %}

<div class="custom-line-item-property">
    <label for="custom_property_input-{{ block.id }}">
      {{ block.settings.label }}
    </label>

<input
  id="custom_property_input-{{ block.id }}"
  type="text"
  name="properties[{{ block.settings.property_key }}]"
  form="{{ product_form_id }}"
  value=""
>

</div>
{% endif %}
{
  "name": "Custom line item property",
  "target": "section",
  "templates": ["product"],
  "settings": [
    {
      "type": "text",
      "id": "label",
      "label": "Label",
      "default": "Enter value"
    },
    {
      "type": "text",
      "id": "property_key",
      "label": "Property Key",
      "default": "Custom Value"
    }
  ]
}

document.addEventListener("DOMContentLoaded", () => {
const propertyWrapper = document.querySelector(".custom-line-item-property");

if (!propertyWrapper) return;

const productForm = document.querySelector('form[action*="/cart/add"]');

if (productForm) {
productForm.appendChild(propertyWrapper);
}
});

Let me know if you face any issue.
Thanks!!

Thank you for your help! I found the issue! I got null when entering document.querySelector(‘[name^=“properties[”]’).form in the console so that was good sign. When I tried to add your code to my theme extension app code, it didn’t work. I had the idea of modifying it to add it to the console, and that showed me the issue. I modified your code to this:"[

 // 1. Find the cart action form or component
const form = document.querySelector('form[action*="/cart/add"]');

// 2. Find your input by its class name instead of a Liquid ID
const input = document.querySelector('input[name*="properties"]');

if (form && input) {
if (!form.id) {
form.id = 'app-product-form';
}
input.setAttribute('form', form.id);
console.log("Success! Input successfully linked to form ID:", form.id);
} else {
console.log("Could not find form or input. Check your selectors.");
}

I got this output:
Success! Input successfully linked to form ID: <input type="hidden" name="id" ref="variantId" value="48480201146560">

I also noticed when looking at the console and pressing add to cart with my fields filled, I got this error:

 Error injecting cart properties: SyntaxError: JSON.parse: unexpected character at line 1 column 2 of the JSON data

    fetch john-test-ticket:2904

    handleSubmit product-form.js:183

    registerEventListeners component.js:252

    registerEventListeners component.js:205

    connectedCallback component.js:52

    connectedCallback dialog.js:16

    <anonymous> john-test-ticket:250

    <anonymous> dialog.js:138

 john-test-ticket:2920:23

    fetch john-test-ticket:2920

    handleSubmit product-form.js:183

    registerEventListeners component.js:252

    (Async: EventListener.handleEvent)

    registerEventListeners component.js:205

    connectedCallback component.js:52

    connectedCallback dialog.js:16

    <anonymous> john-test-ticket:250

    <anonymous> dialog.js:138 

All this meant was that the injection was failing, and it was because I was getting the whole JavaScript string, not just the ID value I needed. So I modified the code to grab the ID via getattributes. And I added another fix to bind the hidden IDs to the payload. Now it shows the name in the cart, and the webhook sent includes the IDs in the line item properties. Here is my code for anybody in the future that needs an example of a theme extension app that grabs metafield data and adds custom lineitem properties that work for the Horizon theme:

{% assign team_data = product.metafields.custom.team_athletes.value %}

<div class="athlete-selector-block" style="margin-top: {{ block.settings.margin_top }}px; margin-bottom: {{ block.settings.margin_bottom }}px;">
  <label for="AthleteSelect-{{ block.id }}" style="font-weight: 600; display: block; margin-bottom: 6px;">
    {{ block.settings.label_text }} {% if block.settings.is_required %}<span style="color: red;">*</span>{% endif %}
  </label>

  {% comment %} 
    Main dropdown attached to the product form. 
  {% endcomment %}
  <select 
    id="AthleteSelect-{{ block.id }}" 
    name="properties[{{ block.settings.property_name }}]"
    form="product-form-{{ section.id}}"
    {% if block.settings.is_required %}required{% endif %}
    onchange="handleAthleteChange_{{ block.id | replace: '-', '_' }}(this)"
    style="width: 100%; padding: 10px; border-radius: 4px; border: 1px solid #ccc; font-size: 14px; background-color: #fff;"
  >
    <option value="" disabled selected>{{ block.settings.placeholder_text }}</option>
    <option value="OTHER" data-player-id="" data-team-id="">Can't find my athlete / Other</option>

    {% if team_data and team_data.teams %}
      {% for team in team_data.teams %}
        <optgroup label="--- {{ team.teamName }} ---">
          {% for athlete in team.athletes %}
            {% if athlete.id %}
              {% comment %} Structured JSON with Backend IDs {% endcomment %}
              <option 
                value="{{ athlete.name }}" 
                data-player-id="{{ athlete.id }}" 
                data-team-id="{{ team.teamId | default: '' }}"
              >
                {{ athlete.name }}
              </option>
            {% else %}
              {% comment %} Legacy Plain String Fallback {% endcomment %}
              <option 
                value="{{ athlete }} ({{ team.teamName }})" 
                data-player-id="" 
                data-team-id=""
              >
                {{ athlete }}
              </option>
            {% endif %}
          {% endfor %}
        </optgroup>
      {% endfor %}
    {% else %}
      {% comment %} Demo Fallback {% endcomment %}
      <optgroup label="--- Team 1 ---">
        <option value="John (Team 1)" data-player-id="101" data-team-id="1">John</option>
        <option value="Seth (Team 1)" data-player-id="102" data-team-id="1">Seth</option>
      </optgroup>
    {% endif %}
  </select>

  {% comment %} Hidden backend properties (_playerId and _teamId are sent to Webhooks/GraphQL) {% endcomment %}
  <input type="hidden" id="HiddenPlayerId-{{ block.id }}" name="properties[_playerId]" value="" form="product-form-{{ section.id}}" />
  <input type="hidden" id="HiddenTeamId-{{ block.id }}" name="properties[_teamId]" value="" form="product-form-{{ section.id}}" />

  {% comment %} Custom Athlete Container when OTHER is selected {% endcomment %}
  <div id="CustomAthleteContainer-{{ block.id }}" style="display: none; margin-top: 10px;">
    <label for="CustomAthleteInput-{{ block.id }}" style="font-size: 13px; font-weight: 600; display: block; margin-bottom: 4px;">
      Type Athlete Name <span style="color: red;">*</span>
    </label>
    <input 
      type="text" 
      id="CustomAthleteInput-{{ block.id }}" 
      name="properties[{{ block.settings.custom_property_name }}]"
      placeholder="First & Last Name"
      form="product-form-{{ section.id}}"
      style="width: 100%; padding: 8px; border-radius: 4px; border: 1px solid #ccc; font-size: 14px;"
    />
  </div>
</div>

<script>
  function handleAthleteChange_{{ block.id | replace: '-', '_' }}(selectEl) {
    const blockId = '{{ block.id }}';
    const customContainer = document.getElementById('CustomAthleteContainer-' + blockId);
    const customInput = document.getElementById('CustomAthleteInput-' + blockId);
    const hiddenPlayerInput = document.getElementById('HiddenPlayerId-' + blockId);
    const hiddenTeamInput = document.getElementById('HiddenTeamId-' + blockId);

    const selectedOption = selectEl.options[selectEl.selectedIndex];
    const playerId = selectedOption.getAttribute('data-player-id') || '';
    const teamId = selectedOption.getAttribute('data-team-id') || '';

    // Pass IDs to hidden properties
    if (hiddenPlayerInput) hiddenPlayerInput.value = playerId;
    if (hiddenTeamInput) hiddenTeamInput.value = teamId;

    if (selectEl.value === 'OTHER') {
      customContainer.style.display = 'block';
      customInput.required = true;
      customInput.focus();
    } else {
      customContainer.style.display = 'none';
      customInput.required = false;
      customInput.value = '';
    }
  }

  // Ensure form attribute binds to product form on DOM ready
  document.addEventListener('DOMContentLoaded', function() {
    const form = document.querySelector('form[action*="/cart/add"]');
    if (!form) return;

    // FIX 1: Use getAttribute('id') to prevent the <input name="id"> from hijacking the variable
    let realFormId = form.getAttribute('id');
    if (!realFormId) {
      realFormId = 'app-product-form-{{ block.id }}';
      form.setAttribute('id', realFormId);
    }

    // FIX 2: Bind ALL inputs from this app block to the form
    const inputsToBind = [
      document.getElementById('AthleteSelect-{{ block.id }}'),
      document.getElementById('HiddenPlayerId-{{ block.id }}'),
      document.getElementById('HiddenTeamId-{{ block.id }}'),
      document.getElementById('CustomAthleteInput-{{ block.id }}')
    ];

    inputsToBind.forEach(function(input) {
      if (input) {
        input.setAttribute('form', realFormId);
      }
    });
  });
</script>

{% schema %}
{
  "name": "Supported Athlete",
  "target": "section",
  "settings": [
    {
      "type": "text",
      "id": "label_text",
      "label": "Field Label",
      "default": "Select Athlete to Support"
    },
    {
      "type": "text",
      "id": "placeholder_text",
      "label": "Placeholder Text",
      "default": "-- Select an Athlete --"
    },
    {
      "type": "text",
      "id": "property_name",
      "label": "Cart Line Item Property Name",
      "default": "Name of athlete you are supporting"
    },
    {
      "type": "text",
      "id": "custom_property_name",
      "label": "Custom Name Property Name",
      "default": "Custom Athlete Name"
    },
    {
      "type": "checkbox",
      "id": "is_required",
      "label": "Make Selection Mandatory",
      "default": true
    },
    {
      "type": "range",
      "id": "margin_top",
      "min": 0,
      "max": 40,
      "step": 2,
      "unit": "px",
      "label": "Margin Top",
      "default": 12
    },
    {
      "type": "range",
      "id": "margin_bottom",
      "min": 0,
      "max": 40,
      "step": 2,
      "unit": "px",
      "label": "Margin Bottom",
      "default": 12
    }
  ]
}
{% endschema %}

And a simpler example that just adds a text field that assigns the custom lineitem property:

{% comment %}
  App block: Custom line item property
  File: blocks/properties_example.liquid
{% endcomment %}

{%- if request.page_type == 'product' -%}
  <div class="custom-line-item-property">
    <label for="custom_property_input-{{ block.id }}">
      {{ block.settings.label }}
    </label>
    <input
      id="custom_property_input-{{ block.id }}"
      type="text"
      name="properties[{{ block.settings.property_key }}]"
      value=""
    >
  </div>

  <script>
    document.addEventListener('DOMContentLoaded', function() {
      // Find the cart form dynamically on the page
      const form = document.querySelector('form[action*="/cart/add"]');
      const input = document.getElementById('custom_property_input-{{ block.id }}');

      if (form && input) {
        // Use getAttribute('id') to prevent DOM clobbering from <input name="id">
        let realFormId = form.getAttribute('id');

        // Fallback: If the theme form has no ID at all, create one on the fly
        if (!realFormId) {
          realFormId = 'app-product-form-{{ block.id }}';
          form.setAttribute('id', realFormId);
        }

        // Programmatically bind the input to the target form
        input.setAttribute('form', realFormId);
      }
    });
  </script>
{%- endif -%}

{% schema %}
{
  "name": "Custom line item property",
  "target": "section",
  "templates": ["product"],
  "settings": [
    {
      "type": "text",
      "id": "label",
      "label": "Input label",
      "default": "Enter a custom value"
    },
    {
      "type": "text",
      "id": "property_key",
      "label": "Property name (key)",
      "default": "Custom value"
    }
  ]
}
{% endschema %}

glad you found it — one thing though, the dynamic form-link approach has a few traps we ran into in production with this exact pattern:

  1. multiple /cart/add forms on the page. featured-product sections, quick-buy modals and sticky add-to-cart bars all bring their own form, and querySelector('form[action*="/cart/add"]') just grabs the first one in the DOM — not necessarily the one your block sits in. safer: walk up from your own block first (container.closest('form[action*="/cart/add"]')) and only fall back to the global lookup if that returns nothing.
  2. variant changes re-render the form. dawn-style themes morph the product section when the customer picks another variant, which can recreate the form without your assigned id — your input silently detaches again and you lose the property exactly for the customers who changed variants. re-run the linking after variant change (listen on the variant selects, or a MutationObserver on the section).
  3. some themes never submit the form at all. a few ajax themes build the payload by hand ({ id, quantity, properties } straight to /cart/add.js) instead of serializing the form — then form-linked inputs never arrive no matter what you do. quick check: network tab, look at the cart/add request. if its hand-built json, you need to hook that call instead.

and for your hidden player ids: prefix the key with an underscore (properties[_playerId]) — most themes hide underscore properties in cart and order display, so customers dont see the raw ids but they still land on the line item for your dashboard app.

Thank you for the advice! I currently changed my DOM function to use container.closest and I confirmed that my athlete names are showing in the add.js when checking adding an item to cart.