Checkout Blocks’ UI extensions render on a third of all customized checkouts and were built on React and Shopify's legacy Remote UI bridge, running on the soon-to-be-sunset 2025-07 API version. Shopify is moving its entire UI extension surface to remote-dom with Polaris web components, so staying on the old path was no longer an option.
We upgraded all five extensions to 2026-01 running on remote-dom, with Preact and Polaris web components, and rewrote the codebase to TypeScript. This is how we did it, and what we learned along the way.
Why we took this on
Checkout is an extremely performance-sensitive surface. Every extension adds buyer-facing latency: code that downloads, parses, and executes in the buyer's browser at the moment they're deciding whether or not to buy. Polaris web components and remote-dom cut that overhead directly: smaller bundles, faster page loads, and ultimately a faster checkout experience for the buyer.
Starting October 1, 2026, app deployments will be blocked when they include extension versions earlier than 2026-01. Now is a great time to upgrade from legacy APIs and capture the associated performance gains.
Results
We set out to make our extensions leaner and faster, and to move a big chunk of checkout traffic off the legacy React components and APIs.
Once we did this, every extension's bundle size dropped significantly. Transferred bundle sizes dropped between 40% to 85% across the family. Most importantly, every extension loaded faster.
Bundle size
Smaller bundles = less code to download = less time to evaluate = faster loading times. With this upgrade, transferred bundle sizes dropped meaningfully across every extension:
| Extension | Transferred size reduction |
|---|---|
| payment-icons | -84.4% |
| static-content | -54.1% |
| custom-field | -44.6% |
| dynamic-content | -45.2% |
| line-item-actions | -40.5% |
Load performance
We measure load performance by tracking Extension Load Time (ELT). This metric isolates how long a single extension takes to load on its own. Here are the results aggregated across all upgraded extensions and weighted by checkout volume:
| Metric | Change |
|---|---|
| ELT P50 | -8% |
| ELT P90 | -7% |
And per extension:
| Extension | ELT P50 | ELT P90 |
|---|---|---|
| dynamic-content | -8.2% | -7.9% |
| line-item-actions | -8.4% | -6.5% |
| custom-field | -8.1% | -5.9% |
| static-content | -6.5% | -3.8% |
| payment-icons | -11.7% | -11.9% |
A quick word on architecture
So, what actually changed? Checkout Blocks’ UI extensions all consume from a single shared library we call core. This is a folder that sits right alongside our extension code in /extensions.
This core folder holds the common machinery, most importantly the context providers (block config, product data, a GraphQL query client, analytics, telemetry) that wrap every extension, as well as shared hooks, helpers, and our rule engine. An extension is mostly just its own UI; core supplies everything underneath.
The upgrade touched the rendering model, language, API version, and several dependencies. Here is the before and after at a glance.

