I work on the Shopify store of Renaissance Jewel, a lab-grown diamond jeweler in New York. Most of what they sell is engagement rings, and rings break a limit that most Shopify stores never reach.
Take one solitaire design. It comes in platinum, white gold, yellow gold and rose gold. The centre diamond comes in different colours. And the diamond is cut in many shapes, from asscher to round. A customer can buy any mix of these.
Shopify allowed 100 variants on one product when we did this work. With example numbers, 4 metals, 3 diamond colours and 10 shapes is 120 combinations. One design is already over the limit.
Every combination is a product
So a design is not one product in this store.
Each combination is its own product with a single variant. All products of the same design carry the same tag, a style code that starts with RJ- followed by a number. For example, one black diamond solitaire exists as five products, one each in platinum, silver, white gold, yellow gold and rose gold, and all five carry the same style code.
To the customer it still looks like one ring with options. The product page shows metal, shape, carat and diamond colour as switchers. When they click “rose gold”, they open a different product.
This has a nice side effect for search. Every combination has its own URL, its own title and its own photos.
How one product finds its siblings
Liquid cannot search for products. You cannot ask it for “all products with this tag” from a product page. So each product has to carry the addresses of its siblings.
We store them in product metafields. Each product has one metafield per option value, and the value is the handle of the sibling product:
{% assign rose_gold = product.metafields.custom.v1_rose_gold_link %}
{% assign white_gold = product.metafields.custom.v1_white_gold_link %}
{% if rose_gold != blank %}
<a href="{{ all_products[rose_gold].url }}" class="metal-swatch">
<span class="{% if product.metafields.custom.v1_current_metal_color == 'Rose' %}is-active{% endif %}">
Rose gold
</span>
</a>
{% endif %}
all_products[handle] turns a handle into a product. A second set of metafields, like v1_current_metal_color, says which value this product itself is, so the right swatch is marked as active. If a sibling does not exist, its metafield is empty and the swatch is not shown.
Two things to know if you copy this.
all_products has a limit. Liquid allows 20 different handles per page through all_products. A ring page with shapes, metals, carats and colours can pass that. Count your swatches before you choose this method.
It was our second version. The first version used one list metafield holding all products of a design, and guessed the active swatch by checking if the product’s handle contained the metal name. A “contains” check like that is fragile, because “gold” is inside “rose-gold” and “white-gold” too. Separate link metafields per value are more typing and much more reliable.
Nobody can create that many products by hand
The cost of this structure is the number of products. One new design means dozens of products. Each needs the right title, price, photos, the exact style code, and the links to its siblings. If one link is wrong, a swatch is missing or opens the wrong ring, and nobody notices until a customer cannot find yellow gold.
Creating these in the Shopify admin by hand is slow, and it is where the mistakes come from. So we made a custom app for uploading products, and a Node script that uses it.
The custom app
In Shopify, a custom app is how you get API access to one store. You create it in the store’s admin, choose its permissions, and Shopify gives you an Admin API access token.
Ours needs very little: permission to read and write products. It has no screens. It exists so that the script has a token, and so that everything the script creates is recorded against that app and not against a staff login.
The Node script
The script takes the ring data as input: for each design, its style code, and for each combination the metal, shape, diamond details, price and image URLs.
For each combination it builds one product and sends it to the Shopify Admin API. A simplified version of one call looks like this:
const res = await fetch(
`https://${SHOP}/admin/api/${API_VERSION}/graphql.json`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Access-Token': process.env.SHOPIFY_ADMIN_TOKEN,
},
body: JSON.stringify({
query: `mutation ($input: ProductInput!) {
productCreate(input: $input) {
product { id handle }
userErrors { field message }
}
}`,
variables: {
input: {
title: ring.title, // metal, carat, colour, cut, setting
tags: [ring.styleCode], // RJ-xxxx, shared by the whole family
productType: 'ring',
},
},
}),
}
);
One call is the easy part. These are the things an upload script like this has to get right.
Titles come from the data. The title is built from the metal, carat, colour, cut and setting. Two sibling products can never get the same title, and nobody types titles by hand.
The style code is set in one place. Every product of a design gets its tag from the same field in the input. This removes the easiest mistake to make with manual entry.
Rate limits. The Admin API limits how fast an app can write. When Shopify answers that the app is throttled, the script has to wait and send the same product again. Without this, a big upload stops in the middle.
Running it twice must be safe. An upload of hundreds of products will fail somewhere at least once, from a network error or a bad row. So the script has to check if a product already exists before creating it. Then it can be run again after a failure without creating duplicates.
Read userErrors. The GraphQL API returns HTTP 200 even when the product was not created. The reason is inside userErrors. So the script must log those with the row they belong to. A script that only checks the HTTP status will report success for products that do not exist.
What I would tell someone doing the same
Splitting a design into products solves the variant limit. But it only stays healthy if products are created by code. The switcher depends on every tag and every link being exact, and people are not exact across hundreds of rows.