All skills
Skillintermediate
Checkout Customization
- Adding custom UI to checkout (banners, fields, upsells) - Implementing custom discount logic with Shopify Functions - Building post-purchase experiences - Customizing shipping and payment options - Checkout branding and localization
Claude Code Knowledge Pack7/10/2026
Overview
Checkout Customization
When to Use
- Adding custom UI to checkout (banners, fields, upsells)
- Implementing custom discount logic with Shopify Functions
- Building post-purchase experiences
- Customizing shipping and payment options
- Checkout branding and localization
When NOT to Use
- Full checkout replacement (not possible on Shopify)
- Theme-level cart customization (use Liquid)
- Pre-checkout flows (use theme or headless)
- Admin-side order processing (use Admin API)
Checkout Extensibility Overview
Extension Points
| Extension | Purpose | API Version |
|---|---|---|
Checkout::Dynamic::Render | Add UI anywhere in checkout | 2024.10+ |
Checkout::CartLineDetails::RenderAfter | Below cart line items | 2024.10+ |
Checkout::DeliveryAddress::RenderBefore | Before delivery address | 2024.10+ |
purchase.checkout.block.render | Custom blocks in checkout | 2024.10+ |
purchase.thank-you.block.render | Thank you page | 2024.10+ |
purchase.post-purchase.render | Post-purchase upsell | 2024.10+ |
Project Setup
# Create checkout extension
npm run shopify app generate extension -- --type checkout_ui
# Extension structure
extensions/
└── checkout-ui/
├── src/
│ └── Checkout.tsx # Main extension component
├── locales/
│ └── en.default.json # Translations
├── shopify.extension.toml
└── package.json
Checkout UI Extensions
Configuration
# extensions/checkout-ui/shopify.extension.toml
api_version = "2024-10"
[[extensions]]
type = "ui_extension"
name = "Custom Checkout Banner"
handle = "custom-checkout-banner"
[[extensions.targeting]]
module = "./src/Checkout.tsx"
target = "purchase.checkout.block.render"
[extensions.capabilities]
api_access = true
network_access = true
block_progress = true
[extensions.settings]
[[extensions.settings.fields]]
key = "banner_text"
type = "single_line_text_field"
name = "Banner Text"
description = "Text to display in the banner"
[[extensions.settings.fields]]
key = "banner_status"
type = "single_line_text_field"
name = "Banner Status"
description = "info, warning, success, or critical"
Basic Extension Component
// extensions/checkout-ui/src/Checkout.tsx
reactExtension,
Banner,
useSettings,
useTranslate,
BlockStack,
Text,
useExtensionCapability,
useBuyerJourneyIntercept,
} from "@shopify/ui-extensions-react/checkout";
));
function CheckoutBanner() {
const translate = useTranslate();
const { banner_text, banner_status } = useSettings();
return (
);
}
Cart Line Item Extension
// extensions/cart-upsell/src/CartLineUpsell.tsx
reactExtension,
useCartLines,
useApplyCartLinesChange,
Button,
Text,
InlineStack,
Image,
BlockStack,
Divider,
} from "@shopify/ui-extensions-react/checkout";
"purchase.checkout.cart-line-list.render-after",
() =>
);
function CartUpsell() {
const cartLines = useCartLines();
const applyCartLinesChange = useApplyCartLinesChange();
// Example: Suggest complementary product based on cart contents
const upsellProduct = getUpsellRecommendation(cartLines);
if (!upsellProduct) return null;
const handleAddToCart = async () => {
const result = await applyCartLinesChange({
type: "addCartLine",
merchandiseId: upsellProduct.variantId,
quantity: 1,
});
if (result.type === "error") {
console.error("Failed to add item:", result.message);
}
};
return (
Complete your order
{upsellProduct.title}
{upsellProduct.price}
Add
);
}
function getUpsellRecommendation(cartLines: CartLine[]) {
// Logic to determine upsell based on cart contents
// This would typically call your backend or use metafields
return null; // Implement based on your business logic
}
Custom Form Fields
// extensions/custom-fields/src/CustomFields.tsx
reactExtension,
useApplyMetafieldsChange,
useMetafield,
TextField,
Checkbox,
BlockStack,
Text,
useBuyerJourneyIntercept,
} from "@shopify/ui-extensions-react/checkout";
"purchase.checkout.delivery-address.render-before",
() =>
);
function DeliveryInstructions() {
const [instructions, setInstructions] = useState("");
const [leaveAtDoor, setLeaveAtDoor] = useState(false);
const [error, setError] = useState("");
const applyMetafieldsChange = useApplyMetafieldsChange();
// Block checkout if validation fails
useBuyerJourneyIntercept(({ canBlockProgress }) => {
if (canBlockProgress && leaveAtDoor && !instructions) {
return {
behavior: "block",
reason: "Please provide delivery instructions when leaving at door",
errors: [
{
message: "Delivery instructions required",
target: "$.cart.deliveryInstructions",
},
],
};
}
return { behavior: "allow" };
});
const handleInstructionsChange = async (value: string) => {
setInstructions(value);
setError("");
await applyMetafieldsChange({
type: "updateMetafield",
namespace: "custom",
key: "delivery_instructions",
valueType: "string",
value,
});
};
const handleLeaveAtDoorChange = async (checked: boolean) => {
setLeaveAtDoor(checked);
await applyMetafieldsChange({
type: "updateMetafield",
namespace: "custom",
key: "leave_at_door",
valueType: "boolean",
value: String(checked),
});
};
return (
Delivery Preferences
Leave package at door
);
}
Shopify Functions
Discount Function
# Generate discount function
npm run shopify app generate extension -- --type product_discounts
# extensions/volume-discount/shopify.extension.toml
api_version = "2024-10"
[[extensions]]
name = "Volume Discount"
handle = "volume-discount"
type = "function"
description = "Apply discounts based on quantity"
[extensions.build]
command = "cargo wasi build --release"
path = "target/wasm32-wasip1/release/volume-discount.wasm"
watch = ["src/**/*.rs", "Cargo.toml"]
[extensions.ui]
enable_create = true
[[extensions.ui.paths]]
path = "create"
module = "./src/CreateDiscount.tsx"
[[extensions.ui.paths]]
path = "details"
module = "./src/DiscountDetails.tsx"
[extensions.input.variables]
namespace = "$app:volume-discount"
key = "config"
// extensions/volume-discount/src/main.rs
use shopify_function::prelude::*;
use shopify_function::Result;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "camelCase")]
struct Config {
tiers: Vec,
}
#[derive(Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
struct Tier {
quantity: i64,
percentage: f64,
}
#[shopify_function_target(query_path = "src/run.graphql", schema_path = "schema.graphql")]
fn run(input: input::ResponseData) -> Result<output::FunctionRunResult> {
let config: Config = input
.discount_node
.metafield
.as_ref()
.map(|m| serde_json::from_str(&m.value).unwrap_or_default())
.unwrap_or_default();
let mut discounts = vec![];
for line in input.cart.lines {
if let input::InputCartLinesMerchandise::ProductVariant(variant) = &line.merchandise {
let quantity = line.quantity;
// Find applicable tier
let applicable_tier = config
.tiers
.iter()
.filter(|t| quantity >= t.quantity)
.max_by_key(|t| t.quantity);
if let Some(tier) = applicable_tier {
discounts.push(output::Discount {
value: output::Value::Percentage(output::Percentage {
value: Decimal(tier.percentage),
}),
targets: vec![output::Target::CartLine(output::CartLineTarget {
id: line.id.clone(),
quantity: None,
})],
message: Some(format!("{}% off for buying {} or more", tier.percentage, tier.quantity)),
});
}
}
}
Ok(output::FunctionRunResult {
discounts,
discount_application_strategy: output::DiscountApplicationStrategy::FIRST,
})
}
# extensions/volume-discount/src/run.graphql
query RunInput {
cart {
lines {
id
quantity
merchandise {
... on ProductVariant {
id
product {
id
handle
}
}
}
}
}
discountNode {
metafield(namespace: "$app:volume-discount", key: "config") {
value
}
}
}
Shipping Customization Function
// extensions/shipping-customization/src/main.rs
use shopify_function::prelude::*;
use shopify_function::Result;
#[shopify_function_target(query_path = "src/run.graphql", schema_path = "schema.graphql")]
fn run(input: input::ResponseData) -> Result<output::FunctionRunResult> {
let mut operations = vec![];
// Example: Hide express shipping for PO Box addresses
let is_po_box = input
.cart
.delivery_groups
.iter()
.any(|group| {
group.delivery_address.as_ref().map_or(false, |addr| {
addr.address1.as_ref().map_or(false, |a| {
a.to_lowercase().contains("po box") ||
a.to_lowercase().contains("p.o. box")
})
})
});
if is_po_box {
for group in &input.cart.delivery_groups {
for option in &group.delivery_options {
if option.title.as_ref().map_or(false, |t| t.contains("Express")) {
operations.push(output::Operation::Hide(output::HideOperation {
delivery_option_handle: option.handle.clone(),
}));
}
}
}
}
// Example: Rename shipping option based on cart value
let cart_total: f64 = input.cart.cost.subtotal_amount.amount.parse().unwrap_or(0.0);
if cart_total >= 100.0 {
for group in &input.cart.delivery_groups {
for option in &group.delivery_options {
if option.title.as_ref().map_or(false, |t| t.contains("Standard")) {
operations.push(output::Operation::Rename(output::RenameOperation {
delivery_option_handle: option.handle.clone(),
title: Some("Free Standard Shipping".to_string()),
}));
}
}
}
}
Ok(output::FunctionRunResult { operations })
}
Payment Customization Function
// extensions/payment-customization/src/main.rs
use shopify_function::prelude::*;
use shopify_function::Result;
#[shopify_function_target(query_path = "src/run.graphql", schema_path = "schema.graphql")]
fn run(input: input::ResponseData) -> Result<output::FunctionRunResult> {
let mut operations = vec![];
// Example: Hide Cash on Delivery for international orders
let is_international = input
.cart
.delivery_groups
.iter()
.any(|group| {
group.delivery_address.as_ref().map_or(false, |addr| {
addr.country_code.as_ref().map_or(false, |c| c != "US")
})
});
if is_international {
for method in &input.payment_methods {
if method.name.contains("Cash on Delivery") || method.name.contains("COD") {
operations.push(output::Operation::Hide(output::HideOperation {
payment_method_id: method.id.clone(),
}));
}
}
}
// Example: Reorder payment methods based on cart total
let cart_total: f64 = input.cart.cost.subtotal_amount.amount.parse().unwrap_or(0.0);
if cart_total >= 500.0 {
// Move "Pay Later" options to top for high-value orders
for method in &input.payment_methods {
if method.name.contains("Affirm") || method.name.contains("Klarna") {
operations.push(output::Operation::Move(output::MoveOperation {
payment_method_id: method.id.clone(),
index: 0,
}));
}
}
}
Ok(output::FunctionRunResult { operations })
}
Post-Purchase Extensions
Post-Purchase Upsell
// extensions/post-purchase/src/PostPurchase.tsx
extend,
render,
useExtensionInput,
BlockStack,
Button,
CalloutBanner,
Heading,
Image,
Text,
TextContainer,
Layout,
View,
} from "@shopify/post-purchase-ui-extensions-react";
extend("Checkout::PostPurchase::ShouldRender", async ({ inputData, storage }) => {
// Decide whether to show post-purchase page
const { initialPurchase } = inputData;
// Skip for orders under $50
const orderTotal = parseFloat(initialPurchase.totalPriceSet.shopMoney.amount);
if (orderTotal < 50) {
return { render: false };
}
// Fetch upsell offer from your backend
const response = await fetch("https://your-app.com/api/upsell", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
orderId: initialPurchase.referenceId,
lineItems: initialPurchase.lineItems,
}),
});
const { offer } = await response.json();
if (!offer) {
return { render: false };
}
// Store offer data for render phase
await storage.update({ offer });
return { render: true };
});
render("Checkout::PostPurchase::Render", () => <PostPur