Engineering stories · 01
Custom subscription buy box on native selling plans
- Selling plans
- Recharge
Building a subscribe/one-time buy box on Shopify's native selling plans, with Recharge as the downstream processor, so the price a shopper sees is always the price they are billed.
Shopify OS 2.0 · Native selling plans · Recharge as processor · Vanilla JS · Subscription commerce
A companion to the Lumway flagship case study, opening up one system in full: the subscription buy box on the product page.
Why subscription was the system to get right
Lumway sells supplement gummies to US consumers on Shopify. The catalog is small and the price point is considered, so the economics lean hard on repeat purchase. A one-time gummy order is worth a fraction of a subscriber over a year. Subscription is the retention engine and most of the lifetime value, which makes the subscribe option the single most commercially important control on the page.
The stake I kept coming back to is simple to state and easy to get wrong: the offer a shopper sees must equal the offer they are billed on. If the page shows one price and Recharge charges another, or a shopper picks a three-jar pack and gets billed on a monthly cadence, the store loses the order, the recurring revenue behind it, and gains a support ticket. On mobile, where most of Lumway's paid traffic lands, that trust is decided in a few seconds inside a small vertical strip (pack, subscribe-or-not, price, savings, add to cart), and any moment the price looks wrong is a moment the buyer hesitates.
So the brief was not "add subscriptions." It was: sell 1, 2, and 3-jar packs as both one-time and subscription, with pricing a shopper can trust, on a page that has to be pixel-perfect.
Why I did not use the Recharge widget
The obvious path is the Recharge subscription widget. It is built for exactly this, it ships fast, and for many stores it is the right call. For Lumway it was not, for three reasons that trace back to the same stake.
It renders inside a shadow DOM, a boundary the theme's typography, spacing, and interaction rules cannot reach through, and pixel-perfect was a hard requirement here, not a preference. It owns its own price and plan state, but the buy box already had two other controls that move price (a 1/2/3-jar pack selector and a per-jar versus pack-total display), and I needed the visible price, the selected pack, and the billed plan to stay in lockstep as the shopper toggled; handing plan state to a component behind a shadow boundary meant reconciling two sources of truth on every interaction. And it pulls in Recharge's own JavaScript, which I did not want to add to the store's most important mobile page for a component I was going to restyle anyway.
The insight that unlocked the alternative: Recharge does not need its widget to do its job. Recharge recurs an order when the line carries a native Shopify selling_plan. That is the entire contract. Write the right selling_plan onto the cart line, and checkout stays on Shopify, the order is created natively, and Recharge picks it up downstream as the processor. The widget is one way to produce that line property. It is not the only way.
Constraints I was designing against
- Pixel-perfect to the design, subscribe card and savings badge included.
- One buy box, three moving parts. Pack selector, purchase type, and add-to-cart share no DOM but must agree on price at every instant; the contract that keeps them consistent is deep dive 02.
- Merchant-editable, nothing hardcoded. Content, prices, and plans live in configuration.
- Survive Recharge reconfiguration. A plan renamed, deleted, or recreated should never require a code deploy.
- No new vendor JavaScript in the buy box.
The architecture I chose
A fully custom subscribe/one-time UI, built on native Shopify selling plans, with Recharge as the processor and checkout on Shopify. No Recharge JS or SDK anywhere in the buy box. The only Recharge surface in the repo is some CSS reskinning its customer-portal iframe, cosmetic and unrelated to this box.
The purchase-type control renders two radio cards, one-time and subscribe. The subscribe card only appears when the product actually has plans:
{% if product.selling_plan_groups.size > 0 %}
{# render the subscribe card #}
{% endif %}
Each product carries three plans: 1-month, 2-month, and 3-month, at 15% off. The 15% is not a discount code and not a number I wrote in JavaScript. It lives inside the selling plan as a price adjustment, and the UI reads it back out.
The box talks to the rest of the buy box through window globals and DOM events rather than shared DOM: on resolving subscribe state it publishes window.__lumResolvedSub (plan and price for the selected pack) and precomputes window.__lumSubByVariant (the subscribe price of every pack), so the pack cards draw their own savings badges and the sticky bar shows the same price without recomputing it. That decoupling is covered in deep dive 02.
The hard part: resolving a plan without hardcoding IDs
The center of this system is one function, resolvePlanForVariant(). Its job sounds trivial and is not: given the pack the shopper selected, return the correct subscription plan and the correct subscribe price.
The naive version hardcodes it. Pack one uses plan 680..., pack two uses plan 681..., and so on. That works until the first time someone in Recharge renames a plan, deletes and recreates it (which mints a new ID), or reorders the group. Then the buy box points at a dead ID, and nobody finds out until a subscriber is billed wrong. On a live store edited by non-engineers, hardcoded IDs are a time bomb.
So the mapping is derived from plan text, not IDs, in three steps.
Pack position becomes desired months. A pack of N jars should subscribe to the N-month plan. Position is the variant's index, variantIndex(v) + 1: the first pack wants one month, the second wants two, the third wants three.
Plan text becomes an interval. For any plan I build a haystack from everything readable on it (the option values, the option names, and the plan name), then pull the first number that looks like a month count:
var m = hay.match(/(\d+)\s*month/i) || hay.match(/(\d+)/);
return m ? parseInt(m[1], 10) : null;
A plan named "3 Month" resolves to months = 3, and so would "Every 3 months" or "3-month delivery." The plan's ID never enters the decision.
Months become a plan. planForMonths(n) scans the group and returns the plan whose text-derived interval equals n.
That text-not-IDs choice is the load-bearing one. A merchant can rename, delete, or recreate a plan in Recharge, and as long as it still says how many months it recurs, the box keeps mapping packs correctly with no code change. The mapping survives the exact operation that breaks a hardcoded integration.
Graceful step-down when a plan is missing
Products do not always carry all three plans; the metabolic product ships a trimmed set. If a three-jar pack wants a three-month plan and there is none, returning nothing would leave the shopper with a broken subscribe card. So the resolver steps down to the nearest smaller interval:
for (var mm = pos; mm >= 1 && !plan; mm--) plan = planForMonths(mm);
if (!plan) { var ks = Object.keys(plansById); if (ks.length) plan = plansById[ks[0]]; }
It tries the exact interval, then walks down one month at a time, and only if nothing matches at all falls back to the first plan in the group. A missing plan degrades to a sensible neighbor instead of a dead control.
Two modes and an escape hatch
The resolver runs in one of two modes, read off a data attribute. mapped (the default) is the position-to-interval logic above. auto reads the variant's own selling_plan_allocations, what Recharge attaches when plans are assigned at the variant level, and uses those directly. Two optional schema settings, monthly_plan_id and bimonthly_plan_id, pin positions one and two explicitly: an escape hatch for a product whose plan names are non-numeric, off by default and used only when the text-derived path cannot cope.
Reading the discount, never writing it
The subscribe price comes from the plan's own price adjustment, whatever kind it is:
if (a.value_type === 'percentage') return Math.round(price * (1 - a.value / 100));
if (a.value_type === 'fixed_amount') return Math.max(0, price - a.value);
if (a.value_type === 'price') return a.value;
The 15% is read from value_type: 'percentage' on the live plan. Change the offer to 20% or a fixed price and the box follows without a deploy. Nothing about the discount is hardcoded, which keeps the merchant in control of their own pricing.
Preventing the double discount
Here is a bug that never shipped because the architecture headed it off.
The buy box has a display layer that formats prices for the cards, the button, and the sticky bar, and it knows how to apply a plan's price adjustment. But by the time the subscribe price reaches that layer, resolvePlanForVariant() has already applied the 15%. If the display layer applied the plan's percentage again, the shopper would see 15% off an already-discounted number, and the price would drift down every render.
The fix is a synthetic price context. When subscribe is selected, I hand the display layer a fabricated plan object that carries the resolved price as an absolute value, not a percentage:
var synthetic = { id: r.planId, priceAdjustments: [{ value_type: 'price', value: r.subPrice }] };
window.Elixir_SetSubscriptionContext(r.planId, v, synthetic);
Because the synthetic adjustment is value_type: 'price', the display layer returns the value verbatim (the return a.value branch above) and cannot re-apply a percentage on top. The price is computed once, in the resolver, and every downstream consumer treats it as final. Two code paths, one number.
How selling_plan reaches checkout
None of the resolution matters unless the plan lands on the order. This is where the native-selling-plans bet pays off: the contract with checkout is a single hidden input.
The add-to-cart component renders the real Shopify product form and marks it with a class:
{%- form 'product', product, class: 'product-buy-form-pdp ...' -%}
<input type="hidden" name="selling_plan" value="">
When the shopper selects subscribe, a small helper writes the resolved plan ID into that input, scoped to the buy box's own form and no other:
var form = document.querySelector('form[action*="cart/add"].product-buy-form-pdp');
var input = form.querySelector('input[name="selling_plan"]'); // created if missing
input.value = String(sellingPlanId);
Add-to-cart is an AJAX submit: it builds FormData from that form and POSTs to /cart/add.js. Because the selling_plan field is inside the form, it rides along in the payload and the line is created against the Recharge-managed plan, which Recharge then recurs. Switching back to one-time clears the input, so a one-time line carries no plan and is never recurred. The form scoping (why it is .product-buy-form-pdp and not a document-wide query) exists because a second, hidden product form on landing pages was grabbing the wrong input; that story lives in deep dive 02.
The result is a subscribe box with zero Recharge code in it that still drives Recharge correctly, because it speaks the platform's own language.
One-time versus subscribe, and two price views
The box holds two price states and two views, and all four have to stay consistent. The states are one-time and subscribe; toggling recomputes and recolors the savings badge for each, because the math differs (a "buy 2 get 1" pack saves differently from a subscription). The views are per-jar and pack total: the cards show a per-jar price, because a shopper comparing packs wants to watch the unit come down as the pack grows, while the button and sticky bar show the pack total, because that is what gets charged. Keeping the per-jar cards and the pack-total button agreeing across both states is exactly why the subscribe price for every pack is precomputed up front rather than derived ad hoc in three places.
The v1 to v2 rework, kept reversible
The current resolver is version two. Version one was a different commercial model, and I did not delete it. I froze it.
v1 used a "gift jars" model: the differentiator between packs was free jars and a free-shipping perk, and the plan mapping was a binary threshold (each pack monthly or bi-monthly, gated by a configured cutoff). It did not precompute per-pack subscribe prices, and its price events carried no purchase-type, so the cards could not recolor their savings for subscribe versus one-time. v2 (live) is the true N-month mapping described above, with the step-down, the precomputed __lumSubByVariant, and price events that carry a type so the cards recolor correctly. Gift jars came out, per-pack pricing was recomputed, a three-month plan was added and three-jar packs mapped onto it, and the same logic was propagated to the other product templates.
The part worth highlighting is not the code change; it is how I shipped it. Before the rework I snapshotted v1 into a frozen file (purchase-type-lum-v1.liquid) and a dedicated git branch: an explicit rollback path, so if the new model underperformed, returning to the old one was a revert, not a reconstruction. Treating a pricing-UX change as a reversible experiment rather than a one-way door is the difference between changing a live revenue surface with confidence and doing it with your fingers crossed.
Tradeoffs I accepted
No design is free. Three costs are worth naming plainly.
Text-regex parsing is fragile to non-numeric plan names. Name a plan "Monthly" with no digit and the interval regex finds nothing, so the resolver falls back. This is real fragility, mitigated by the monthly_plan_id / bimonthly_plan_id overrides, but the mitigation is manual. The bet is that numeric names are the norm and the override covers the exceptions.
Position-based mapping assumes variant order equals ascending pack size. First variant is one jar, second is two, third is three. Reorder the variants and the mapping breaks. It holds because packs are always set up in ascending order, but it is a convention the code trusts rather than a fact it verifies.
Global-variable coupling is timing-sensitive. The three components coordinate through window globals and events, so initialization order matters and late third-party scripts or Theme Editor re-renders can interfere. The box re-initializes on a small stagger and on Shopify's section-load event to stay correct through those. It works, but it is coordination the platform does not enforce.
None of these is hidden. They are the price I paid for a box that is pixel-perfect, carries no vendor JS, and survives Recharge edits, and I would make the same trade again.
How I validated it
The box is verified end to end in clean, headless Chrome with a Puppeteer harness, not by clicking around in my own browser (my profile runs an extension that interferes with the cart drawer, so manual testing there lies). The harness walks the full matrix, every pack times one-time and subscribe, checking that the form carries the correct variant, that the resolved selling_plan is present and correct, and that prices agree to the cent across cards, button, and sticky bar. It runs on the product page and on the landing pages that host the same box, giving me verified technical confidence: add-to-cart writes the right plan, the double discount never occurs, and the four price surfaces never disagree.
Migrating the store's live subscribers onto Recharge, the other half of the Recharge story, has its own account in deep dive 04.
Outcome and business impact
The verified technical outcome: a subscription buy box on native selling plans, with pack-to-interval mapping resolved from plan text and no hardcoded plan IDs, no Recharge JavaScript in the box, no double discount, and a correct selling_plan reaching checkout so Recharge recurs the order, all confirmed in a clean-room harness.
The expected business value I state as protection and enablement rather than a published number: the store's commercial figures belong to the brand and sit under NDA. The box protects what subscription revenue depends on: the price a shopper sees is the price Recharge bills, and the pack they pick is the cadence they get. That alignment lowers the risk of the recurring-order errors and churn that quietly bleed a subscription business. Reading the discount from the plan lets operations change the offer without an engineer, and deriving the mapping from plan text keeps the box working through the routine Recharge edits that would otherwise become production incidents.
What I would take to the next build
Read configuration, do not encode it. What makes this box durable is that it derives the pack-to-plan mapping from data the merchant controls (plan text and price adjustments) instead of constants an engineer controls. It survives the edits that break hardcoded integrations, and it hands pricing control back to the people who own the offer.
Compute a price once and make it final. The synthetic price context exists so "already discounted" can never be discounted again. When more than one code path can touch a number, decide which one owns it and give the others a value they cannot mutate.
Speak the platform's contract, not a vendor's SDK. The architecture rests on one fact: Recharge recurs anything carrying a native selling_plan. Building to that contract instead of the widget bought pixel-perfect control, zero added vendor JavaScript, and a native checkout, for the cost of writing one hidden input correctly. And freezing v1 first made changing a live revenue surface a reversible experiment, which on anything touching money is the responsible default.
Technical references
The files that make up this system:
snippets/purchase-type-lum.liquid: the subscribe/one-time cards and the resolver (resolvePlanForVariant,intervalMonthsFromPlan,planForMonths,applyAdjustment, the synthetic context).snippets/purchase-type-lum-v1.liquid: the frozen v1 "gift jars" model, preserved with its git branch as a rollback path.snippets/bundle-selector-lum.liquid: the 1/2/3-jar pack selector that sets the variant and publisheswindow.__lumBundlePack.snippets/subscription-plans-data.liquid: plan data feeding the box.snippets/add-to-cart-lum.liquid: the real product form (.product-buy-form-pdp), the AJAX add, and the sticky price bar.assets/product-form-controller.js:Elixir_SetProductFormSellingPlan, which writes theselling_planhidden input into the scoped form.assets/theme-loaders.js:Elixir_SetSubscriptionContext, the display-layer entry point that consumes the synthetic price context.
Need a Shopify engineer who can work across the full commerce path?
I work where storefront UX, product logic, platform constraints, measurement, and production delivery meet.
Start a conversation