Developers
Start building
Themes/Product page: customize add to cart

Product page: customize add to cart

How the product page's buy box is built from blocks, and how a theme restyles or re-renders the add-to-cart area without touching commerce logic.

On this page

The add-to-cart area on the product page (quantity, the add button, express wallets, the pre-order line and Notify me) is the buy box. Like the rest of the product page it is built from blocks. The platform keeps the commerce behaviour behind it (adding to the cart, analytics events, wallet payments, bundles, error handling) behind one hook, useProductBuy from @poltura/theme-sdk/product. A theme can redraw every pixel of the buy box and still call that hook.

How the product page is composed

The product template is one product-information section with three regions, named by productRegions from @poltura/theme-sdk/product: media (the gallery column), details (beside the gallery) and below (the full-width area under both). The default theme's pages/product.json fills them with these blocks, and adds a related-products section under it:

Block typeRegionWhat it renders
product-gallerymediaThe image gallery
breadcrumbdetailsBreadcrumb trail
product-vendordetailsBrand or maker
product-titledetailsTitle
product-ratingdetailsReview score
pricedetailsPrice, compare-at price, discount, unit price, VAT note
variant-pickerdetailsOption / variant selection
inventorydetailsIn stock, running low, sold out, pre-order states
buy-buttonsdetailsThe buy box: quantity, add-to-cart, wallets, product-wishlist
delivery-infodetailsWhen it ships and arrives, with each carrier and price
product-highlightsdetailsHighlighted features
product-detailsdetailsPlatform, region, coverage
product-addonsdetailsAdd-on products, each with its own add to cart
product-tabsbelowDescription, specifications and the store's other tabs
product-safetybelowThe manufacturer's details and product safety information
product-reviewsbelowCustomer reviews and the average rating

The delivery box

delivery-info draws the delivery promise of the chosen variant: the order deadline (a countdown once it is under three hours away, the clock time before), the connected carriers with their day and price for the visitor's country, the free shipping threshold and a country picker. Everything comes from the store's delivery time settings, the carrier apps and the product's lead times, so a theme writes no delivery promise of its own. Its settings are style (compact or card), show_dispatch, show_carriers, carrier_limit (1 to 6), show_free_shipping, show_country, an optional instant_label heading and an optional note line under the box. The shipped themes set only style. Place the block under the buy buttons:

{ "id": "delivery", "type": "delivery-info", "slot": "details", "settings": { "style": "card" } }

Merchants reorder, add, hide and edit blocks in the Customizer. The gallery, title, price, variant picker, buy buttons and product safety blocks are static: a merchant can restyle them but cannot move or delete them. The variant picker, the buy buttons and product safety cannot be hidden either, and neither can the add-to-cart button inside the buy buttons. The gallery, title and price can be hidden.

Themes replace a platform block's markup with overrideBlock from @poltura/theme-sdk/elements, exported from a file under src/blocks. The platform keeps the block's schema, inspector and data, and draws the block's frame (merchant style, anchor, Customizer selection) itself. Settings arrive typed:

src/blocks/add-to-cart.tsx
import {
  overrideBlock,
  type ElementRenderProps,
  type PlatformBlockSettings,
} from '@poltura/theme-sdk/elements';
import { AddToCartButton, useProductBuy, type ProductBuy } from '@poltura/theme-sdk/product';

type AddToCartSettings = PlatformBlockSettings<'add-to-cart'>;

function labelFor(buy: ProductBuy, settings: AddToCartSettings): string {
  const status = (buy.variant ?? buy.product).availability.status;
  const custom = buy.isAdding
    ? settings.adding_label
    : status === 'preorder'
      ? settings.preorder_label
      : status === 'comingSoon'
        ? settings.coming_soon_label
        : status === 'unavailable'
          ? settings.sold_out_label
          : settings.label;
  return custom?.trim() || buy.ctaLabel;
}