| Aspect | Legacy | New |
|---|---|---|
| Rendering | React + Polaris React components | Preact + Polaris web components (s-* tags) |
| Language | JavaScript (JSX) | TypeScript (TSX) |
| API version | 2025-07 and earlier | 2026-01 and later |
| Shared library for providers, telemetry, etc. | extensions/core | extensions/core-next |
| Markdown | markdown-to-jsx (React) | markdown-to-jsx, aliased onto Preact via @preact/compat |
| Liquid | liquidjs | custom in-house "droplet" parser |
| Dates | dayjs | custom in-house date utility |
The headline change is React to Preact. Our legacy extensions were rendered through @shopify/ui-extensions-react, which shipped with react-reconciler to translate a React tree into remote-ui's serialized protocol.
Remote-dom works differently: it mirrors real DOM nodes from the sandbox to the host, so any DOM-rendering framework works (or none at all), and Polaris web components are just framework-agnostic custom elements; no bespoke reconciler needed. We picked Preact for React's hooks model at a fraction of the size, and the s-* components replaced our Polaris React tree.
We also used this as an opportunity to move the codebase over to TypeScript. The additional lift here was negligible and now we get the lasting dividends of working on a typesafe codebase.
How we approached it: incremental, smallest first
That “incremental, smallest first” foundation shaped the upgrade:
- Before: Every extension was on the legacy core (React/JSX, Remote UI).
- During: We stood up a new core-next (Preact/TypeScript, remote-dom) alongside the old core. As each extension was upgraded, it repointed to core-next; everything else kept running on core untouched.
- After: Once all were moved, core-next folded back into a single shared core and we deleted the legacy code.
So the strategy was to upgrade one extension at a time, smallest and lowest-risk first, rather than one heroic branch flipping everything at once. The first to ship, our Static Content extension, set the pattern and stood up core-next. We grew core-next incrementally, each extension pulling in only the shared files it needed, leaving the fold-back and dead-code deletion as cleanup. This kept every change reviewable, and battle-tested the patterns on simple extensions before the complex ones.
The conversion was heavily AI-assisted. An in-house agent skill, pointed at our docs with custom prompts, handled the mechanical parts: React-to-Preact, swapping Polaris React components for their s-* equivalents, and moving hooks to the new APIs. The agent did the repetitive lifting; engineers spent their time on the judgment calls, which is exactly where the hard problems turned out to be.
We’ve since packaged these learnings into our Shopify AI Toolkit to help assist our ecosystem in the upgrade.
The hard part: getting under 64KB
To ensure extensions render as fast as possible, the 2026-01 remote-dom CLI enforces a hard 64KB gzip limit on each extension bundle. Our extensions were more than double that: 300-356KB raw, around 100-112KB gzipped. We could not ship until each one fit, so we dug in to see what we could remove or rewrite more simply. This single constraint shaped most of the engineering work.
Here’s where the weight went, and how we clawed it back.
Dropping react-reconciler (~89KB)
This was the single biggest win by far and one we got for free by switching to Preact. Shopify recommends Preact for UI extensions, and this one single change got us most of the way to budget.
Replacing liquidjs (~73KB)
Instead we used our own slimmed down version. Checkout Blocks lets merchants write Liquid in their block configurations, so we needed a Liquid engine in the bundle. liquidjs is excellent but far too large for a 64KB budget. So we wrote our own minimal Liquid parser, internally nicknamed "droplet," and put it in core-next. We built it using AI assistance via /autoresearch, but in a disciplined way:
- We fed the agent the official Liquid spec as the source of truth.
- We pointed it at a parity test suite built from thousands of real-world merchant Liquid configurations pulled from our data warehouse, each paired with the exact output
liquidjsproduced for it. The fixtures file alone is over 42,000 lines. - We iterated until droplet matched
liquidjson that corpus, documenting the handful of known, intentional differences explicitly.
The result is a parser that is a tiny fraction of the size of liquidjs (13 KB gzipped vs liquidjs ≈ 22 KB gzipped, roughly 9 KB / ~40% smaller), validated against how merchants actually use Liquid in production rather than against synthetic cases.
Replacing dayjs (~12KB)
The custom-field extension used dayjs for date handling. We replaced it with a small in-house date utility scoped to exactly what we needed.
Keeping markdown-to-jsx (~15KB)
We did this by aliasing it onto Preact. Several extensions render merchant-authored markdown via markdown-to-jsx, which is a React library. The tempting move was to rip it out and swap in a 2KB markdown parser plus a custom HTML-to-Preact step. We prototyped that and decided against it: it was high risk because it changed how markdown actually rendered for merchants.
Instead, we kept markdown-to-jsx and ran it on Preact by aliasing React to Preact in our pnpm workspace catalog:
... and in each extension's package.json:
With the alias in place, markdown-to-jsx runs unmodified on Preact. No markdown behavior changed, and we didn’t take on a new parser to maintain.
A quick note on this: the official Preact docs recommend aliasing to the @preact/compat library. We were able to reuse our already imported preact because markdown-to-jsx's reliance on React was minimal, used no hooks, and only imported createElement, Fragment, and cloneElement.
Bugs we fixed along the way
An app upgrade of this size is a stress test of the platform itself. We were often the first app to push a real, high-traffic extension through a brand-new component or API path, which meant we found some sharp edges. Several of these would have hit every other extension developer eventually, so we drove fixes upstream.
ID collision (our one production rollback)
After we shipped our custom field extension, overlays started misbehaving wherever a checkout had more than one. A merchant could add a checkbox field with a label like "I agree to the Terms of Service", where the link opens a modal with the policy text. However, on checkouts with two of these fields, clicking the second field's link opened the first field's modal.
The cause was ID collisions. We paired each trigger with its modal through a shared ID (commandFor on the trigger, id on the modal), and we generated that ID with Preact's useId(). The problem was that useId() produces deterministic IDs scoped to a single render tree, so two instances of the same extension generated identical IDs. Polaris web components lean heavily on IDs for commandFor wiring, so those duplicates created bugs. We caught it through monitoring, rolled back the same day, and shipped a fix the next morning.
The fix was a useStableId hook that generates a random, instance-unique ID instead of a deterministic one:
Text alignment difficulties
The trickiest extension was dynamic-content, which is why it shipped last. Its "benefits" block, a grid of cells with a centered icon, title, and description, came out of migration with text jammed to the left.
The cause: textAlign had been removed from Paragraph and Heading in Polaris web components, leaving no way to center text in a multi-column grid. A "cosmetic" gap, but it would have shipped visibly broken checkouts to a long list of our merchants.
The fix was a cross-functional call between our engineers, designers, and product managers: should we re-expose styling control, or build a dedicated component? Ultimately, we aligned on re-exposing textAlignment on Paragraph in ui-extensions (PR #4455).
s-checkbox label slot refactor
s-checkbox label only accepted a plain string, which broke our extremely common "Accept the [terms and conditions]" pattern with an inline link. We decided to refactor the component and add a label slot that accepts a string or an HTMLElement, sanitized to allow only s-text and s-link in ui-extensions (PR #4395).
Testing effort
We tested against real production configurations, not synthetic ones. We wrote a formal test plan for each extension and parity checks against real merchant stores spanning many block configurations. It helped that we had a suite of baseline e2e tests per extension as well that we expected to pass pre- and post-upgrade.
During the effort we also built and improved our shared test infrastructure. A renderWithProviders helper wraps a component or hook in the full extension provider tree (telemetry, block context, query, analytics) with safe defaults, so the team could write fast, realistic remote-dom tests instead of hand-rolling mocks.
We built parity suites for the risky swaps: a 42,000-line production-parity corpus for the Liquid parser, and a 100-example suite from real warehouse data for the markdown migration.
We also used this as an opportunity to tackle some observability gaps in our extensions. Pre-upgrade, we added some metrics to establish success/failure rates so we would be able to see right away if things were regressing post-upgrade.
We released the way we tested: incrementally, lowest-risk first, at most one extension per day to keep the blast radius small. Our one real rollback, the date picker collision, was caught fast by exactly this monitoring and fixed forward the next day. We also removed the deprecated merchant-facing settings from the checkout editor alongside go-live, and posted a forward-looking changelog.
What we learned
- A hard bundle limit can be challenging but it’s beneficial for the buyer experience. The 64KB ceiling forced decisions we had been putting off, like replacing oversized dependencies.
- Metrics are your real rollback signal. Disciplined per-extension metrics plus cautious daily releases gave us a fast, reliable way to detect and undo problems.
- Validate against production data, not synthetic cases. The Liquid parity corpus and the live-config validation on the benefits block were what kept us from shipping subtle breakage to billions of dollars of GMV. A "cosmetic" component gap can mean visibly broken checkouts at scale if you do not check real data.
- AI is great at the mechanical parts. The agent skill handled the repetitive conversion work, and a spec-plus-parity-suite approach let us build a production-grade Liquid parser with AI assistance and high confidence. That freed the team to spend its time on the judgment calls.
If you're upgrading UI extensions to remote-dom and Polaris web components, Shopify now provides an AI toolkit and upgrade guides. These automate most of what we did by hand and bake in much of what we learned. The mechanical work is largely solved. The hard problems are the ones worth planning for up front: bundle budgets, ID collisions, and component nuances.
We would love to hear from other teams who have run work like this. What did your hardest dependency swap turn out to be? Chat with us in the Shopify dev community.
