Development

Replacing a Shopify product options app with native metafields

A case study in removing app lock-in for configurable product options on a Shopify D2C furniture brand - and what I learned about doing it without breaking parity.

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_options mirrors real variant axes the merchant wants the app to display in its own picker (typically Size).
  • virtual_options is the interesting one. Each entry is a non-variant option - fabric, storage, assembly, etc. It includes a price delta, plus optional conditional_option / conditional_value for 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 of options metaobject 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:

  • metafieldDefinitions and metaobjects(type:"options") to inventory the new architecture.
  • productVariantsBulkUpdate to set the shared option-product variant prices.
  • metafieldsSet to populate custom.product_options references 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.json template still had the old native variant_picker block 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 4FT6 while the map keys used Double (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_option support 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.

Filip Rastovic
Filip Rastovic
Shopify Developer & CRO Specialist · Stargazer Studio

Running a Shopify store with complex product options?

If you're paying for a product options app and want to own the data layer yourself, the architecture in this post applies to most configurable catalogues. Book a call and I'll scope it.

Book a free call More articles
Filip Rastovic
Book a Call Get started today