function AddToCart({ settings }: ElementRenderProps<AddToCartSettings>) {
  const buy = useProductBuy();
  return (
    <div className='min-w-0 flex-1'>
      <AddToCartButton
        label={labelFor(buy, settings)}
        busy={buy.isAdding}
        disabled={!buy.canAdd || buy.isAdding}
        error={buy.error}
        fullWidth={settings.full_width}
        onAdd={() => void buy.add()}
      />
    </div>
  );
}

export const addToCart = overrideBlock('add-to-cart', AddToCart);

The same pattern applies to quantity, wallets, price, inventory and buy-buttons: one file per block type under src/blocks, exporting the overrideBlock result.

The buy hook: useProductBuy

Every part of the buy box reads one shared state from useProductBuy(). It works on the product page, under a product card and inside a ProductScope, and throws anywhere else.

FieldMeaning
productThe product being bought
variantThe chosen variant, or null while the option choice matches none
quantity, setQuantity(n)The quantity to add
canAddWhether the chosen variant can be added now; disable the button when false
isAddingPending state for the button
errorWhy the last add failed, in the page language, or null
add()The action. Call this, nothing else
ctaLabelThe button text for the state: add to cart, pre-order, coming soon or sold out
wallet.visibleWhether WalletButtons has express wallets to show here
wholesaleA wholesale buyer's tier prices for the chosen variant (WholesaleTiers), null otherwise
personalization, giftCardThe personalization texts and gift card details on the product page, null elsewhere
bundleThe bundle offered with the product and the shopper's choice in it, or null

The SDK ships the parts the platform buy box is built from, all in @poltura/theme-sdk/product:

PartWhat it draws
AddToCartButtonThe add button with the last error under it
QuantityStepperMinus, field and plus, keeping a typed amount within the min, step and max you pass
WalletButtonsApple Pay, Google Pay and the other express wallets; nothing off the product page
WholesaleTiersA wholesale buyer's tier price, list price and quantity breaks
PersonalizationFields, GiftCardFieldsThe texts a shopper fills in, and a gift card's amount, recipient and date
BundleSelectorThe bundle offer and its choices
AvailabilityBadge"Pre-order", "Coming soon" or "Sold out"; nothing for plain stock states
NotifyMeThe stock-notice form for a variant that cannot be bought now

add() adds buy.quantity, so a buy box of your own binds QuantityStepper to buy.quantity and buy.setQuantity. The SDK does not give a theme the variant's order rule (minimum, step, maximum), so your stepper enforces only the limits you pass it.

Pre-orders, coming soon and notify me

A variant can be on pre-order: bought now, delivered from an expected date. Its state is availability on the variant (and, as the best state of all its variants, on the product), a union keyed by status:

statusExtra fieldspurchasable
inStockquantity (when the store shows stock levels)always
backordernonealways
preorderexpected, remaining, messagewhile the pre-order has room
comingSoonopensAt, expectednever before orders open
unavailablereason: outOfStock, preorderFull, paused, restrictednever

Every state carries label, the status in the page language. expected.label is the promise already written for the page ("Q4 2026", "Date to be confirmed"), so a theme never formats the expected date itself.

StateButtonLine under the priceTile badge
preorder"Pre-order", enabledexpected date + message"Pre-order"
comingSoon"Coming soon", disabledopening date"Coming soon"
unavailable (preorderFull)"Sold out", disabled"Pre-order sold out""Sold out"
backorder, inStockunchangedunchangedunchanged
import { useFormat } from '@poltura/theme-sdk/i18n';
import { useProductBuy } from '@poltura/theme-sdk/product';

export function PreorderNote() {
  const { variant } = useProductBuy();
  const format = useFormat();
  const availability = variant?.availability;
  if (availability?.status === 'preorder') {
    return (
      <div className='flex flex-col gap-1 text-sm text-muted-foreground'>
        <p>{availability.expected.label}</p>
        {availability.message ? <p>{availability.message}</p> : null}
      </div>
    );
  }
  if (availability?.status === 'comingSoon') {
    return <p className='text-sm text-muted-foreground'>{format.date(availability.opensAt)}</p>;
  }
  return null;
}

