Checkout エクステンション
Shopify のチェックアウトはインターネット上で最もコンバージョン率が高いチェックアウトであり、完全に拡張可能です。Checkout エクステンションを使用すると、カスタム UI の追加、決済および配送オプションの変更、カート内容のバリデーション、購入後体験の作成が可能です -- すべて Shopify のコアチェックアウトコードに触れることなく。これらのエクステンションは、チェックアウトのパフォーマンスとセキュリティが決して損なわれないことを保証する安全なサンドボックス環境で実行されます。
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 エクステンション
Checkout UI エクステンションはチェックアウトフローの特定のポイントでカスタムコンポーネントをレンダリングします。最も一般的なタイプのチェックアウトエクステンションです。
エクステンションターゲット
Checkout UI extensions can render at these locations:
| ターゲット | 場所 | 一般的なユースケース |
|---|---|---|
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 |
Checkout UI エクステンションの構築
# 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.
購入後エクステンション
購入後エクステンションは、顧客が支払いを完了した直後、サンキューページの前にレンダリングされます。アップセルとクロスセルにとって高コンバージョンの瞬間です。
// 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.
決済カスタマイズ
決済カスタマイズエクステンションを使用すると、カートの内容、顧客の ID、その他の条件に基づいて、利用可能な決済方法とその表示順序を変更できます。
// 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 };
}
配送カスタマイズ
配送カスタマイズエクステンションは、利用可能な配送料を変更します -- 条件に基づいて料率の名前変更、並べ替え、非表示を行います。
// 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 };
}
カートとチェックアウトのバリデーション
バリデーションエクステンションはチェックアウトでビジネスルールを適用し、無効な注文の発注を防ぎます。
// 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.
デプロイとテスト
# 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
テストチェックリスト
- 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.