Shopify combined listings let a Plus store sell separate products, one per colour or material, as a single product page. Create an empty parent with combinedListingRole: PARENT, attach the existing products with combinedListingUpdate, and publish the parent. Then the theme has to load each child’s price, images and stock when the buyer switches option.
The data side takes two mutations. The theme side is where most builds go wrong, because a picker that loops over product.variants looks fine with three children, slows the page at forty, and silently drops variants past 250.
Everything below was written against Admin GraphQL 2026-10.
What does a combined listing change?
Each child is an ordinary product: “Field Jacket - Navy”, “Field Jacket - Olive”, with its own handle, images, description, variants and inventory. The parent is a new product that owns none of that. Shopify’s combined listings overview is blunt about it: parent products cannot be purchased, have no inventory and record no sales. Checkout and orders only ever show the child.
That split is why merchants want it. Each colour keeps its own URL and its own photography, but the collection page shows one jacket instead of five.
The limits decide whether it fits:
The store must be on Shopify Plus, and the listing only works on the Online Store channel. Point of Sale, third-party channels and subscriptions are out. A parent can have up to 60 children, three options of its own, and 2,000 variants across all the children combined. A combined listing cannot be a bundle, and cannot sit inside another combined listing.
If you sell through a marketplace channel that needs the grouped product, or you rely on selling plans, stop here. Plain variants, or a product with many options, will serve you better.
How do you create a Shopify combined listing through the API?
Most merchants use Shopify’s own Combined Listings app. You need the API when you are migrating a catalogue where colours were already separate products, or when a PIM decides the grouping. Your app needs the write_products scope.
First, the parent. productCreate (and productSet) accept combinedListingRole, whose values are PARENT and CHILD:
mutation CreateParent($product: ProductCreateInput!) {
productCreate(product: $product) {
product { id handle combinedListingRole }
userErrors { field message }
}
}
Then attach the children. The combinedListingUpdate reference takes the parent ID, the ordered options and values, and one entry per child saying which parent option value it stands for:
mutation AttachChildren(
$parentProductId: ID!
$optionsAndValues: [OptionAndValueInput!]
$productsAdded: [ChildProductRelationInput!]
) {
combinedListingUpdate(
parentProductId: $parentProductId
optionsAndValues: $optionsAndValues
productsAdded: $productsAdded
) {
product {
combinedListing {
combinedListingChildren(first: 60) {
nodes {
product { handle }
parentVariant { selectedOptions { name value } }
}
}
}
}
userErrors { code field message }
}
}
Here is a Node script (Node 20 or later, saved as .mjs) that runs both. The children must exist before you call it.
const SHOP = process.env.SHOP; // your-store.myshopify.com
const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
const ENDPOINT = `https://${SHOP}/admin/api/2026-10/graphql.json`;
const CREATE_PARENT = `mutation CreateParent($product: ProductCreateInput!) {
productCreate(product: $product) {
product { id handle combinedListingRole }
userErrors { field message }
}
}`;
const ATTACH_CHILDREN = `mutation AttachChildren(
$parentProductId: ID!
$optionsAndValues: [OptionAndValueInput!]
$productsAdded: [ChildProductRelationInput!]
) {
combinedListingUpdate(
parentProductId: $parentProductId
optionsAndValues: $optionsAndValues
productsAdded: $productsAdded
) {
product { id }
userErrors { code field message }
}
}`;
async function admin(query, variables) {
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Access-Token': TOKEN,
},
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`Admin API returned ${res.status}`);
const { data, errors } = await res.json();
if (errors) throw new Error(JSON.stringify(errors));
return data;
}
const children = [
{ id: 'gid://shopify/Product/1001', colour: 'Navy' },
{ id: 'gid://shopify/Product/1002', colour: 'Olive' },
{ id: 'gid://shopify/Product/1003', colour: 'Rust' },
];
const created = await admin(CREATE_PARENT, {
product: { title: 'Field Jacket', status: 'DRAFT', combinedListingRole: 'PARENT' },
});
if (created.productCreate.userErrors.length) {
throw new Error(JSON.stringify(created.productCreate.userErrors));
}
const parentId = created.productCreate.product.id;
const linked = await admin(ATTACH_CHILDREN, {
parentProductId: parentId,
optionsAndValues: [{ name: 'Colour', values: children.map((c) => c.colour) }],
productsAdded: children.map((c) => ({
childProductId: c.id,
selectedParentOptionValues: [{ name: 'Colour', value: c.colour }],
})),
});
if (linked.combinedListingUpdate.userErrors.length) {
throw new Error(JSON.stringify(linked.combinedListingUpdate.userErrors));
}
console.log(`Linked ${children.length} children to ${parentId}`);
The parent goes in as DRAFT on purpose. Shopify’s docs say the merchant has to publish the parent before it appears on the storefront, so you get a chance to check it in the admin first.
Maintaining the listing afterwards
The same mutation does every later change, and its build guide covers each one. productsAdded appends a new child, and its option value goes to the end of the list unless you also send optionsAndValues in the order you want. productsRemovedIds detaches children. productsEdited changes which option value a child represents, though renaming a value does not rename the child product’s own title, which is a separate update. All four arguments can go in one call.
If the colours are swatches backed by a metaobject, give the option a linkedMetafield (namespace, key and the metaobject values) and pass a linkedMetafieldValue alongside each child’s name and value.
To find every parent in a store, query products with query: "combined_listing_role:PARENT" and read combinedListing { combinedListingChildren } from each.
Why does the theme need changing?
Shopify’s overview says it directly: merchants on the Online Store channel must update their theme to work with combined listings. A theme written for ordinary variants shows the parent’s options and then has no idea that choosing “Olive” means showing a different product.
The answer is product_option_value.product_url. On a parent, each option value that a child owns returns that child’s URL. Shopify’s guide to deferring child product loading spells out when it is empty: it returns nil unless the current product is a combined listing parent, and it also returns nil for option values owned by a child. On the jacket’s parent page, Colour values carry URLs and Size values do not. There is no Liquid property that tells you whether a product is a parent or a child, so branch on product_url and nothing else.
Render the picker from product.options_with_values, never by looping over product.variants. That loop loads every child’s variants, which is where the slow TTFB comes from on a 20 to 60 child listing, and it stops at 250 variants without any error.
{%- for option in product.options_with_values -%}
<fieldset>
<legend>{{ option.name }}</legend>
{%- for value in option.values -%}
<label>
<input
type="radio"
name="{{ option.name | escape }}"
value="{{ value | escape }}"
data-option-value-id="{{ value.id }}"
{% if value.product_url %}data-product-url="{{ value.product_url }}"{% endif %}
{% if value.selected %}checked{% endif %}
>
{{ value }}
</label>
{%- endfor -%}
</fieldset>
{%- endfor -%}
When a buyer changes an option, fetch the child’s URL if the value has one, or the current path if it does not, through the Section Rendering API. Send every checked option value ID as option_values, and replace only the product section that fired the event:
document.addEventListener('change', async (event) => {
const input = event.target;
const section = input.closest('[data-product-section]');
if (!section || input.type !== 'radio') return;
const ids = [...section.querySelectorAll('input[type="radio"]:checked')]
.map((el) => el.dataset.optionValueId);
const base = input.dataset.productUrl || window.location.pathname;
const params = ids.length ? `&option_values=${ids.join(',')}` : '';
const res = await fetch(`${base}?section_id=${section.dataset.sectionId}${params}`);
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const next = doc.querySelector('[data-product-section]');
if (next) section.replaceWith(next);
});
Scoping the swap to input.closest(...) matters on pages with a quick-add drawer or a featured product block, where a plain ID lookup can update the wrong one. Shopify’s sample does not touch the address bar either. If you want the URL to follow the colour so a shared link opens on Olive, add history.replaceState yourself and test it.
Unspecified options fall back to the first available value, so a partial selection is safe to send. Test with a parent that has at least 20 children, because three children will hide every performance problem described above.
Does Hydrogen support combined listings?
Yes, through a recipe rather than anything built into the framework. The Hydrogen combined listings recipe now sits under Shopify’s legacy Hydrogen docs. It marks parents with a product tag (combined by default), hides Add to cart on the parent, shows a price range instead of a variant price, and can redirect a request for the parent’s handle to the first available child. It also keeps parents off the all-products page with a NOT tag:combined query filter. Collection pages cannot take that filter, so there the recipe drops parents in code after the query returns. The tag is your convention, so the API grouping and the tag have to be kept in step.
When we would use it
For a Plus store whose colours already live as separate products with their own SEO history, combined listings are the right call. You keep every URL that already ranks and still show one product on the collection page. For a new catalogue, or anything sold through POS, marketplaces or subscriptions, model the colours as options on one product and skip the parent entirely.
Migrating a catalogue onto combined listings, or reworking a theme so its picker holds up at 60 children, is the kind of Shopify development work we take on.