Label settings of the buy box blocks (declare per-theme defaults in pages/product.json):

BlockSettingMeaning
add-to-cartpreorder_labelButton text of a variant on pre-order
add-to-cartcoming_soon_labelDisabled button text before pre-orders open
add-to-cartsold_out_labelDisabled button text of a variant that cannot be bought
inventorybackorder_labelStock line of a variant that ships later
inventoryshow_preorder_remainingShow "Only N left" (default off; needs the store's "Show remaining")

Notify me. When the store offers stock notices and the chosen variant cannot be bought yet, the buy-buttons block draws NotifyMe under the buttons. To place it elsewhere, render <NotifyMe variantId={variant.id} reason='outOfStock' /> (or comingSoon, preorderFull). It handles its own request, captcha, consent text and states. useNotifyMe backs a form of your own, so a theme never calls the stock notice API itself.

On the product page the buy-buttons block also draws the price gate in place of the buttons when prices are hidden from the visitor, and the pre-order date and terms, so a page whose price or stock block is hidden still tells the shopper why they cannot buy.

For product tiles, <AvailabilityBadge availability={product.availability} /> renders the "Pre-order", "Coming soon" and "Sold out" badge, and variant.availability is the full state for a variant outside the buy box. Read availability, never stock levels: a variant on an open pre-order can be ordered with no stock, and after release it stays closed until the pre-orders are fulfilled.

A minimal custom buy box

Overriding buy-buttons replaces the whole row and its children:

src/blocks/buy-buttons.tsx
import {
  overrideBlock,
  type ElementRenderProps,
  type PlatformBlockSettings,
} from '@poltura/theme-sdk/elements';
import {
  AddToCartButton,
  NotifyMe,
  QuantityStepper,
  WalletButtons,
  useProductBuy,
  type Availability,
  type NotifyMeReason,
} from '@poltura/theme-sdk/product';
import { Price, PriceGate, usePriceAccess } from '@poltura/theme-sdk/price';
import { Card, cn } from '@poltura/theme-sdk/ui';

function notifyReason(availability: Availability | null): NotifyMeReason | null {
  if (availability?.status === 'comingSoon') return 'comingSoon';
  if (availability?.status !== 'unavailable') return null;
  if (availability.reason === 'outOfStock' || availability.reason === 'preorderFull') {
    return availability.reason;
  }
  return null;
}

function BuyButtons({ settings }: ElementRenderProps<PlatformBlockSettings<'buy-buttons'>>) {
  const buy = useProductBuy();
  const access = usePriceAccess();
  const reason = notifyReason(buy.variant?.availability ?? null);

  if (!access.visible) {
    return (
      <div className='text-base font-semibold'>
        <PriceGate className='underline underline-offset-2' />
      </div>
    );
  }

  return (
    <Card className='flex flex-col gap-4 rounded-xl p-6'>
      <Price price={(buy.variant ?? buy.product).price} className='text-4xl font-black' />
      <div className={cn('flex gap-2', settings.layout === 'stacked' ? 'flex-col' : 'flex-row')}>
        <QuantityStepper value={buy.quantity} onChange={buy.setQuantity} />
        <AddToCartButton
          label={buy.ctaLabel}
          busy={buy.isAdding}
          disabled={!buy.canAdd || buy.isAdding}
          error={buy.error}
          fullWidth
          onAdd={() => void buy.add()}
        />
      </div>
      {buy.wallet.visible ? <WalletButtons /> : null}
      {buy.variant && reason ? <NotifyMe variantId={buy.variant.id} reason={reason} /> : null}
    </Card>
  );
}

export const buyButtons = overrideBlock('buy-buttons', BuyButtons);

A buy-buttons override draws no personalization, gift card or bundle fields unless it places PersonalizationFields, GiftCardFields and BundleSelector itself. It also replaces the merchant's quantity, add-to-cart, wallets and product-wishlist blocks inside it. Save in the code editor to see it in the editor's Preview, and publish to put it live.

The variant picker

The variant picker follows the same pattern under its own type. The platform holds the selection and shares it with every block of the product. Keep no selection state of your own, or the picker, the buy box and the variant analytics event disagree:

src/blocks/variant-picker.tsx
import {
  overrideBlock,
  type ElementRenderProps,
  type PlatformBlockSettings,
} from '@poltura/theme-sdk/elements';
import { VariantOption, VariantOptionValue, useVariantPicker } from '@poltura/theme-sdk/product';

function Picker({ settings }: ElementRenderProps<PlatformBlockSettings<'variant-picker'>>) {
  const picker = useVariantPicker();
  if (picker.options.length === 0) return null;
  return (
    <div className='flex flex-col gap-4'>
      {picker.options.map((option) => (
        <VariantOption
          key={option.id}
          option={option}
          display={settings.style === 'dropdown' ? 'dropdown' : option.display}
          showTitle={settings.show_option_name}
        >
          {option.values.map((value) => (
            <VariantOptionValue
              key={value.id}
              value={value}
              onSelect={() => picker.select(option.id, value.id)}
            />
          ))}
        </VariantOption>
      ))}
    </div>
  );
}

export const variantPicker = overrideBlock('variant-picker', Picker);

Each value's selectable says whether choosing it can still lead to a variant that can be bought, and availability is the state of the variant it completes. Use them for disabled and dimmed options. <VariantPicker /> is the complete platform picker, for when you only need to restyle it through classes.

Moving it: regions and the section

Restyling a block changes its pixels. To put the buy box somewhere else entirely, try these in order:

Merchants can already move blocks

Merchants drag blocks between the media, details and below regions in the Customizer's block tree. The section's settings already swap the columns (gallery_position), set the media width (media_width) and keep the details column in view (sticky_details). Check whether the layout you want is a drag away before you write code.

Own the section layout

For a different arrangement, override the product-information section and place the regions yourself. The default theme does this in src/sections/product-information.tsx. A shorter version:

src/sections/product-information.tsx
import {
  Slot,
  overrideSection,
  type ElementRenderProps,
  type PlatformSectionSettings,
} from '@poltura/theme-sdk/elements';
import { BundleSelector, productRegions } from '@poltura/theme-sdk/product';
import { cn } from '@poltura/theme-sdk/ui';

function ProductInformation({
  settings,
}: ElementRenderProps<PlatformSectionSettings<'product-information'>>) {
  return (
    <div className='bg-background'>
      <div className='container mx-auto flex flex-col gap-10 px-4 py-8'>
        <div className='grid grid-cols-1 gap-10 lg:grid-cols-2'>
          <Slot
            name={productRegions.media}
            className={cn('flex min-w-0 flex-col gap-4', settings.gallery_position === 'right' && 'lg:order-2')}
          />
          <Slot name={productRegions.details} className='flex min-w-0 flex-col gap-5' />
        </div>
        <BundleSelector placement='below' />
        <Slot name={productRegions.below} className='flex min-w-0 flex-col gap-8' />
      </div>
    </div>
  );
}

export const productInformation = overrideSection('product-information', ProductInformation);

The merchant's blocks keep rendering inside your Slots, and the page stays editable in the Customizer.

Lighter touches

  • Tokens only: the default buy box is built on --primary, --card, --border, --muted and --radius. The Customizer's colours and tokens.css restyle it without code.
  • Copy: merchants edit the button labels in the Customizer. A theme changes the translated defaults by overriding platform text keys in its texts/<language>.json.
  • Around the page: the platform draws the product.above and product.below app slots above and below the product template, so installed apps add banners or content there without the theme placing anything.

What the platform keeps

The cart change behind add(), wallet payments, bundle pricing, availability and the analytics events stay with the platform. Your block owns the markup, and useProductBuy owns the money path. That split is what lets a redesigned buy box keep working through platform updates.

Next

Last updated