Shopify Checkout Extensibility Migration: What Actually Broke
Shopify killed checkout.liquid and forced everyone onto Checkout Extensibility. Here's what broke during our migrations, what the docs don't warn you about, and the patterns that survived production traffic.

Shopify's deprecation of checkout.liquid wasn't a surprise — they announced it years out — but the migrations we ran in the last twelve months still surfaced problems the documentation glossed over. If you're a Plus merchant who put this off, or an engineer inheriting a checkout from an agency that did the bare minimum, this is what you actually need to know.
This isn't a feature tour. It's a field report.
Why the old model had to die
checkout.liquid let you do anything. That was the problem. Merchants injected jQuery plugins, third-party tag managers, custom address validators, and entire React widgets into a page that Shopify needed to own for PCI scope, Shop Pay performance, and accelerated checkout parity.
The result, by 2023, was a checkout surface Shopify couldn't safely evolve. Every Plus merchant had a slightly different snowflake, and any platform-level change risked breaking thousands of stores.
Checkout Extensibility is Shopify's answer: a sandboxed extension model where you ship UI through Checkout UI Extensions (React or vanilla JS running in a Web Worker) and business logic through Shopify Functions (compiled to WebAssembly, executed server-side). You give up direct DOM access. You get a checkout that stays fast, stays PCI-compliant, and survives Shopify's quarterly improvements without you babysitting it.
The migration audit nobody wants to do
Before you write a single extension, inventory what's in your current checkout. We've never seen a merchant whose list matched what they told us in the kickoff call.
Categories that actually matter
Group every customization into one of these buckets:
- Pure visual — colors, fonts, logo placement. These move to Checkout Branding API, not extensions.
- Field additions — gift messages, delivery instructions, PO numbers, tax IDs. These become UI extensions with metafield or attribute writes.
- Conditional logic on shipping/payment/discounts — hiding cash-on-delivery for orders over a threshold, applying volume discounts. These are Functions, not UI work.
- Third-party scripts — analytics, fraud, address autocomplete. These need Web Pixels (for analytics) or specific partner extensions. Generic
<script>tags are gone. - Post-purchase upsells — separate extension target, separate UX considerations.
The audit usually reveals two things: half the customizations are unused, and one or two are load-bearing in ways nobody documented.
War story: a fashion merchant had a checkout.liquid hack that suppressed a specific shipping rate when the cart contained a pre-order SKU. No ticket, no commit message explaining why. We almost migrated without it. The first day of pre-orders would have shipped 800 orders with impossible delivery dates.
UI extensions: the gotchas
The React-style API looks familiar, but the runtime is not a browser. Your extension runs in a Web Worker with a message-passing bridge to the checkout. That has consequences.
No direct DOM, no global fetch tricks
You can't query elements, can't read cookies, can't attach event listeners outside the extension's own components. Network calls go through the fetch provided by the extension API, and they're restricted to domains you declare in shopify.extension.toml.
[extensions.network_access]
allowed_urls = [
"https://api.your-domain.com/checkout/*"
]
Forget to declare it, and your call silently fails in production while working locally. That bit us twice before we added a CI check that diffs the TOML against the actual fetch calls in source.
Targets, not pages
Extensions attach to targets — named slots like purchase.checkout.block.render or purchase.checkout.shipping-option-list.render-after. You don't control layout outside your target. If you need a field to appear in a specific spot, check the target list first; if the slot doesn't exist, you can't put it there.
State lives in storage primitives
Use useApplyAttributeChange for cart attributes, useApplyMetafieldsChange for metafields, and useStorage for extension-local state. Anything you write to attributes shows up on the order, which is what most integrations want anyway.
import {
reactExtension,
TextField,
useApplyAttributeChange,
useAttributeValues,
} from '@shopify/ui-extensions-react/checkout';
export default reactExtension(
'purchase.checkout.block.render',
() => <GiftMessage />
);
function GiftMessage() {
const [current] = useAttributeValues(['gift_message']);
const apply = useApplyAttributeChange();
return (
<TextField
label="Gift message"
value={current ?? ''}
onChange={(value) =>
apply({ type: 'updateAttribute', key: 'gift_message', value })
}
/>
);
}
That's the entire pattern for 80% of merchant requests. The temptation is to over-engineer it.
Functions: where the real logic moved
Shopify Scripts (the old Ruby-based system) is gone for new development. Shopify Functions replace it: small WebAssembly modules written in Rust, JavaScript, or any language that compiles to Wasm, executed inside Shopify's infrastructure on every cart and checkout event.
The execution budget is tight — single-digit milliseconds and a hard memory cap. You write a pure function: input is a GraphQL query result describing the cart, output is a list of operations (discount this line, hide this delivery option, reorder payment methods).
What you can and can't do
- Can: discounts, payment method customizations, delivery customizations, cart validation, cart transforms (bundle expansion).
- Can't: call external APIs, read non-cart data, persist state, do anything async.
That last constraint trips up teams who want "a Function that checks our ERP for stock." You can't. The pattern is: sync data into a metafield on a schedule, then read the metafield from the Function input.
Testing Functions like real code
The Shopify CLI gives you shopify app function run with a JSON input. Wire it into your test runner.
shopify app function run \
--input ./test/fixtures/cart-with-preorder.json \
--export run
We keep a fixtures directory per Function with one file per scenario the merchant cares about, and the CI fails if the output diff doesn't match the expected operations. This is the single biggest quality lift we've added to checkout projects.
Performance: what actually changes
The headline pitch is "faster checkout." In our experience the gap is real but smaller than Shopify's marketing suggests for merchants who already had a clean checkout.liquid. The bigger win is consistency — extensibility checkouts don't degrade when you add the fourth or fifth customization, because each extension is sandboxed and lazy-loaded.
What we measure on every migration:
- Time to interactive on Information step (mobile, throttled to mid-tier Android)
- Shop Pay one-tap visibility — extensibility checkouts surface it earlier in the flow
- Conversion rate by step, segmented by device, four weeks pre- and post-migration
Don't trust a single A/B window. Checkout traffic is noisier than category pages, and seasonal effects swamp small lifts. Give it a full cycle.
The headless question
If you're on Hydrogen or a custom headless storefront, you have a choice: Checkout Extensibility on Shopify-hosted checkout (the path above) or Customer Account API + a fully custom checkout using the Storefront API's cart and checkout mutations.
We almost always recommend the first. Shop Pay, Apple Pay, Google Pay, local payment methods, fraud, tax — Shopify does these well, and rebuilding them costs more than the design freedom is worth. Use Extensibility for the differentiation, keep the rails.
The exception is B2B with quote-to-order flows, approval chains, and net-terms financing. That genuinely doesn't fit the hosted checkout, and a custom flow built against the Storefront API earns its keep. If that's your situation, our team has shipped a few — see our e-commerce work for context.
Where we'd start
If you haven't migrated yet, do this in order:
- Run the audit. Categorize every existing customization into branding, UI extension, Function, pixel, or "delete it."
- Stand up a dev store and a checkout profile. Migrate branding first — it's low-risk and shows the merchant visible progress.
- Build the Functions next, with fixture-based tests from day one. These carry the most business logic and are the easiest to regress silently.
- Layer UI extensions last, target by target.
- Run both checkouts in parallel for at least two weeks using checkout profiles, route a small traffic slice, watch conversion and support tickets.
The merchants who treated this as a Q3 fire drill regretted it. The ones who treated it as a quarter-long engineering project came out with a faster, cleaner checkout and a customization layer their next developer can actually read.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a project