Checkout Extensions
Shopify's checkout is the highest-converting checkout on the internet, and it is fully extensible. Checkout extensions let you add custom UI, modify payment and shipping options, validate cart contents, and create post-purchase experiences -- all without touching Shopify's core checkout code. These extensions run in a secure, sandboxed environment that ensures checkout performance and security are never compromised.
Shopify has moved away from checkout.liquid customization toward a component-based extension model. All new checkout customizations should use checkout extensions. The checkout.liquid approach is deprecated for new stores and will eventually be removed for existing Shopify Plus stores.
Checkout UI Extensions
Checkout UI extensions render custom components at specific points in the checkout flow. They are the most common type of checkout extension.
Extension Targets
Checkout UI extensions can render at these locations:
| Target | Location | Common Use Cases |
|---|---|---|
purchase.checkout.block.render | Main checkout body | Custom fields, upsells, loyalty info |
purchase.checkout.header.render-after | Below checkout header | Banners, trust badges |
purchase.checkout.footer.render-before | Above checkout footer | Legal notices, guarantees |
purchase.checkout.delivery-address.render-before | Before shipping address | Gift messaging, delivery instructions |
purchase.checkout.shipping-option-list.render-after | After shipping options | Shipping protection, delivery notes |
purchase.checkout.payment-method-list.render-before | Before payment methods | Loyalty points redemption |
purchase.thank-you.block.render | Thank-you page | Post-purchase survey, referral |
purchase.order-status.block.render | Order status page | Tracking, support, reviews |
Building a Checkout UI Extension
# Generate a checkout UI extension
shopify app generate extension --template checkout_ui --name loyalty-points-checkout
// extensions/loyalty-points-checkout/src/Checkout.tsx
import {
reactExtension,
Banner,
BlockStack,
Button,
Checkbox,
Divider,
Heading,
InlineLayout,
Text,
useApi,
useApplyDiscountCodeChange,
useCartLines,
useTotalAmount,
} from '@shopify/ui-extensions-react/checkout';
import { useEffect, useState } from 'react';
const checkoutBlock = reactExtension(
'purchase.checkout.block.render',
() => <LoyaltyPointsRedemption />
);
export default checkoutBlock;
function LoyaltyPointsRedemption() {
const { buyerIdentity } = useApi();
const cartLines = useCartLines();
const totalAmount = useTotalAmount();
const applyDiscountCode = useApplyDiscountCodeChange();
const [loyaltyData, setLoyaltyData] = useState<{
pointsBalance: number;
maxRedeemable: number;
dollarValue: number;
} | null>(null);
const [redeemPoints, setRedeemPoints] = useState(false);
const [applied, setApplied] = useState(false);
const customerEmail = buyerIdentity?.email?.current;
useEffect(() => {
async function fetchLoyalty() {
if (!customerEmail) return;
try {
const response = await fetch(
`/api/loyalty/checkout-balance?email=${encodeURIComponent(customerEmail)}&total=${totalAmount.amount}`
);
const data = await response.json();
setLoyaltyData(data);
} catch (err) {
console.error('Failed to fetch loyalty data');
}
}
fetchLoyalty();
}, [customerEmail, totalAmount.amount]);
async function handleRedeem() {
if (!loyaltyData) return;
try {
// Apply a discount code that corresponds to the points redemption
const result = await applyDiscountCode({
type: 'addDiscountCode',
code: `LOYALTY-${customerEmail}-${loyaltyData.maxRedeemable}`,
});
if (result.type === 'success') {
setApplied(true);
}
} catch (err) {
console.error('Failed to apply loyalty discount');
}
}
if (!loyaltyData || loyaltyData.pointsBalance === 0) {
return null; // Don't render anything if no points available
}
return (
<BlockStack spacing="base">
<Heading level={2}>Loyalty Points</Heading>
<Divider />
<InlineLayout columns={['fill', 'auto']}>
<Text>Available Points</Text>
<Text emphasis="bold">
{loyaltyData.pointsBalance.toLocaleString()}
</Text>
</InlineLayout>
<InlineLayout columns={['fill', 'auto']}>
<Text>Redeemable Value</Text>
<Text emphasis="bold">
${loyaltyData.dollarValue.toFixed(2)}
</Text>
</InlineLayout>
{!applied ? (
<BlockStack spacing="tight">
<Checkbox
checked={redeemPoints}
onChange={setRedeemPoints}
>
Apply {loyaltyData.maxRedeemable.toLocaleString()} points
(-${loyaltyData.dollarValue.toFixed(2)})
</Checkbox>
{redeemPoints && (
<Button kind="secondary" onPress={handleRedeem}>
Redeem Points
</Button>
)}
</BlockStack>
) : (
<Banner status="success">
{loyaltyData.maxRedeemable.toLocaleString()} points applied!
You saved ${loyaltyData.dollarValue.toFixed(2)}.
</Banner>
)}
</BlockStack>
);
}
Checkout extensions must load within 500ms. Keep your bundle small, minimize API calls during render, and use Shopify's built-in hooks (useCartLines, useTotalAmount, useBuyerIdentity) instead of making separate API requests for data Shopify already provides.
Post-Purchase Extensions
Post-purchase extensions render immediately after the customer completes payment but before the thank-you page. This is a high-conversion moment for upsells and cross-sells.
// extensions/post-purchase-upsell/src/PostPurchase.tsx
import {
reactExtension,
useApi,
BlockStack,
Button,
Heading,
Image,
InlineLayout,
Text,
TextBlock,
} from '@shopify/ui-extensions-react/post-purchase';
import { useState } from 'react';
const postPurchase = reactExtension(
'purchase.post.render',
() => <PostPurchaseUpsell />
);
export default postPurchase;
function PostPurchaseUpsell() {
const { inputData, calculateChangeset, applyChangeset, done } = useApi();
const [loading, setLoading] = useState(false);
const [accepted, setAccepted] = useState(false);
// The offer is determined server-side and passed through inputData
const offer = inputData.initialPurchase
? getRecommendedOffer(inputData.initialPurchase)
: null;
if (!offer) {
done(); // No offer to show, proceed to thank-you
return null;
}
async function handleAccept() {
setLoading(true);
// Calculate the new order total with the upsell item
const changeset = await calculateChangeset({
changes: [
{
type: 'add_variant',
variantId: offer.variantId,
quantity: 1,
discount: {
value: offer.discountPercentage,
valueType: 'percentage',
title: 'Post-purchase exclusive',
},
},
],
});
// Apply the changeset -- charges the customer immediately
await applyChangeset(changeset.token);
setAccepted(true);
setLoading(false);
}
function handleDecline() {
done(); // Proceed to thank-you page
}
if (accepted) {
return (
<BlockStack spacing="loose">
<Heading>Added to your order!</Heading>
<Text>
{offer.title} has been added. Your card has been charged an
additional ${offer.discountedPrice.toFixed(2)}.
</Text>
<Button onPress={() => done()}>Continue</Button>
</BlockStack>
);
}
return (
<BlockStack spacing="loose">
<Heading>Wait -- exclusive offer just for you!</Heading>
<InlineLayout columns={['auto', 'fill']} spacing="base">
<Image source={offer.imageUrl} alt={offer.title} />
<BlockStack spacing="tight">
<Text emphasis="bold" size="large">{offer.title}</Text>
<TextBlock>{offer.description}</TextBlock>
<InlineLayout columns={['auto', 'auto']} spacing="tight">
<Text appearance="subdued" accessibilityRole="deletion">
${offer.originalPrice.toFixed(2)}
</Text>
<Text emphasis="bold">
${offer.discountedPrice.toFixed(2)}
</Text>
</InlineLayout>
<Text size="small" appearance="subdued">
{offer.discountPercentage}% off -- this offer expires when
you leave this page
</Text>
</BlockStack>
</InlineLayout>
<InlineLayout columns={['fill', 'fill']} spacing="base">
<Button kind="primary" onPress={handleAccept} loading={loading}>
Add to Order
</Button>
<Button kind="plain" onPress={handleDecline}>
No thanks
</Button>
</InlineLayout>
</BlockStack>
);
}
function getRecommendedOffer(initialPurchase: any) {
// In production, this calls your recommendation engine
// For this example, return a static offer
return {
variantId: 'gid://shopify/ProductVariant/99999',
title: 'Premium Shoe Care Kit',
description: 'Keep your new shoes looking fresh with our complete care kit.',
imageUrl: 'https://example.com/shoe-care.jpg',
originalPrice: 29.99,
discountedPrice: 19.99,
discountPercentage: 33,
};
}
Post-purchase upsells charge the customer's payment method immediately without requiring them to re-enter card details. This is powerful but must be used responsibly. Always clearly communicate the additional charge and make the decline option prominent. Abusing post-purchase extensions will result in app removal from the Shopify App Store.
Payment Customization
Payment customization extensions let you modify which payment methods are available and in what order they appear, based on cart contents, customer identity, or other conditions.
// extensions/payment-customization/src/run.ts
import type { RunInput, FunctionRunResult } from '../generated/api';
export function run(input: RunInput): FunctionRunResult {
const cart = input.cart;
const buyerCountry = input.buyerIdentity?.countryCode;
const operations: FunctionRunResult['operations'] = [];
// Hide Cash on Delivery for international orders
if (buyerCountry && buyerCountry !== 'US') {
const codMethod = input.paymentMethods.find(
(method) => method.name.includes('Cash on Delivery')
);
if (codMethod) {
operations.push({
hide: {
paymentMethodId: codMethod.id,
},
});
}
}
// Move PayPal to the top for carts over $200
const cartTotal = parseFloat(cart.cost.totalAmount.amount);
if (cartTotal > 200) {
const paypal = input.paymentMethods.find(
(method) => method.name.includes('PayPal')
);
if (paypal) {
operations.push({
move: {
paymentMethodId: paypal.id,
index: 0,
},
});
}
}
return { operations };
}
Shipping Customization
Shipping customization extensions modify available shipping rates -- renaming, reordering, or hiding rates based on conditions.
// extensions/shipping-customization/src/run.ts
import type { RunInput, FunctionRunResult } from '../generated/api';
export function run(input: RunInput): FunctionRunResult {
const cart = input.cart;
const operations: FunctionRunResult['operations'] = [];
const cartTotal = parseFloat(cart.cost.totalAmount.amount);
const hasFragileItem = cart.lines.some((line) =>
line.merchandise.__typename === 'ProductVariant' &&
line.merchandise.product.hasAnyTag &&
line.merchandise.product.tags?.includes('fragile')
);
// Rename standard shipping to show estimated date
const standardRate = input.shippingRates.find(
(rate) => rate.title === 'Standard Shipping'
);
if (standardRate) {
operations.push({
rename: {
shippingRateId: standardRate.id,
title: 'Standard Shipping (5-7 business days)',
},
});
}
// Hide economy shipping for fragile items
if (hasFragileItem) {
const economyRate = input.shippingRates.find(
(rate) => rate.title.includes('Economy')
);
if (economyRate) {
operations.push({
hide: {
shippingRateId: economyRate.id,
},
});
}
}
// Free shipping for orders over $100
if (cartTotal >= 100) {
const standardShipping = input.shippingRates.find(
(rate) => rate.title.includes('Standard')
);
if (standardShipping) {
operations.push({
rename: {
shippingRateId: standardShipping.id,
title: 'Free Standard Shipping (5-7 business days)',
},
});
}
}
return { operations };
}
Cart and Checkout Validation
Validation extensions enforce business rules at checkout, preventing invalid orders from being placed.
// extensions/cart-validation/src/run.ts
import type { RunInput, FunctionRunResult } from '../generated/api';
export function run(input: RunInput): FunctionRunResult {
const errors: FunctionRunResult['errors'] = [];
const cart = input.cart;
// Rule 1: Maximum 5 units of any single product
for (const line of cart.lines) {
if (line.quantity > 5) {
const title = line.merchandise.__typename === 'ProductVariant'
? line.merchandise.product.title
: 'this item';
errors.push({
localizedMessage: `Maximum 5 units of ${title} per order. Please reduce the quantity.`,
target: 'cart',
});
}
}
// Rule 2: Certain products cannot be shipped to PO Boxes
const shippingAddress = input.buyerIdentity?.shippingAddress;
const hasRestrictedItem = cart.lines.some(
(line) =>
line.merchandise.__typename === 'ProductVariant' &&
line.merchandise.product.tags?.includes('no-po-box')
);
if (
hasRestrictedItem &&
shippingAddress?.address1 &&
/p\.?o\.?\s*box/i.test(shippingAddress.address1)
) {
errors.push({
localizedMessage:
'One or more items in your cart cannot be shipped to a PO Box. Please use a street address.',
target: 'cart',
});
}
// Rule 3: Minimum order value for wholesale tagged customers
const isWholesale = input.buyerIdentity?.customer?.hasAnyTag &&
input.buyerIdentity.customer.tags?.includes('wholesale');
const cartTotal = parseFloat(cart.cost.totalAmount.amount);
if (isWholesale && cartTotal < 250) {
errors.push({
localizedMessage: `Wholesale accounts have a minimum order of $250.00. Your current total is $${cartTotal.toFixed(2)}.`,
target: 'cart',
});
}
return { errors };
}
Cart validation functions execute on every checkout update -- when items are added, addresses change, or discount codes are applied. They must complete within 5 seconds and cannot make external network requests. All data must come from the function input. If you need external data, use metafields to store it ahead of time.
Deployment and Testing
# Test checkout extensions locally
shopify app dev
# The CLI provides a preview URL for the checkout:
# https://your-dev-store.myshopify.com/?preview_checkout_extensions=true
# Deploy to production
shopify app deploy
Testing Checklist
- Test with various cart compositions (single item, multiple items, high value, low value)
- Test with different customer states (logged in, guest, returning customer)
- Test on mobile devices (checkout is heavily used on mobile)
- Test error states (network failures, invalid data)
- Test with discount codes and gift cards applied
- Verify that the checkout still completes if your extension fails to load
Continue to Theme App Extensions to learn how to add your app's functionality to merchant storefronts using Online Store 2.0 app blocks.