post-purchase-extension
Post-Purchase Extension
Component catalog, lifecycle contract, and sandbox rules for @shopify/post-purchase-ui-extensions-react (post-purchase upsell surface, npm 0.13.5, package in maintenance — no newer version exists; the modern <s-*> 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:
cd extensions/<your-extension>
npx tsc --noEmit
TypeScript resolves types automatically via the bundled .d.ts 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 search_docs_chunks 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 @shopify/ui-extensions web-component SDK only and rejects every post-purchase component (BlockStack, Button, …) as "not a Polaris web component."
⚠️ Skip if a different surface
- Admin App Home
<s-*>markup → useshopify-polaris-app-homeinstead. - Modern checkout extensions (
@shopify/ui-extensions-react,<s-*>web components) → useshopify-polaris-checkout-extensionsinstead. - Customer account extensions → use
shopify-polaris-customer-account-extensionsinstead.
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> - Lifecycle,
useExtensionInput,Changeset,InputData,ChangesetErrorCode: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-offerandhttps://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-offersandhttps://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{ render: true }. See Lifecycle Contract. storage.update(data)in ShouldRender;storage.initialDatain Render. Storage is the only hand-off between the two phases — they run in separate JS contexts.applyChangesettakes 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 callawait applyChangeset(token). Never sign client-side.- Always call
done(), including on error paths. Documented behavior:done()"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 beforedone()runs, the buyer is stuck on a blank screen — wrap accept handlers in try/finally to guarantee the call. - Treat
ShouldRenderas 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 byreferenceId). - Sandbox: no DOM, no CSS, no
window, no external scripts. All visual customization happens through component props. There is no<style>, noclassName, no inlinestyle={…}. Spacing comes from prop tokens (xtight/tight/loose/xlooseon stack containers). .jsx/.jsextension 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
"react". The runtime bundles its own React — importing additional React entry points causes duplicate-React errors.useState,useEffect, etc. work because they pass through. - Validate with
tsc, 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 architecture.md, not here.
Boilerplate entry point
The two-phase contract — every post-purchase extension starts with this skeleton.
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
Button has a built-in loading prop — flip it on press to disable double-clicks during the sign-changeset round trip. No reset needed; done() navigates away.
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 aspectRatio + fit="cover" to prevent layout shift and align cards in a Tiles grid when source images have varying intrinsic ratios.
<Image source={url} description={alt} aspectRatio={1} fit="cover" />
Heading semantics via HeadingGroup
Heading levels are derived from HeadingGroup nesting depth — never set level manually unless you need to override visuals.
<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
({ inputData, storage }) => Promise<{ render: boolean }>
inputData: InputData— order context (see below).storage.update(data)— persist data for the Render phase.- Return
{ render: true }to mount,{ render: false }to skip silently.
Render API
({ inputData, storage, calculateChangeset, applyChangeset, done }) => JSX
| Field | Type | Use |
|---|---|---|
inputData |
InputData |
Same shape as ShouldRender. |
storage.initialData |
unknown |
Data written by ShouldRender via storage.update. Read-only. |
calculateChangeset |
(changeset | signedToken) => Promise<CalculateChangesetResult> |
Preview cost impact without applying. Optional — useful for "Total: $X" lines. |
applyChangeset |
(signedToken: string, options?: ApplyChangesetOptions) => Promise<ApplyChangesetResult> |
Apply the order edit and charge the buyer. Token must be JWT-signed by your backend with the app secret. |
done |
() => Promise<void> |
Navigate to thank-you page. Always call this, success or error. |
InputData
| Field | Type |
|---|---|
extensionPoint |
string |
initialPurchase |
Purchase (referenceId, customerId?, totalPriceSet, lineItems[]) |
locale |
string |
shop |
Shop (id, domain, metafields) |
token |
string (JWT — pass to your backend for verification) |
version |
string |
Changeset shape
Changeset { changes: Change[] }
Change = AddVariantChange | AddShippingLineChange | SetMetafieldChange | AddSubscriptionChange
ApplyChangesetOptions
| Option | Type | Use |
|---|---|---|
buyerConsentToSubscriptions |
boolean |
Required when changes include add_subscription; pair with BuyerConsent component. |
ChangesetErrorCode
payment_required · insufficient_inventory · changeset_already_applied · unsupported_payment_method · invalid_request · server_error · buyer_consent_required · subscription_vaulting_error · subscription_contract_creation_error · subscription_no_shipping_address_error · subscription_limit_error · order_released_error
Component Catalog
29 components total. All importable from @shopify/post-purchase-ui-extensions-react.
Spacing prop scale (used by BlockStack, InlineStack, Bookend, Tiles, TextContainer): none / xtight / tight / loose / xloose (not all components accept none — see notes). View uses a different scale: extraTight / tight / base / loose / extraLoose.
Layout & Structure
| Component | Purpose | Key Props / Gotchas |
|---|---|---|
BlockStack |
Vertical stack | spacing: xtight/tight/loose/xloose (no none). alignment: leading/center/trailing. |
InlineStack |
Horizontal row | Same spacing as BlockStack. alignment: leading/center/trailing/baseline. |
Bookend |
Pin first/last child to intrinsic size, fill middle | leading?: boolean, trailing?: boolean. spacing, alignment. |
Tiles |
Equal-size grid, wraps and stacks responsively | maxPerLine?: number. breakAt?: number (px width below which tiles stack). spacing: includes none. Direct children stretch — wrap a child in View to keep its intrinsic size. |
Layout |
Multi-section page scaffold with media-queried sizes | maxInlineSize?: number (≤1 = %, >1 = px). sizes?: Size[] (per section). media?: Media[] (responsive overrides). inlineAlignment/blockAlignment. |
View |
Generic container that does NOT stretch | inlinePadding / blockPadding: extraTight/tight/base/loose/extraLoose. Use to opt out of Tiles/Layout stretching. |
Separator |
Visual divider | direction: horizontal (default) / vertical. width: thin / medium / thick / xthick. |
Typography
| Component | Purpose | Key Props / Gotchas |
|---|---|---|
Heading |
Section title | level?: 1 | 2 | 3 — visual override only; semantic level comes from HeadingGroup nesting. role="presentation" strips semantics, keeps styling. |
HeadingGroup |
Increments heading level for nested children | No props. Wrap children that contain their own Heading to bump them down a level semantically. |
Text |
Inline styled text | size: small/medium/large/xlarge. emphasized, subdued. role: address/deletion/abbreviation/directional-override/datetime (use deletion for strikethrough on original prices). Inline only — wrap in TextBlock or stack to break to a new line. |
TextBlock |
Block-level paragraph | Same size/emphasized/subdued as Text. appearance: critical/warning/success. |
TextContainer |
Vertical spacing wrapper for text elements | spacing: none/tight/loose. alignment: leading/center/trailing. |
Actions
| Component | Purpose | Key Props / Gotchas |
|---|---|---|
Button |
Primary action | onPress(): void. submit?: boolean (form submit). to?: string (renders as Link). subdued?: boolean (secondary look), plain?: boolean (link-styled). loading?: boolean + loadingLabel?: string. disabled?: boolean. No variant/tone props — emphasis is via subdued/plain. |
ButtonGroup |
Inline-stacked buttons with auto-spacing | No props. Wraps two or more Buttons. |
Link |
Navigation | to?: string and/or onPress?(): void — must provide at least one. external?: boolean opens in new tab. Not a button — use Button for actions. |
Forms
| Component | Purpose | Key Props / Gotchas |
|---|---|---|
Form |
Form wrapper with implicit-submit-on-Enter | onSubmit(): void required. disabled?: boolean. implicitSubmit?: boolean | string (string = a11y label for screen-reader-only submit button). No <form> HTTP submission — handle in onSubmit. |
FormLayout |
Vertical-stacked field layout | No props. Children stack on the block axis. |
FormLayoutGroup |
Inline-grouped fields within a FormLayout |
No props. Fields appear side-by-side with equal spacing. |
TextField |
Single-line input | label: string (required, doubles as placeholder when empty). value, onChange(value: string). type: text/email/number/telephone. name (form key). |
Select |
Dropdown | label: string (required). options: { value, label, disabled? }[]. value, onChange(value: string). placeholder. |
Checkbox |
Boolean toggle | checked (preferred) or value: boolean. onChange(checked: boolean). disabled?, error?: string. |
Radio |
Single radio button | name: string (required — same name groups options). checked/value. onChange. |
BuyerConsent |
Subscription consent checkbox | policy: "subscriptions". checked: boolean, onChange(value: boolean). error?: string. Required when applying an add_subscription change with applyChangeset({ buyerConsentToSubscriptions: true }). |
Feedback & Status
| Component | Purpose | Key Props / Gotchas |
|---|---|---|
Banner |
Status / system message | title?: string. status: info (default) / success / warning / critical. collapsible?, iconHidden?. For status reporting — not for promotional copy. |
CalloutBanner |
Promotional offer header | title?: string. background: secondary (default) / transparent. border: block (default) / none. alignment: leading/center (default)/trailing. spacing: none/tight/loose. For limited-time-offer framing — distinct from Banner. |
Spinner |
Loading indicator | size: small / large. color: inherit. Children = a11y fallback for reduced-motion users. |
Media
| Component | Purpose | Key Props / Gotchas |
|---|---|---|
Image |
Responsive image | source: string (required). description?: string (alt). sources?: { source, viewportSize?, resolution? }[] for responsive variants. aspectRatio?: number — sets height from width to prevent layout shift. fit: cover / contain (pair with aspectRatio to avoid stretch). loading: eager / lazy. bordered?, decorative?. |
Accessibility
| Component | Purpose | Key Props / Gotchas |
|---|---|---|
HiddenForAccessibility |
Hide children from a11y tree but show visually | No props. Use for purely decorative or duplicated content. |
VisuallyHidden |
Hide visually but keep available to screen readers | No props. Use for screen-reader-only labels. |
More from preetamnath/agent-skills
shopify-dev-mcp
Routes Shopify Dev MCP calls for surfaces NOT covered by the bundled Shopify skills: `storefront-graphql`, `customer`, `partner`, `payments-apps`, `functions`, `hydrogen`, `liquid`, `custom-data`. SKIP for Admin GraphQL or App Home markup — the bundled `shopify-admin` and `shopify-polaris-app-home` skills cover those. SKIP entirely for `@shopify/post-purchase-ui-extensions-react` — the MCP doesn't index that legacy SDK; use `post-purchase-ui-extension` instead.
15plan-runner
Executes wave-grouped markdown plans via parallel subagents. Orchestrates implementation, per-wave review, fix cycles, and final two-pass-review. Resumable across conversations.
13interview-me
Move from ambiguity to clarity before building. Use when user says 'interview me', asks to be interviewed, or the task has ambiguous scope.
13sentry-analysis
Analyze Sentry error logs, breadcrumbs, and codebase context to diagnose and explain the root cause of issues.
13code-review
Structured code review with P0-P3 findings, confidence scores, and criteria-based analysis. Use for reviewing code changes, PRs, or specific files.
12plan-builder
Creates dependency-ordered, wave-grouped executable plans from a goal + context. Produces markdown plans with parallel execution waves compatible with plan-runner. Use when you need to break a goal into sequenced, atomic work items before execution.
10