The setup
The client sells beds, sofas, and mattresses. Almost every product has configurable options beyond size: fabric, fabric colour, headboard height, storage drawers, ottoman base, TV lift, mattress add-on, professional assembly. Pricing changes per option. None of those are real Shopify variants - the variant ceiling (100 per product, 3 option dimensions) would have been exceeded several times over per SKU.
For years they ran this on VO Product Options, a Shopify app that stores configurations in a bcpo metafield namespace as a JSON blob. It worked. It also meant every PDP read its options from an opaque third-party data structure, every storefront render hit the app, and the entire merchandising layer was locked to one vendor.
I replaced it with native Shopify metaobjects and metafields. Same options, same prices, no app.
What VO was actually doing
The first job was understanding the data, not the UI. VO writes a single product metafield bcpo.bcpo_data containing two arrays:
shopify_optionsmirrors real variant axes the merchant wants the app to display in its own picker (typically Size).virtual_optionsis the interesting one. Each entry is a non-variant option - fabric, storage, assembly, etc. It includes apricedelta, plus optionalconditional_option/conditional_valuefor dependent options (for example "only show this storage type when Base = Divan").
Once you read that JSON you have the full parity surface. Anything not in there is not a configurable option, regardless of what the storefront looks like.
The replacement architecture
Three product metafields, one metaobject definition, and a handful of shared "option products" that hold the actual values and prices.
Metaobject definition: options
| Field | Type | Purpose |
|---|---|---|
icon |
file_reference | Accordion icon shown next to the option title |
title |
single_line_text | e.g. "Choose your fabric and colour" |
sub_heading |
single_line_text | Microcopy under the title |
product_variant |
product_reference | Points at a shared "option product" whose variants are the selectable values |
Product metafields
custom.product_options(list ofoptionsmetaobject references) - the main spine. Per product, list the metaobjects to render in order.custom.mattresses_options(list of collection references) - drives the mattress add-on grid, scoped by collection so mattress size compatibility is implicit.custom.assembly_options(product reference) - points at a shared "Assembly Option" product whose variants hold the £60 / £80 / £120 price tiers.
Shared option products
Each option family (Fabric, Storage, Headboard Height, Mattress, Assembly, etc.) is modelled as a real Shopify product whose variants are the selectable values. The variant title is the user-facing label, the variant price is the price delta, the variant featured image is the swatch. Eight products cover the entire catalogue.
Encoding option values as variants of a shared product is the decision most worth taking from this post. Price changes are made in one place per option family, not per product. The picker UI gets variant images and prices for free. Inventory tracking is available if you ever need it. The app's "values are arbitrary JSON" problem disappears - you get all the merchandising tools Shopify gives you for real variants.
The renderer
A single Liquid snippet product-options.liquid produces the entire purchase block: price box, finance estimate, "selling fast" badge, and the option pickers.
For real variants (Size, Firmness, etc.) it loops product.options_with_values and renders each option as a card grid, with a static image map keyed on the value name. For configurable options it loops product.metafields.custom.product_options.value, resolves each metaobject's product_variant reference, and renders that product's variants as the selectable cards.
The whole purchase form - including the Add to Basket button - lives in one snippet. The native Shopify variant_picker, price, quantity_selector, and buy_buttons blocks are disabled in the template JSON. One snippet owns the form; one place to change behaviour.
Filling the new system without breaking the old
The store is live throughout. The new theme is on a preview URL, the new metafields and metaobjects are populated, and VO keeps powering the live theme. Everything written to the new architecture is inert until the new theme is published.
I used the Shopify Admin GraphQL API (2025-01) with a client-credentials OAuth grant to a dev app. Reads first to inventory parity, writes only after gaps were confirmed:
metafieldDefinitionsandmetaobjects(type:"options")to inventory the new architecture.productVariantsBulkUpdateto set the shared option-product variant prices.metafieldsSetto populatecustom.product_optionsreferences on each product, and to repoint a duplicate "storage" metaobject onto a single canonical one.
Two parity bugs surfaced during this pass - both were data problems, not UI problems. The "Assembly Option" product variant prices in the new system were stale; VO had the correct figures, so I wrote VO's values back into the new product. The "Add Storage Option" metaobject existed twice with subtly different content; I repointed every consumer onto the canonical one and the duplicate became unreferenced.
QA via cart total parity
The most efficient acceptance test was also the simplest: build the same bed configuration in both themes on a phone, add the same mattress and assembly add-on, compare the cart total. Both came to the same figure. If the new architecture and prices are correct, the basket maths has nowhere to lie.
Two pure-theme bugs also surfaced at this stage - neither caused by the migration:
- The default
product.jsontemplate still had the old nativevariant_pickerblock alongside the new card picker, so mattresses on that template showed two size selectors. Disabling that block in the template JSON fixed it for every product on the default template at once. - The static image map for size cards used exact string matching against the variant value name. Some products had variant values written as
Double 4FT6while the map keys usedDouble (4FT6). Singles and Small Doubles rendered images; larger sizes were blank cards. Normalising both sides - stripping parentheses, collapsing spaces, lower-casing - made the lookup tolerant of punctuation inconsistencies in variant names.
Both were pre-existing inconsistencies that the new card UI made visible. Worth catching before launch either way.
What didn't need migrating
A pre-launch sweep for hardcoded ad pixels (Meta, Google Ads, GA4, TikTok, GTM, dataLayer) found nothing in the theme files. All paid-traffic tracking lives at the store level via Customer Events web pixels and app embeds. The new theme renders {{ content_for_header }} the same way, so attribution carries across publish unchanged.
App embeds, however, are theme-specific - this is the migration step that bites people. Klaviyo onsite, review widgets, Shopify Inbox, and any countdown timers all need to be re-enabled on the new theme via the theme editor when you publish. Check this before you go live.
When this approach works, and when it doesn't
Native metafields plus the "values are variants of a shared product" pattern works well when:
- You have a handful of recurring option families across many products (fabric, storage, finishes, assembly tiers)
- Price deltas are predictable per option value rather than per product
- You want full control over the picker UX rather than an app's iframe or web component
- You care about app cost, performance, and not being locked to a vendor's data model
It's a worse fit when:
- Options are unique per product with no reusable price structure
- You need conditional logic complex enough to justify a dedicated rules engine - VO's
conditional_optionsupport is non-trivial; if you use it heavily, expect to replicate it in Liquid or JS - You don't want to own and maintain the picker code yourself
For the right catalogue - recurring option families, predictable price deltas, and a team that wants to own the storefront - the trade is straightforward: one-off migration cost, option model in your own data, no monthly app fee, full control over the rendering. That's what this architecture delivers.