Ezgi Hocaoğlu

Ezgi Hocaoğlu 👋

Frontend developer 🖥️ building e-commerce front-ends with Shopify, React & WordPress — 5 years of custom storefronts, widgets and checkout flows.

Ezgi Hocaoğlu — CV

PDF · one page

Preview not loading? Open the file in a new tab instead.

Currently on repeat

DISCOUNT

Hostinger

See discounted plans
Conversion article cover
Conversion
  • 6 min read

Post-Purchase Upsells That Do Not Hurt Conversion

The offer shown right after payment is the cheapest one a store will ever make. The buyer has already decided to trust you, the payment method is on file, and adding an item costs them one tap. It is also the easiest place to annoy someone who just gave you money.

How the extension actually works

A Shopify post-purchase extension runs between the payment and the thank-you page. It has two jobs. The first decides whether to render at all, and it should be quick and conservative:

import { extend, render } from "@shopify/post-purchase-ui-extensions-react";

extend("Checkout::PostPurchase::ShouldRender", async ({ inputData, storage }) => {
  const offer = await fetch(`${API}/offer?order=${inputData.initialPurchase.referenceId}`, {
    headers: { Authorization: `Bearer ${inputData.token}` },
  }).then((r) => r.json());

  if (!offer) return { render: false };

  await storage.update(offer);
  return { render: true };
});

The second renders the offer and, if accepted, applies a changeset to the existing order — no second checkout, no card details entered again:

render("Checkout::PostPurchase::Render", () => <Offer />);

function Offer() {
  const { storage, inputData, applyChangeset, done } = useExtensionInput();
  const [loading, setLoading] = useState(false);

  async function accept() {
    setLoading(true);
    const { changeset } = await fetch(`${API}/sign-changeset`, {
      method: "POST",
      body: JSON.stringify({ referenceId: inputData.initialPurchase.referenceId,
                             variantId: storage.data.variantId }),
    }).then((r) => r.json());

    await applyChangeset(changeset);   // server-signed, never built in the browser
    done();
  }

  return (
    <BlockStack spacing="loose">
      <Heading>Add {storage.data.title} to this order</Heading>
      <TextBlock>Ships together, no extra delivery cost.</TextBlock>
      <Button onPress={accept} loading={loading}>Add for {storage.data.price}</Button>
      <Button subdued onPress={done}>No thanks</Button>
    </BlockStack>
  );
}

The changeset must be signed on your server. That is not a formality — it is what stops the price being rewritten in the browser.

The rules that keep conversion intact

  • One offer. Not a funnel. The second screen is where people start pressing the back button.
  • Related and cheaper. A refill, an accessory, a size up. Roughly a third of the order value is a sensible ceiling.
  • No new payment details. If the flow asks for a card again, you have lost the only advantage this placement has.
  • Render fast. If the offer is not ready in a second, skip it. A spinner between payment and confirmation reads as a failed order.
  • An obvious decline. "No thanks" must be as easy to find as the accept button, or the support tickets will follow.

Judge the offer on whether it would still be a good recommendation if nobody paid you for it. Buyers can tell the difference, and so can the refund rate.

Measure more than attach rate

Attach rate alone will make a bad offer look like a good one. Watch the return rate on upsold items, support contacts mentioning the extra product, and repeat purchase rate for buyers who accepted versus those who did not. An upsell that lifts one order and costs you the next one is a loss that takes a quarter to show up.

Filed under Conversion
All articles