Post-Purchase UI Extension
Component catalog, lifecycle contract, and sandbox rules for
@shopify/post-purchase-ui-extensions-react
(post-purchase upsell surface, npm
, package in maintenance — no newer version exists; the modern
checkout-extensions SDK has no post-purchase target as of writing).
⚠️ MANDATORY: Validate with tsc (do not skip)
Run after writing or editing any post-purchase JSX:
bash
cd extensions/<your-extension>
npx tsc --noEmit
TypeScript resolves types automatically via the bundled
at
node_modules/@shopify/post-purchase-ui-extensions-react/build/ts/index.d.ts
. If types fail twice on the same artifact, stop and surface the error to the user.
NEVER call validate_component_codeblocks
, validate_graphql_codeblocks
, or for this SDK. The Shopify Dev MCP doesn't index
@shopify/post-purchase-ui-extensions-react
. The validator's
polaris-checkout-extensions
enum value covers the modern
web-component SDK only and rejects every post-purchase component (
,
, …) as "not a Polaris web component."
⚠️ Skip if a different surface
- Admin App Home markup → use instead.
- Modern checkout extensions (
@shopify/ui-extensions-react
, web components) → use shopify-polaris-checkout-extensions
instead.
- Customer account extensions → use
shopify-polaris-customer-account-extensions
instead.
Doc lookup (WebFetch only)
The MCP doesn't index this SDK — use WebFetch:
- Component props:
https://shopify.dev/docs/api/checkout-extensions/post-purchase/components/<name>
(lowercase — PascalCase URLs return 404, e.g. works, does not)
- Lifecycle, , , , :
https://shopify.dev/docs/api/checkout-extensions/post-purchase/api
- End-to-end tutorials:
https://shopify.dev/docs/apps/build/checkout/product-offers/build-a-post-purchase-offer
and https://shopify.dev/docs/apps/build/checkout/product-offers/create-a-post-purchase-subscription
- UX guidance (only when working on copy, layout, or offer framing — not for API/prop questions):
https://shopify.dev/docs/apps/build/checkout/product-offers/ux-for-post-purchase-product-offers
and https://shopify.dev/docs/apps/build/checkout/product-offers/ux-for-post-purchase-subscriptions
If a component, prop, lifecycle field, or error code is missing from the
Component Catalog or
Lifecycle Contract below, WebFetch the canonical reference.
Rules
- Two extension points, two phases.
extend("Checkout::PostPurchase::ShouldRender", …)
is a data-prefetch hook; render("Checkout::PostPurchase::Render", App)
is the React mount. Render runs only if ShouldRender returned . See Lifecycle Contract.
- in ShouldRender; in Render. Storage is the only hand-off between the two phases — they run in separate JS contexts.
- takes a signed JWT string, not a Changeset object. Sign the changeset on your backend with the app's API secret, return the token to the extension, then call
await applyChangeset(token)
. Never sign client-side.
- Always call , including on error paths. Documented behavior: "indicates that the extension has finished running" and "redirects customers to the Order status page." Build-guide code samples call it in both accept and decline branches. Operational rule (not stated in shopify.dev): if an accept handler throws or rejects before runs, the buyer is stuck on a blank screen — wrap accept handlers in try/finally to guarantee the call.
- Treat as potentially called more than once per checkout. The shopify.dev pages do not document call frequency. Operational observation in production: it can fire on payment-page load and again after the buyer clicks Pay. Make the handler idempotent (backend dedupe of identical fetches keyed by ).
- Sandbox: no DOM, no CSS, no , no external scripts. All visual customization happens through component props. There is no , no , no inline . Spacing comes from prop tokens — but the scale differs per component (see Spacing scales).
- / extension required in every import. The Shopify CLI bundler does not auto-resolve.
import { X } from "./foo"
fails; import { X } from "./foo.jsx"
works.
- Only import what the SDK re-exports from . The runtime bundles its own React — importing additional React entry points causes duplicate-React errors. , , etc. work because they pass through.
- Validate with , not the MCP. See MANDATORY block above.
Common Patterns
Generic SDK patterns. Repo-specific architecture (layouts/templates/components, normalize functions, config/token systems) lives in the consuming repo's
, not here.
Boilerplate entry point
The two-phase contract — every post-purchase extension starts with this skeleton.
jsx
import { extend, render } from "@shopify/post-purchase-ui-extensions-react";
extend("Checkout::PostPurchase::ShouldRender", async ({ inputData, storage }) => {
const data = await fetchOffer(inputData); // your backend
if (!data) return { render: false };
await storage.update(data);
return { render: true };
});
render("Checkout::PostPurchase::Render", App);
function App({ storage, applyChangeset, done }) {
const offer = storage.initialData;
return <BlockStack spacing="loose">{/* … */}</BlockStack>;
}
Loading state on accept Button
has a built-in
prop — flip it on press to disable double-clicks during the sign-changeset round trip. No reset needed;
navigates away.
jsx
const [loading, setLoading] = useState(false);
<Button
loading={loading}
loadingLabel="Processing"
onPress={async () => {
setLoading(true);
const token = await signChangeset(variantId);
await applyChangeset(token);
done();
}}
>
Add to order
</Button>
Image with locked aspect ratio
Use
+
to prevent layout shift and align cards in a
grid when source images have varying intrinsic ratios.
jsx
<Image source={url} description={alt} aspectRatio={1} fit="cover" />
Heading semantics via HeadingGroup
Heading levels are derived from
nesting depth — never set
manually unless you need to override visuals.
jsx
<HeadingGroup>
<Heading>Section title</Heading>
<HeadingGroup>
<Heading>Subsection title</Heading>
</HeadingGroup>
</HeadingGroup>
Lifecycle Contract
Extension points
| Point | String | Purpose |
|---|
| ShouldRender | Checkout::PostPurchase::ShouldRender
| Data prefetch. Decide whether to render. |
| Render | Checkout::PostPurchase::Render
| Mount the React tree. |
ShouldRender API
ts
(api: PostPurchaseShouldRenderApi) => { render: boolean } | Promise<{ render: boolean }>
exposes
,
, plus
/
/
from the standard surface.
storage.update(data: any): Promise<void>
— persist data for the Render phase. Returns a Promise — it before returning.
- Return to mount, to skip silently. May return synchronously or as a .
Render API
ts
render("Checkout::PostPurchase::Render", (api: PostPurchaseRenderApi) => ReactElement)
| Field | Type | Use |
|---|
| | Same shape as ShouldRender. |
| | Data written by ShouldRender via . Read-only. |
| (changeset: Readonly<Changeset> | string) => Promise<CalculateChangesetResult>
| Preview cost impact without applying. Pass either a raw object or the signed JWT string. |
| (changeset: string, options?: ApplyChangesetOptions) => Promise<ApplyChangesetResult>
| Apply the order edit and charge the buyer. The parameter is a JWT string signed by your backend with the app secret — despite the name, this overload does not accept a raw object. |
| | Navigate to thank-you page. Always call this, success or error. |
| / / | from | Available alongside . |
InputData
| Field | Type |
|---|
| |
| (referenceId, customerId?, destinationCountryCode?, totalPriceSet, lineItems[]) |
| |
| (id: number, domain, metafields) |
| (JWT — pass to your backend for verification) |
| (current value: ) |
:
,
,
,
.
:
,
,
,
.
is
;
is
'integer' | 'string' | 'json_string'
.
Changeset shape
ts
Changeset { changes: Changes }
Changes = (AddVariantChange | AddShippingLineChange | SetMetafieldChange | AddSubscriptionChange)[]
ApplyChangesetOptions
| Option | Type | Use |
|---|
buyerConsentToSubscriptions?
| | Set when changes include ; pair with component. Optional in the type, but required by the server for subscription changes. |
ChangesetErrorCode
·
·
changeset_already_applied
·
unsupported_payment_method
·
·
·
·
subscription_vaulting_error
·
subscription_contract_creation_error
·
subscription_no_shipping_address_error
·
·
Component Catalog
29 components total, served by 28 doc URLs under
(lowercase).
shares the
page rather than having its own URL — when looking it up, fetch
.
All importable from
@shopify/post-purchase-ui-extensions-react
.
Spacing scales
There is no single "spacing scale" — three different scales coexist. Match the literal exactly to the consumer's
:
| Scale | Allowed values | Used by |
|---|
| Stack scale | 'xtight' | 'tight' | 'loose' | 'xloose'
| , , |
| Stack scale + | 'none' | 'xtight' | 'tight' | 'loose' | 'xloose'
| |
| Compact scale | 'none' | 'tight' | 'loose'
| , |
| View padding scale | 'extraTight' | 'tight' | 'base' | 'loose' | 'extraLoose'
| , |
/
and
/
are NOT interchangeable — each is rejected by the component that doesn't list it.
Layout & Structure
| Component | Purpose | Key Props / Gotchas |
|---|
| Vertical stack | : stack scale (no ). : 'leading' | 'center' | 'trailing'
. |
| Horizontal row | : stack scale. : 'leading' | 'center' | 'trailing' | 'baseline'
. |
| Pin first/last child to intrinsic size, fill middle | , . : stack scale. : 'leading' | 'center' | 'trailing' | 'baseline'
. |
| Equal-size grid, wraps and stacks responsively | . (px width below which tiles stack). : stack scale + . : 'leading' | 'center' | 'trailing' | 'baseline'
. Direct children stretch — wrap a child in to keep its intrinsic size. |
| Multi-section page scaffold with media-queried sizes | (≤1 = %, >1 = px). where Size = 'auto' | 'fill' | number
. where Media = { viewportSize: 'small' | 'medium' | 'large'; maxInlineSize?: number; sizes?: Size[] }
. inlineAlignment?: 'leading' | 'trailing'
. blockAlignment?: 'center' | 'trailing'
. No prop exists on despite appearing in some doc code samples — using it is a TS error. |
| Generic container that does NOT stretch | / : View padding scale ('extraTight' | 'tight' | 'base' | 'loose' | 'extraLoose'
). Note camelCase — NOT /. Use to opt out of / stretching. |
| Visual divider | : (default) / . : 'thin' | 'medium' | 'thick' | 'xthick'
. |
Typography
| Component | Purpose | Key Props / Gotchas |
|---|
| Section title | — visual override only; semantic level comes from nesting. strips semantics, keeps styling. |
| Increments heading level for nested children | No props. Wrap children that contain their own to bump them down a level semantically. |
| Inline styled text | size?: 'small' | 'medium' | 'large' | 'xlarge'
. , . . appearance?: 'critical' | 'warning' | 'success'
. : string or (use for strikethrough on original prices), or an object: { type: 'abbreviation'; for?: string }
, { type: 'directional-override'; direction: 'ltr' | 'rtl' }
(direction is required), { type: 'datetime'; machineReadable?: string }
. Inline only — wrap in or a stack to break to a new line. |
| Block-level paragraph | size?: 'small' | 'medium' | 'large' | 'xlarge'
. , . . appearance?: 'critical' | 'warning' | 'success'
. No prop. |
| Vertical spacing wrapper for text elements | spacing?: 'none' | 'tight' | 'loose'
(compact scale — / are NOT accepted here). alignment?: 'leading' | 'center' | 'trailing'
. |
Actions
| Component | Purpose | Key Props / Gotchas |
|---|
| Primary action | (optional in the type — provide if not using or ). (form submit). (renders as Link). (secondary look), (link-styled). + . . No / props — emphasis is via /. |
| Inline-stacked buttons with auto-spacing | No props. Wraps two or more s. |
| Navigation | and/or — provide at least one. opens in new tab. (target for accessibility-label associations). Not a button — use for actions. |
Forms
| Component | Purpose | Key Props / Gotchas |
|---|
| Form wrapper with implicit-submit-on-Enter | required. . implicitSubmit?: boolean | string
(string = a11y label for screen-reader-only submit button). No HTTP submission — handle in . |
| Vertical-stacked field layout | No props. Children stack on the block axis. |
| Inline-grouped fields within a | No props. Fields appear side-by-side with equal spacing. Lives in the same file as . |
| Single-line input | (required, doubles as placeholder when empty). , onChange?(value: string): void
(fires on commit/blur). onInput?(value: string): void
(fires every keystroke — drive controlled state from , not ). type?: 'text' | 'email' | 'number' | 'telephone'
. (form key). . (semantic only; does not auto-error). . . autocomplete?: Autocomplete | boolean
. tooltip?: { label: string; content: string }
. , . |
| Dropdown | (required). options: { value: string; label: string; disabled?: boolean }[]
. , onChange?(value: string): void
. . , . . (on the Select itself, not just options). . autocomplete?: Autocomplete | boolean
. |
| Boolean toggle | (preferred) or — takes precedence when both are set. onChange?(value: boolean): void
. , . , , accessibilityLabel?: string
. |
| Single radio button | (required — same groups options). / . onChange?(value: boolean): void
. . , accessibilityLabel?: string
. |
| Subscription consent checkbox | . (required), onChange(value: boolean): void
(required — unlike other form components). . Required when applying an change with applyChangeset(token, { buyerConsentToSubscriptions: true })
. |
Feedback & Status
| Component | Purpose | Key Props / Gotchas |
|---|
| Status / system message | . status?: 'info' | 'success' | 'warning' | 'critical'
(default ). , . For status reporting — not for promotional copy. |
| Promotional offer header | . background?: 'secondary' | 'transparent'
(default ). border?: 'none' | 'block'
(default ). alignment?: 'leading' | 'center' | 'trailing'
(default ). spacing?: 'none' | 'tight' | 'loose'
(compact scale — / are NOT accepted; default ). For limited-time-offer framing — distinct from . |
| Loading indicator | . . Children = a11y fallback for reduced-motion users. |
Media
| Component | Purpose | Key Props / Gotchas |
|---|
| Responsive image | (required). (alt; default ). sources?: { source: string; viewportSize?: 'small' | 'medium' | 'large'; resolution?: 1 | 1.3 | 1.5 | 2 | 2.6 | 3 | 3.5 | 4 }[]
for responsive variants — is a constrained numeric literal union, not any number. — sets height from width to prevent layout shift. fit?: 'cover' | 'contain'
(pair with to avoid stretch). loading?: 'eager' | 'lazy'
. , . |
Accessibility
| Component | Purpose | Key Props / Gotchas |
|---|
| Hide children from a11y tree but show visually | No props. Use for purely decorative or duplicated content. |
| Hide visually but keep available to screen readers | No props. Use for screen-reader-only labels. |