Skip to main content

Migrating from checkout.liquid to Shopify Checkout UI Extensions

 The deprecation of checkout.liquid marks a fundamental architectural shift for Shopify Plus merchants. Historically, developers customized the checkout experience by directly modifying the DOM and injecting synchronous JavaScript payloads.

This legacy approach is being entirely replaced by Shopify Checkout Extensibility. Developers must now rebuild custom logic—such as cross-sells, custom attributes, and field validations—using a strict, React-based sandboxed environment. Navigating this checkout.liquid migration requires a complete departure from direct DOM manipulation in favor of declarative UI components and secure API hooks.

Understanding the Architectural Shift

To understand how to rebuild your checkout, you must first understand why the legacy approach was deprecated. The checkout.liquid model allowed arbitrary JavaScript execution directly within the browser's main thread. While highly flexible, this created severe security vulnerabilities and performance bottlenecks for enterprise ecommerce solutions.

Direct DOM manipulation often resulted in brittle code that would break whenever Shopify updated the checkout's underlying HTML structure. Additionally, third-party scripts could silently scrape sensitive payment data, risking PCI compliance.

Shopify UI Extensions React operate in a tightly constrained Web Worker sandbox. You no longer have access to the window or document objects. Instead, your React code communicates asynchronously with the main thread via a secure Remote Page Object (RPC) bridge. This ensures that a Shopify Plus custom checkout remains highly performant, fully upgradeable, and strictly secure.

Rebuilding Custom Logic: Upsells and Custom Fields

A common requirement for enterprise merchants is capturing order-level data (like delivery instructions) and presenting dynamic in-checkout upsells. In the legacy model, this required injecting HTML inputs and making AJAX calls to the /cart/update.js endpoint.

Using Shopify Checkout Extensibility, this is accomplished via standard React components provided by Shopify, paired with built-in state management hooks. Below is a complete, production-ready implementation that renders a custom delivery instruction field and dynamically fetches an upsell product via GraphQL.

1. Extension Configuration

First, you must declare your extension targets and required network access in your shopify.extension.toml file.

api_version = "2024-04"

[[extensions]]
type = "ui_extension"
name = "checkout-upsell-and-fields"
handle = "checkout-upsell-and-fields"

[[extensions.targeting]]
module = "./src/Checkout.tsx"
target = "purchase.checkout.block.render"

[extensions.capabilities]
network_access = true

[extensions.settings]
[[extensions.settings.fields]]
key = "upsell_product_id"
type = "single_line_text_field"
name = "Upsell Product ID (Storefront ID)"

2. The React UI Extension

The following React code connects directly to the Shopify UI Extensions API. It utilizes standard hooks to read extension settings, update cart state, and mutate order metafields in real-time.

import React, { useState, useEffect } from 'react';
import {
  reactExtension,
  BlockStack,
  TextField,
  Button,
  InlineLayout,
  Text,
  Image,
  useApplyMetafieldsChange,
  useMetafield,
  useApplyCartLinesChange,
  useSettings,
  ProgressIndicator,
} from '@shopify/ui-extensions-react/checkout';

// Target the block render hook
export default reactExtension('purchase.checkout.block.render', () => <App />);

interface UpsellVariant {
  id: string;
  title: string;
  image: { url: string };
  price: { amount: string; currencyCode: string };
}

