
The name for recommending products from a specific collection under the order summary in Shopify Plus checkout is Checkout UI Extension. The setup documentation exceeds 80 pages, but those documents don't reveal where the real difficulty lies. This article shares 6 pitfalls learned over 18 deployments during a real implementation, complete with full code examples.
Ultimately, you'll have this working: an extension that pulls dynamic products from a Storefront collection, switches between an "Add" button and a quantity stepper, and is protected against double-click race conditions, all appearing directly below the order summary's total line. The merchant can configure which collection to use from the Shopify checkout editor without changing any code.
What We'll Build
The checkout upsell extension uses an API surface exclusive to Shopify Plus: Checkout UI Extensions. It's not possible to inject code into the checkout on standard Shopify plans. Therefore, extension development is limited to Shopify Plus or Shopify Developers Preview.
The component we'll build will look like this:
┌────────────────────────────────────────────────────────────┐
│ [thumb] Product Name │
│ ₺299,00 [−] 2 [+] │
│ Remove │
└────────────────────────────────────────────────────────────┘Three columns: a 64-pixel thumbnail, a flexible title + price block, and an "Add" button for products not in the cart, or a quantity stepper if it's already in the cart. Stack: Preact + JSX, Shopify checkout web components (<s-grid>, <s-stack>, <s-button>), Storefront API via shopify.query. No external CSS, no external state library. ~330 lines, single file.
Requirements and Setup
To develop a Checkout UI Extension:
- Shopify Plus or Partner development store
- Shopify CLI (npm install -g @shopify/cli)
- Node.js 18+
Project skeleton:
shopify app generate extension --type ui_extension --name checkout-upsell
This command creates shopify.extension.toml and src/Checkout.jsx. Initial configuration:
api_version = "2026-01"
type = "ui_extension"
name = "Checkout Upsell"
handle = "checkout-upsell"
[[targeting]]
module = "./src/Checkout.jsx"
target = "purchase.checkout.block.render"
[capabilities]
api_access = trueCritical: Without the api_access = true line, every call to the Storefront API silently fails. This is the easiest step to miss and the most confusing part of the setup.
Pitfall 1: api_access Capability - Mentioned Twice in the Docs
When you make your first call to the Storefront API with shopify.query(...), you'll see this error in the browser console:
ExtensionUsageError: Extension is not allowed to use the Storefront API;
permission to use the Storefront API must be specified under [capabilities]
with: "api_access = true"In the Shopify Partners dashboard, there's a toggle called "Network access." This toggle is for making requests to external URLs; for the Storefront API, a different capability is required. The two are not interchangeable.
What happens without an error message: useEffect silently fails, the extension renders with zero products, and no error appears on screen. The only signal is a console message; nothing appears broken at the UI level.
The solution is to add a single line to shopify.extension.toml:
[capabilities]
api_access = trueThis line enables the shopify.query call and, consequently, all GraphQL Storefront API queries. When creating a new extension, the first thing to do is add this line, then start coding.
Pitfall 2: Render Target Selection Directly Impacts Conversion Rate
Checkout extensions do not take pixel coordinates; they are injected into predefined targets in the DOM. Available targets in the order summary panel include:
Most Shopify tutorials use cart-line-list.render-after. In practice, this target creates a problem: the upsell banner pushes the order total out of the visible area. Customers want to see what they're paying before clicking the "Add" button; if the total is off-screen, this connection is broken.
purchase.checkout.block.render presents the extension as an independent App block in the checkout editor. The merchant drags this block from the left panel and places it "below the cost summary." The setup step is a single drag-and-drop; in return, you gain full placement control and flexibility for future changes.
In shopify.extension.toml, targeting is defined as follows:
[[targeting]]
module = "./src/Checkout.jsx"
target = "purchase.checkout.block.render"Pitfall 3: camelCase Requirement - Silent Failure
Shopify checkout extensions come as custom elements: <s-grid>, <s-stack>, <s-button>, <s-image>. In Preact JSX, the attributes for these elements appear to be writable in both kebab-case and camelCase. Only camelCase works.
The @shopify/ui-extensions/preact register directly binds JSX prop names to component property setters. Setters only respond to camelCase names; kebab-case attributes are not read, and the component falls back to its default.
// YANLIŞ — s-grid tek-kolon dikey block stack olarak render olur
<s-grid grid-template-columns="64px 1fr auto" align-items="center" gap="base">
...
</s-grid>
// DOĞRU — üç kolon, dikey ortalanmış
<s-grid gridTemplateColumns="64px 1fr auto" alignItems="center" gap="base">
...
</s-grid>No error message. Only the incorrect layout appears on screen. Affected props: gridTemplateColumns, alignItems, inlineSize, borderRadius, paddingBlock, paddingInline, labelAccessibilityVisibility, accessibilityLabel. If your checkout component "isn't taking its style," this is the first place to check.
Practical rule: When writing props for any new <s- component, first check that it's camelCase. There's no difference for single-word props (gap, border, background); this rule applies to every two-word prop.
Pitfall 4: s-button Does Not Expose the icon Prop on the Checkout Surface
For compact − / + stepper buttons, it seems logical to write icon="minus" and icon="plus" on an s-button. The result is an empty, bordered square.
On the checkout surface, s-button inherits only specific props from the base type:
// node_modules/@shopify/ui-extensions/.../checkout/components/Button.d.ts
export interface ButtonElementProps extends Pick<
ButtonProps$1,
| "accessibilityLabel"
| "disabled"
| "href"
| "id"
| "inlineSize"
| "loading"
| "target"
| "tone"
| "type"
| "variant"
> {}The icon is not included in this Pick<>. If you write it in JSX, the component won't read it, and nothing will render. This pattern applies to many components on the checkout surface: props that exist in the base type but are not exposed at the element level are silently swallowed.
The solution is to build the stepper button with s-clickable + s-icon:
function StepperButton({ icon, onClick, disabled, accessibilityLabel }) {
return (
<s-clickable
onClick={onClick}
disabled={disabled}
accessibilityLabel={accessibilityLabel}
border="base base"
borderRadius="small"
paddingBlock="small-200"
paddingInline="small-300"
background="base"
>
<s-icon type={icon} size="small" />
</s-clickable>
);
}Two more details:
border="base base" is the second base color value. If you only write border="base", only the thickness is assigned, a transparent 1px border renders, and nothing is visible. A color value must be added.
paddingBlock and paddingInline independently control their vertical/horizontal axes. This is useful for buttons that appear square but have rectangular content.
Pitfall 5: s-number-field Cannot Be Collapsed
Shopify's s-number-field component with the controls="stepper" property truly looks Polaris-quality: − and + controls within a single bordered box, keyboard support, and all accessibility features:
<s-number-field
label="Adet"
labelAccessibilityVisibility="exclusive"
controls="stepper"
min={1}
step={1}
value={String(quantity)}
onChange={handleChange}
/>Problem: this component has a minimum inline width and cannot be collapsed. The inlineSize="fit-content" prop does not work with s-number-field. When used on the same line as a 64px thumbnail and product title, the control expands to fill the entire line, breaking the layout.
For layouts where the thumbnail + title + quantity control are on the same line, use a custom stepper:
<s-grid gap="small-100" gridTemplateColumns="auto auto auto" alignItems="center">
<StepperButton icon="minus" onClick={onDecrement} ... />
<s-text type="strong">{String(quantity)}</s-text>
<StepperButton icon="plus" onClick={onIncrement} ... />
</s-grid>s-number-field is useful in wide interfaces where the quantity input takes its own line. In any layout where it needs to share a line, prefer a custom stepper.
Pitfall 6: Async Mutation Race Condition and Global busyId
applyCartLinesChange is an async operation. If a customer quickly clicks the + button twice, two updateCartLine calls will overlap, and the result might be an incorrect quantity. When multiple products are clicked consecutively across the strip, responses can interfere.
The pattern implemented by Nodus Works: a single busyId state at the top of the component. It only holds the variantId to indicate which product is being processed.
const [busyId, setBusyId] = useState(null);
async function runMutation(variantId, change) {
setBusyId(variantId);
setErrorMessage("");
try {
const result = await applyCartLinesChange(change);
if (result?.type === "error") setErrorMessage(result.message);
} catch {
setErrorMessage("Sepet güncellenemedi. Lütfen tekrar deneyin.");
} finally {
setBusyId(null);
}
}Each offer card takes two props:
const anyBusy = busyId !== null;
offers.map(({ product, variant }) => (
<OfferRow
isBusy={busyId === variant.id}
disabledOthers={anyBusy && busyId !== variant.id}
...
/>
));The CTA of the busy row shows loading={true}. All other rows are disabled. The customer sees which row is being processed, and two mutations cannot be fired simultaneously.
This pattern should not be skipped with the assumption, "There's only one button, so it's not needed." A second button for a stock alert, a campaign button, or a bundle suggestion could be added within a week, and at that point, the race condition would return as a hard-to-trace bug.
Writing the Collection Query Correctly
The Storefront API query is made via shopify.query and requires api_access = true. The collection handle and maximum number of products are read from merchant settings:
const PRODUCTS_QUERY = `
query CheckoutUpsell($handle: String!, $first: Int!) {
collection(handle: $handle) {
products(first: $first, sortKey: BEST_SELLING) {
nodes {
id title availableForSale
featuredImage { url altText }
variants(first: 1) {
nodes {
id availableForSale
price { amount currencyCode }
}
}
}
}
}
}
`;sortKey: BEST_SELLING brings the best-selling items of the collection to the top. You can change this sort key if the collection is set to "manually sorted" in the Shopify admin.
Settings configuration is defined as follows in shopify.extension.toml:
[settings]
[[settings.fields]]
key = "collection_handle"
type = "single_line_text_field"
name = "Koleksiyon handle"
description = "Upsell ürünleri çekilecek koleksiyonun handle'ı."
[[settings.fields]]
key = "max_products"
type = "number_integer"
name = "Maksimum ürün sayısı"
description = "1-12 arasında bir değer girin."The merchant updates these settings from the Shopify checkout editor, without changing any code.
Full Extension Code
The Checkout.jsx below is the complete solution to all six pitfalls mentioned above:
/** @jsxImportSource preact */
import "@shopify/ui-extensions/preact";
import { render } from "preact";
import { useEffect, useMemo, useState } from "preact/hooks";
const PRODUCTS_QUERY = `
query CheckoutUpsell($handle: String!, $first: Int!) {
collection(handle: $handle) {
products(first: $first, sortKey: BEST_SELLING) {
nodes {
id title availableForSale
featuredImage { url altText }
variants(first: 1) {
nodes { id availableForSale price { amount currencyCode } }
}
}
}
}
}
`;
export default function () {
render(<Extension />, document.body);
}
function Extension() {
const { applyCartLinesChange, query, i18n, lines, settings } = shopify;
const config = settings?.current || {};
const collectionHandle = (config.collection_handle || "upsell").toString();
const maxProducts = Math.min(Math.max(Number(config.max_products) || 6, 1), 12);
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState(null);
const [errorMessage, setErrorMessage] = useState("");
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
try {
const result = await query(PRODUCTS_QUERY, {
variables: { handle: collectionHandle, first: maxProducts },
});
if (!cancelled)
setProducts(result?.data?.collection?.products?.nodes ?? []);
} catch {
if (!cancelled)
setErrorMessage("Ürünler yüklenemedi.");
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [collectionHandle, maxProducts]);
const cartLines = lines?.value || [];
const lineByVariant = useMemo(() => {
const m = new Map();
for (const l of cartLines) m.set(l.merchandise.id, l);
return m;
}, [cartLines]);
const offers = useMemo(
() =>
products
.map((p) => ({ product: p, variant: p?.variants?.nodes?.[0] }))
.filter(
({ product, variant }) =>
product?.availableForSale !== false &&
variant?.availableForSale !== false,
),
[products],
);
async function runMutation(variantId, change) {
setBusyId(variantId);
setErrorMessage("");
try {
const result = await applyCartLinesChange(change);
if (result?.type === "error") setErrorMessage(result.message);
} catch {
setErrorMessage("Sepet güncellenemedi. Lütfen tekrar deneyin.");
} finally {
setBusyId(null);
}
}
if (loading) return <s-skeleton-text />;
if (offers.length === 0) return null;
const anyBusy = busyId !== null;
return (
<s-stack gap="large-200">
<s-divider />
<s-text type="strong">Siparişinize ekleyin</s-text>
{errorMessage ? (
<s-banner tone="critical">{errorMessage}</s-banner>
) : null}
<s-stack gap="base">
{offers.map(({ product, variant }) => {
const line = lineByVariant.get(variant.id) || null;
return (
<OfferRow
key={variant.id}
product={product}
variant={variant}
line={line}
isBusy={busyId === variant.id}
anyBusy={anyBusy}
i18n={i18n}
onAdd={() =>
runMutation(variant.id, {
type: "addCartLine",
merchandiseId: variant.id,
quantity: 1,
})
}
onSetQuantity={(next) => {
if (!line) return;
if (next <= 0) {
return runMutation(variant.id, {
type: "removeCartLine",
id: line.id,
quantity: line.quantity,
});
}
if (next === line.quantity) return;
return runMutation(variant.id, {
type: "updateCartLine",
id: line.id,
quantity: next,
});
}}
/>
);
})}
</s-stack>
</s-stack>
);
}
function OfferRow({ product, variant, line, isBusy, anyBusy, i18n, onAdd, onSetQuantity }) {
const price = variant?.price;
const formattedPrice = price?.amount
? i18n.formatCurrency(Number(price.amount), { currency: price.currencyCode })
: "";
const inCartQty = line?.quantity || 0;
const disabledOthers = anyBusy && !isBusy;
return (
<s-grid gap="base" gridTemplateColumns="64px 1fr auto" alignItems="center">
<s-image
borderWidth="base"
borderRadius="large-100"
src={product.featuredImage?.url}
alt={product.featuredImage?.altText || product.title}
aspectRatio="1"
/>
<s-stack gap="none">
<s-text type="strong">{product.title}</s-text>
{formattedPrice ? (
<s-text color="subdued">{formattedPrice}</s-text>
) : null}
</s-stack>
{line ? (
<QuantityActions
quantity={inCartQty}
isBusy={isBusy}
disabled={disabledOthers}
onIncrement={() => onSetQuantity(inCartQty + 1)}
onDecrement={() => onSetQuantity(inCartQty - 1)}
onRemove={() => onSetQuantity(0)}
/>
) : (
<s-button
variant="secondary"
loading={isBusy}
disabled={disabledOthers}
onClick={onAdd}
accessibilityLabel={`${product.title} ekle`}
>
Ekle
</s-button>
)}
</s-grid>
);
}
function QuantityActions({ quantity, isBusy, disabled, onIncrement, onDecrement, onRemove }) {
const stepDisabled = isBusy || disabled;
return (
<s-stack gap="small-100" alignItems="end">
<s-grid gap="small-100" gridTemplateColumns="auto auto auto" alignItems="center">
<StepperButton
icon="minus"
onClick={onDecrement}
disabled={stepDisabled}
accessibilityLabel="Adeti azalt"
/>
<s-text type="strong">{String(quantity)}</s-text>
<StepperButton
icon="plus"
onClick={onIncrement}
disabled={stepDisabled}
accessibilityLabel="Adeti artır"
/>
</s-grid>
<s-clickable
onClick={onRemove}
disabled={stepDisabled}
accessibilityLabel="Sepetten kaldır"
>
<s-text tone="critical">Kaldır</s-text>
</s-clickable>
</s-stack>
);
}
function StepperButton({ icon, onClick, disabled, accessibilityLabel }) {
return (
<s-clickable
onClick={onClick}
disabled={disabled}
accessibilityLabel={accessibilityLabel}
border="base base"
borderRadius="small"
paddingBlock="small-200"
paddingInline="small-300"
background="base"
>
<s-icon type={icon} size="small" />
</s-clickable>
);
}Deployment and Merchant Setup
To deploy the extension
shopify app deployAfter deployment is complete, the merchant follows these steps:
- Shopify admin → Online Store → Themes → Customize
- Select the checkout page
- In the "App blocks" section on the left panel, find "Checkout Upsell"
- Drag it to the "Order summary" section, place it below the "Cost summary"
- In the "Collection handle" field, enter the handle of the upsell collection
- Save
The collection handle is read from the last part of the URL in Shopify admin → Products → Collections → [Collection].
Frequently Asked Questions
Does Checkout UI Extension work without Shopify Plus? No. Injecting extensions into the checkout is only possible with Shopify Plus and Shopify Partner development stores. Checkout code cannot be modified on standard plans; this is a deliberate security restriction by Shopify.
Can I display products from multiple collections? Yes. You can add a second collection_handle field to settings.fields, make two separate queries, and combine the results. However, since you are making two separate API calls, you will need to adjust the useEffect dependency array and loading state accordingly.
Are Storefront API calls charged? The Storefront API is included in Shopify Plus pricing. There are no additional API costs per checkout extension. However, excessively high rate limit violations (thousands of requests per minute) can trigger throttling. Keeping the maxProducts value below 12 is sufficient.
The extension is not appearing in Analytics, why? For checkout extensions to use the purchase.checkout.block.render target, the merchant must have activated the App block in the editor. Simply deploying it is not enough; without being placed in the editor, the extension will not render and will not appear in Analytics.
Why isn't onClick working on s-button? On the checkout surface, the s-button's onClick prop is written directly as a prop, not within buttonProps. Additionally, onClick is not triggered on buttons where disabled={true}, which is expected behavior. Check the disabledOthers state.
The upsell product was added to the cart, but it doesn't seem to reflect instantly? lines?.value depends on Shopify's cart state reactivity. When the cartLines value in the useMemo dependency array is updated, the lineByVariant map is recalculated. If there's a delay, it originates from Shopify checkout's own cart state update cycle; there's nothing to be done on the extension side.
What should I do for Turkish price format? You can use i18n.formatCurrency(amount, { currency: "TRY" }). However, the currency comes from the store settings; changing the price.currencyCode value to a fixed "TRY" will cause issues in multi-currency stores. It is safer to use the price.currencyCode value.
Checklist: Pre-Deployment
Checking these seven points before every new checkout extension deployment is a shortcut to writing code that works on the first deploy:
- Is api_access = true present in shopify.extension.toml?
- Was purchase.checkout.block.render used as the target?
- Are all multi-word JSX props camelCase?
- Is the icon prop omitted where s-button is used?
- Is a custom stepper used instead of s-number-field in compact row layouts?
- Do all cart mutations go through a single busyId?
- Do border values contain color (e.g., "base base")?
This list is a distilled version of Nodus Works' 18 deployment experiences accumulated while developing checkout extensions for Shopify Plus stores. Each item on the list corresponds to at least one wasted deployment.
To consult with the Nodus Works technical team when planning your Shopify Plus checkout customization project, our Shopify Plus solutions page you can explore.




