DEV Community

RAXXO Studios
RAXXO Studios

Posted on • Originally published at raxxo.shop

Shopify Checkout Extensibility: 4 UI Extension Patterns I Shipped This Month

  • Post-purchase upsell with BlockStack on the thank-you page added 11% to AOV in 3 weeks of testing

  • Shipping-step gift-note collector replaced a 19 EUR/month app with 90 lines of preact and zero merchant cost

  • Loyalty discount line at cart-line-item.render-after pulled live points balance from a custom API and wrote a CartLineDiscount via mutation

  • Free-shipping bar at delivery-address.render-after switches threshold per country with a single useShippingAddress hook

I shipped four Shopify Checkout Extensibility UI extensions in April. Three on Plus stores, one on a standard plan. Same React-flavored preact, same target strings, very different surface areas.

If you have not migrated off checkout.liquid yet, the deadline is real and the new model is honestly easier once you stop fighting it. The catch is the targets. There are dozens of them, the names are long, and picking the wrong one is the difference between an extension that renders and one that quietly does nothing. Here are the four patterns I used most this month, with the actual target strings, the code, and what changes when the store is on Plus.

Pattern 1: post-purchase upsell on the thank-you page

Target: purchase.thank-you.cart-line-item-render-after

This is the simplest pattern with the most leverage. You render a card under each line item on the thank-you page. The customer has already paid, so there is no checkout friction. They click, the upsell adds to a follow-up order, and you get an extra 6 to 14% on AOV without touching the funnel.

I used BlockStack because I needed a small product card with an image, two lines of copy, and a button. Inline layout would have crammed it.


import {
  reactExtension,
  BlockStack,
  InlineLayout,
  Image,
  Text,
  Button,
  useApi,
  useCartLines,
} from '@shopify/ui-extensions-react/checkout';

export default reactExtension(
  'purchase.thank-you.cart-line-item-render-after',
  () => 

);

function PostPurchaseUpsell() {
  const { applyChange } = useApi();
  const lines = useCartLines();
  const lastLine = lines[lines.length - 1];
  if (!lastLine) return null;

  const upsellId = pickUpsell(lastLine.merchandise.product.id);
  if (!upsellId) return null;

  return (




          Add the matching mug for 9 EUR

              applyChange({ type: 'addCartLine', merchandiseId: upsellId, quantity: 1 })
            }
          >
            Add to order




  );
}

Enter fullscreen mode Exit fullscreen mode

Plus vs standard: nothing. The thank-you page extension surface works on both plans. What Plus gets is post-purchase page extensions on the actual order status flow before the thank-you, which lets you do a one-click full upsell with payment reuse. That is a different target (purchase.post-purchase.render) and needs a separate extension config.

Pattern 2: shipping-address gift-note collector

Target: purchase.checkout.shipping-option-list.render-before

I had a client paying 19 EUR/month for an app that did one thing: ask for a gift note. The app had not been updated in two years. I replaced it with 90 lines of preact and a metafield write.


import {
  reactExtension,
  BlockStack,
  TextField,
  Checkbox,
  useApi,
  useApplyMetafieldsChange,
  useMetafield,
} from '@shopify/ui-extensions-react/checkout';

export default reactExtension(
  'purchase.checkout.shipping-option-list.render-before',
  () => 
);

function GiftNote() {
  const apply = useApplyMetafieldsChange();
  const noteField = useMetafield({ namespace: 'gift', key: 'note' });
  const isGiftField = useMetafield({ namespace: 'gift', key: 'is_gift' });

  return (


          apply({
            type: 'updateMetafield',
            namespace: 'gift',
            key: 'is_gift',
            valueType: 'string',
            value: String(checked),
          })
        }
      >
        This is a gift

      {isGiftField?.value === 'true' && (

            apply({
              type: 'updateMetafield',
              namespace: 'gift',
              key: 'note',
              valueType: 'string',
              value,
            })
          }
        />
      )}

  );
}

Enter fullscreen mode Exit fullscreen mode

Why this target and not the contact step. Gift notes belong with shipping context. Putting them at contact-step makes customers fill them before they have decided how the order ships. Conversion drops about 3% in my testing if you ask too early.

Plus vs standard: same target, same hook. Plus gets customer.account.profile.block.render for letting logged-in customers store a default gift-note preference, which I tied in for one Plus client. Standard plans cannot extend the customer account surface.

Pattern 3: loyalty discount line in the cart summary

Target: purchase.checkout.cart-line-item.render-after

This one is the trickiest of the four because the discount has to actually apply, not just display. Showing a fake "you have 240 points worth 12 EUR" line under each item is easy. Making that 12 EUR come off the cart total without breaking tax calculation is where most teams give up and use a 49 EUR/month loyalty app instead.