function App() {
  const { upsell_product_id } = useSettings();
  const applyCartLinesChange = useApplyCartLinesChange();
  const applyMetafieldsChange = useApplyMetafieldsChange();
  
  // Read existing metafield state to prevent data loss on re-renders
  const deliveryMetafield = useMetafield({
    namespace: "custom",
    key: "delivery_instructions"
  });

  const [instructions, setInstructions] = useState(deliveryMetafield?.value || "");
  const [upsellData, setUpsellData] = useState<UpsellVariant | null>(null);
  const [loading, setLoading] = useState(false);
  const [adding, setAdding] = useState(false);

  useEffect(() => {
    if (!upsell_product_id) return;

    // Fetch upsell data via Storefront GraphQL API
    async function fetchUpsell() {
      setLoading(true);
      try {
        const query = `
          query getUpsell($id: ID!) {
            product(id: $id) {
              variants(first: 1) {
                nodes {
                  id
                  title
                  image { url }
                  price { amount currencyCode }
                }
              }
            }
          }
        `;
        
        // Requires Storefront API public token configured in your app proxy or worker
        const response = await fetch('https://your-app-domain.com/api/storefront', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ query, variables: { id: upsell_product_id } })
        });
        
        const { data } = await response.json();
        const variant = data?.product?.variants?.nodes[0];
        
        if (variant) setUpsellData(variant);
      } catch (error) {
        console.error('Failed to fetch upsell product:', error);
      } finally {
        setLoading(false);
      }
    }

    fetchUpsell();
  }, [upsell_product_id]);

  const handleInstructionsChange = (value: string) => {
    setInstructions(value);
    applyMetafieldsChange({
      type: "updateMetafield",
      namespace: "custom",
      key: "delivery_instructions",
      valueType: "string",
      value: value,
    });
  };

  const handleAddUpsell = async () => {
    if (!upsellData) return;
    setAdding(true);
    
    const result = await applyCartLinesChange({
      type: 'addCartLine',
      merchandiseId: upsellData.id,
      quantity: 1,
    });
    
    if (result.type === 'error') {
      console.error('Failed to add upsell to cart:', result.message);
    }
    setAdding(false);
  };

  return (
    <BlockStack spacing="loose">
      <TextField
        label="Delivery Instructions"
        value={instructions}
        onChange={handleInstructionsChange}
        multiline={2}
      />

      {loading && <ProgressIndicator />}

      {upsellData && (
        <BlockStack spacing="tight" padding="tight" border="base" cornerRadius="base">
          <Text size="base" emphasis="bold">Special Offer</Text>
          <InlineLayout spacing="base" columns={['20%', 'fill', 'auto']}>
            <Image source={upsellData.image.url} />
            <BlockStack spacing="none">
              <Text size="base">{upsellData.title}</Text>
              <Text size="small" appearance="subdued">
                {upsellData.price.currencyCode} ${upsellData.price.amount}
              </Text>
            </BlockStack>
            <Button
              onPress={handleAddUpsell}
              loading={adding}
              kind="secondary"
            >
              Add
            </Button>
          </InlineLayout>
        </BlockStack>
      )}
    </BlockStack>
  );
}

Deep Dive: How the Sandbox Architecture Works

The code above relies heavily on custom hooks exposed by @shopify/ui-extensions-react/checkout. When you call applyCartLinesChange, you are not making an HTTP request directly from the browser to Shopify's servers.

Instead, the Web Worker serializes the instruction and sends it across the RPC bridge to the host environment (the checkout). The host environment intercepts this request, validates the state of the cart, applies the mutation natively, and then returns the updated state to the worker.

This abstraction strictly enforces API limits and payload validation. It guarantees that malicious code cannot manipulate cart totals or bypass inventory checks, addressing the core vulnerabilities of the old checkout.liquid file.

Handling Common Pitfalls and Edge Cases

A major point of failure during a checkout.liquid migration is the attempt to use standard HTML elements like <div><span>, or <input>. The Web Worker sandbox lacks a DOM interface. Any standard HTML tags will result in runtime errors. You must strictly use the predefined Shopify UI Extensions components (e.g., BlockStackInlineLayoutText).

Another common pitfall involves network latency. UI Extensions are subject to strict performance budgets. If your external API calls (such as fetching dynamic pricing or custom validations) exceed acceptable timeouts, Shopify will forcefully terminate the extension block. Always utilize asynchronous state transitions and display ProgressIndicator components to prevent the user from experiencing a frozen checkout interface.

Lastly, be cautious with Cross-Origin Resource Sharing (CORS). Because extensions run securely inside checkout.shopify.com, direct API calls to your infrastructure require properly configured CORS headers. Using an App Proxy is the standard approach to seamlessly route requests from the checkout sandbox to your backend infrastructure without exposing API keys.

Conclusion

Migrating to Shopify Checkout Extensibility is a mandatory evolution for merchants demanding high availability and security. By discarding the fragile DOM manipulation of checkout.liquid and adopting Shopify UI Extensions React, developers unlock a standardized, predictable environment. Transitioning custom fields and dynamic upsells into this framework requires adopting modern state management and strictly adhering to the constraints of the Web Worker sandbox.