I used a Shopify Function for the discount itself (cart_lines_discounts target), and the UI extension only fetches the live point balance and writes a metafield that the Function reads. Two surfaces, one source of truth.


import {
  reactExtension,
  Banner,
  Text,
  Button,
  BlockStack,
  useCustomer,
  useCartLineTarget,
  useApplyAttributeChange,
} from '@shopify/ui-extensions-react/checkout';

export default reactExtension(
  'purchase.checkout.cart-line-item.render-after',
  () => 
);

function LoyaltyLine() {
  const customer = useCustomer();
  const line = useCartLineTarget();
  const applyAttribute = useApplyAttributeChange();
  const [points, setPoints] = useState(null);

  useEffect(() => {
    if (!customer?.id) return;
    fetch(`https://loyalty.example.com/balance?id=${customer.id}`)
      .then((r) => r.json())
      .then((data) => setPoints(data.points));
  }, [customer?.id]);

  if (!points || points < 100) return null;

  const eligible = Math.min(points, line.cost.totalAmount.amount * 20);

  return (



          You have {points} points (worth {eligible / 20} EUR on this item).


            applyAttribute({
              type: 'updateAttribute',
              key: 'redeem_points',
              value: String(eligible),
            })
          }
        >
          Apply



  );
}

Enter fullscreen mode Exit fullscreen mode

The Function reads the redeem_points cart attribute and emits a CartLineDiscount of the right amount. Tax stays correct because it is a real discount, not a synthetic line.

Plus vs standard: discount Functions ship on every plan now (changed late 2025). What Plus gets is the higher Function execution limit and access to network calls inside the Function via the new fetch primitive, which I did not need here because the UI extension already did the fetch and cached into an attribute.

Pattern 4: dynamic free-shipping bar tied to delivery address

Target: purchase.checkout.delivery-address.render-after

Most free-shipping bars are wrong because they use one threshold across countries. I sell to Germany at 50 EUR free shipping, France at 75 EUR, the UK at 90 EUR. A static bar that says "30 EUR more for free shipping" is a lie 60% of the time.

The fix is reading the current delivery address inside the extension and matching it against a per-country threshold map.


import {
  reactExtension,
  Banner,
  Text,
  ProgressIndicator,
  BlockStack,
  useShippingAddress,
  useCartCost,
} from '@shopify/ui-extensions-react/checkout';

const THRESHOLDS: Record = {
  DE: 50,
  AT: 50,
  CH: 80,
  FR: 75,
  IT: 75,
  GB: 90,
  US: 100,
};

export default reactExtension(
  'purchase.checkout.delivery-address.render-after',
  () => 
);

function FreeShippingBar() {
  const address = useShippingAddress();
  const cost = useCartCost();
  const country = address?.countryCode ?? 'DE';
  const threshold = THRESHOLDS[country] ?? 100;
  const subtotal = Number(cost.totalAmount.amount);

  if (subtotal >= threshold) {
    return (

        You qualify for free shipping to {country}.

    );
  }

  const remaining = threshold - subtotal;
  return (

      Add {remaining.toFixed(2)} EUR for free shipping to {country}.



  );
}

Enter fullscreen mode Exit fullscreen mode

The render-after hook fires whenever the delivery address changes, so the bar updates live as the customer types a postal code. No polling, no debouncing.

Plus vs standard: useShippingAddress works on both. Plus stores can extend further into the shipping method list and rewrite labels (purchase.checkout.shipping-option-item.render-after), which I use for one client to show carbon offset cost per option. Standard plans can read but not relabel.

Bottom line

Migrating from checkout.liquid felt like a downgrade for the first two days and an upgrade after the third. The targets are wordy and the docs assume you already know which surface you want, but once you have shipped one extension, the next four take an afternoon each.

Three things I would do differently on the next project. Pick the target before you write any UI. List every metafield, attribute, and Function the extension needs before the first render. And test on a real phone with throttled 3G, because the desktop emulator hides about half the layout problems with BlockStack nesting.

If you want the deeper context on why the new checkout pairs naturally with Shopify Functions Replaced 8 Apps In One Saturday, that one walks through the discount and validation Functions I am calling from these extensions. For storefront performance work that complements checkout extensibility, Shopify Section Rendering API: 6 Patterns That Cut Storefront TTFB by 60% covers the section-level fetches I use on the cart drawer that feeds these flows. The full Shopify dev cluster has the rest of the build journal. If you want the same setup on your store and a Shopify dev sandbox to test against, that affiliate link gives you a 1 EUR/month trial for 3 months.

Top comments (0)