Build a post-purchase upsell app backend with Gadget

Time to build: ~1 hour

Technical Requirements

Before starting this tutorial you need the following:

Post-purchase upsell applications are a great way for merchants to increase sales by presenting shoppers with a one-click option to add additional items to their purchases. There are countless different strategies to pick between when determining what products a merchant wants to push for an upsell opportunity: it might be products that are related or frequently bought together with the products that the customer has already bought, it could be an item that the merchant is particularly interested in selling, or it could just be an item that is on sale.

An example of a post-purchase upsell page presented to a customer

The logic required to determine what products are presented to users is the interesting part of a post-purchase upsell application. But to get to the point where you are writing this business logic code, you need to go through the process of setting up a server and ensuring that it will be able to scale to handle potentially high traffic.

Using Gadget, this tutorial shows you how to skip the boilerplate server work and brings you directly to writing your post-purchase upsell logic.

You can fork this Gadget project and try it out yourself.

You still need to set up the Shopify Post-purchase Extension, add environment variables, and complete the application frontend.

Fork on Gadget

Set up Shopify App Bridge Extension

Good news! Shopify already has a tutorial that covers setting up a new frontend application using the Shopify App Bridge Extension.

You should go through the entire first page of their tutorial. It will help you set up a front-end application on a development store and allows you to inject a post-purchase upsell page into your checkout workflow.

Fixes to the Shopify tutorial

When running your app locally, you need to start your app with a Cloudflare tunnel like so: yarn dev --tunnel-url https://my-tunnel-url:port. You must include a port, and the default Cloudflare tunnel port is 8080.

The index.jsx code file that Shopify creates may require an additional import statement. If you see any React errors in your web console, adding import React from "react"; at the top of your code file should be the fix!

Once your Shopify CLI app is up and running, Shopify gives you a button in your store Admin to add test products to your store automatically. Add some products!

The default Shopify CLI app embedded in a store Admin page. It gives you a button to add test products

You should also make sure your products are enabled and visible in your store, and that some products are being offered at a discounted price. To offer some products at a discount, fill in the Compare at price and Price fields on a product. You can also add some test products manually.

Do not proceed until you have the frontend of Shopify's post-purchase extension demo working!

Once you have completed the first portion of the Shopify tutorial, move on to the Upsell example. Your Gadget app will replace the Node server that you set up in Part 2: Build an app server.

Diagram of the tech stack used in this tutorial. Gadget handling sync and custom routing, Shopify handling the store and frontend

This Gadget application will sync product data with your development store. This product data will be used along with custom routing endpoints to determine what product will be offered to a shopper.

Create a new Shopify Connection

You will want the development store's Product Variant data in your Gadget app, so you need to set up a new Shopify Connection.

Connect to Shopify through the Partners dashboard


To complete this connection, you will need a Shopify Partners account as well as a store or development store

Our first step is going to be setting up a custom Shopify application in the Partners dashboard.

Both the Shopify store Admin and the Shopify Partner Dashboard have an Apps section. Ensure that you are on the Shopify Partner Dashboard before continuing.

Click on Apps link in Shopify Partners Dashboard
  • Click the Create App button
Click on Create app button
  • Click the Create app manually button and enter a name for your Shopify app
Shopify's app creation landing page in the Partners Dashboard
  • Go to the Connections page in your Gadget app
The Gadget homescreen, with the Connections link highlighted
  • Copy the Client ID and Client secret from your newly created Shopify app and paste the values into the Gadget Connections page
  • Screenshot of the Partners card selected on the Connections page
  • Click Connect on the Gadget Connections page to move to scope and model selection

Now we get to select what Shopify scopes we give our application access to, while also picking what Shopify data models we want to import into our Gadget app.

You require the Product Read and Write scopes, and want to import the Product, Product Image, and Product Variant models.

Gadget's scope selection page, with the Product read/write scopes selected, along wiht the Product, Product Image, and Product Variant models

We have successfully created our connection!

Now we want to connect our Gadget app to our custom app in the Partners dashboard.

  • In your Shopify app in the Partners dashboard, click on App setup in the side nav bar so you can edit the App URL and Allowed redirection URL(s) fields
  • Copy the App URL and Allowed redirection URL from the Gadget Connections page and paste them into your custom Shopify App
  • Screenshot of the connected app, with the App URL and Allowed redirection URL(s) fields

Set up Gadget routing

Shopify's upsell tutorial has you set up a local Node server to test out the post-purchase upsell frontend. You will replace this server with a Gadget app!

There are 3 things you need to add to your Gadget app to get it up and running:

  • a custom route to handle the /offer request
  • a custom route to handle the /sign-changeset request
  • custom configuration to handle CORS

Custom route for /offer

The first thing you want to do is set up the /offer route. This route will accept a payload from the frontend app, determine what product will be presented as part of the post-purchase upsell, and then return the product variant information required to render on the frontend.

Go to the Gadget file explorer and add a new file in the routes folder.

The button you can use to create a new route file in Gadget

You can then rename the auto-generated file to POST-offer.js. Custom routes in Gadget need to be prepended with one of the accepted Fastify request types. The remainder of the file name is the path of the route! You can read more information about creating custom routes in the Gadget documentation.

You are going to pass information from the post-purchase Shopify App Bridge Extension to our Gadget app. This information includes the line items the customer is purchasing, as well as the total price of the order. You can use this information to determine what kind of offer you want to present to the customer. For this tutorial, you will select a random product variant that is not included as part of the original purchase and offer it to the customer at a discount.

Paste the following code snippet into your POST-offer.js file.

2 * Route handler for GET
3 *
4 * @param { import("gadget-server").Request } request - incoming request data
5 * @param { import("gadget-server").Reply } reply - reply for customizing and sending a response
6 *
7 * @see {@link}
8 * @see {@link}
9 */
11module.exports = async (request, reply) => {
12 const { body } = request;
13 if (body) {
14 // get the purchase information from the Shopify App Bridge Extension
15 const { inputData } = body;
17 // get ids of variants that are in the purchase
18 const purchaseIds =
19 (lineItem) =>
20 );
22 // use Gadget's findMany api and filter for variants that are not included in the shoppers original purchase
23 let variants = await request.api.shopifyProductVariant.findMany({
24 filter: {
25 id: {
26 notIn: purchaseIds,
27 },
28 },
29 });
31 // only present an offer if there are variants that meet the criteria (cheaper than initial purchase and on sale)
32 if (variants.length > 0) {
33 // pick a random variant from the list
34 const randomVariant = Math.floor(Math.random() * (variants.length - 1));
35 const variant = variants[randomVariant];
36 const { product } = variant;
38 // get single image for product to be presented on upsell page
39 const productImages = await request.api.shopifyProductImage.findMany({
40 select: {
41 source: true,
42 },
43 filter: {
44 product: {
45 equals: product?.id,
46 },
47 },
48 });
50 // grab first image for this product
51 let productImage = productImages[0];
53 // discount original price by 15% if there isn't a compareAtPrice available
54 const discountedPrice = variant.compareAtPrice
55 ? variant.price
56 : variant.price
57 ? (parseFloat(variant.price) * 0.85).toFixed(2)
58 : "No discount available";
60 // format data to be consumed by Shopify demo frontend application
61 const initialData = {
62 variantId: parseInt(,
63 productTitle: product?.title,
64 productImageURL: productImage?.source || "", // random image if product does not have one
65 productDescription: product?.body?.split(/<br.*?>/),
66 originalPrice: variant.compareAtPrice || variant.price,
67 discountedPrice,
68 };
70 // send product variant as a response to be offered to shopper
71 reply.send(initialData);
72 } else {
73 defaultResponse(reply);
74 }
75 } else {
76 defaultResponse(reply);
77 }
80function defaultResponse(reply) {
81 // empty response as a default - post-purchase upsell page will be empty
82 reply.send({});

This file uses the Gadget API to call shopifyProductVariant.findMany() and then applies a filter condition to get product variants that are not included in the original purchase.

const variants = await request.api.shopifyProductVariant.findMany({
filter: { id: { notIn: purchaseIds } },

Notice that the Gadget API is available through the request parameter.

You then select a random variant and return it to the frontend. This is the business logic code. You can replace this code when writing a custom post-purchase upsell application.

Custom route for /sign-changeset

If the customer chooses to purchase the product presented to them in the post-purchase upsell window, you need to modify the original order. Another route is needed to authenticate this transaction - Shopify requires a signed JWT to proceed. It is best to handle this with another custom route.

Create another new file in our routes folder and call it POST-sign-changeset.js.

Paste the following code snippet to respond to requests to this endpoint with a signed JWT. This code is almost identical to the server example provided in the Shopify tutorial.

2 * Route handler for GET
3 *
4 * @param { import("gadget-server").Request } request - incoming request data
5 * @param { import("gadget-server").Reply } reply - reply for customizing and sending a response
6 *
7 * @see {@link}
8 * @see {@link}
9 */
11const jwt = require("jsonwebtoken");
12const { v4: uuidv4 } = require("uuid");
14module.exports = async (request, reply) => {
15 const decodedToken = jwt.verify(
16 request.body.token,
17 process.env["SHOPIFY_API_SECRET"]
18 );
19 const decodedReferenceId = decodedToken.input_data.initialPurchase.referenceId;
21 if (decodedReferenceId !== request.body.referenceId) {
22 reply.status(400);
23 }
25 const payload = {
26 iss: process.env["SHOPIFY_API_KEY"],
27 jti: uuidv4(),
28 iat:,
29 sub: request.body.referenceId,
30 changes: request.body.changes,
31 };
33 const token = jwt.sign(payload, process.env["SHOPIFY_API_SECRET"]);
34 reply.send({ token });

This will allow the original order to be modified successfully. But first, you need to import the jsonwebtoken and uuid modules and provide the SHOPIFY_API_KEY and SHOPIFY_API_SECRET environment variables required by our snippet.

Load npm modules

To be able to run this snippet you need to import some modules in the package.json file: jsonwebtoken and uuid. To load these modules in your Gadget application, open the package.json file in the file explorer and add them as dependencies:

2 "dependencies": {
3 "jsonwebtoken": "^8.5.1",
4 "uuid": "^8.3.2"
5 }

Click the Run yarn button in the top right of package.json window to import the packages.

View of the package.json file with added dependencies

The status page that opens will let you know when the yarn install process is complete.

Add environment variables

To add environment variables in Gadget, go to Environment Variables variables in the navigation bar.

The menu users can click on to be brought to Gadget's environment variables page

The SHOPIFY_API_KEY and SHOPIFY_API_SECRET can be found in your Shopify Partners Dashboard in the Overview tab marked as Client ID and Client secret. Make sure to grab the keys for the custom frontend application you set up, not the keys generated for your Gadget app!

The environment variables page with the SHOPIFY_API_KEY and SHOPIFY_API_SECRET entered

Our POST-sign-changeset.js snippet is already set up to read environment variables. You can access them in Gadget the same way you would access them in any other Node project: process.env.<ENVIRONMENT_VARIABLE_NAME>.

Those are the only two routes required for a post-purchase upsell app! Now you just need to enable cross-origin requests so that our tunneled local frontend can retrieve information from our Gadget app.

Handle CORS

Gadget's custom routes are built on top of Fastify. This means you can use Fastify plugins such as fastify/cors to customize your routing. You can read more about extending route functionality in Gadget in our documentation.

To add the fastify-cors module to our Gadget application, open the package.json file and add it as a dependency: "fastify-cors": "^6.0.3".

Click the Run yarn button in the top right of package.json window to import the package.

Now you need to add a file that handles the injection of Fastify plugins into our router. Create a new file in the routes folder and call it +scope.js. Then paste the following snippet in the file:

1const FastifyCors = require("fastify-cors");
3module.exports = async (server) => {
4 await server.register(FastifyCors, {
5 // allow CORS requests from any origin
6 // you should configure this to be domain specific for production applications
7 origin: true,
8 // only allow POST requests
9 methods: ["POST"],
10 });

This allows for cross-origin requests from Shopify, our backend Gadget app should now be reachable!

Need a different flavour of CORS handling for your custom app? Check out our docs for more details on how you can handle CORS settings.

Your Gadget file explorer should now look like this:

The final file explorer with all custom routes and CORS handling files created

Complete application frontend

Our Gadget app is finished! Now you can continue with the Shopify tutorial starting back at Step 3: Update the extension code or copy and paste the snippet below to finish setting up our application extension frontend.

1import React, { useEffect, useState } from "react";
2import {
3 extend,
4 render,
5 useExtensionInput,
6 BlockStack,
7 Button,
8 CalloutBanner,
9 Heading,
10 Image,
11 Text,
12 TextContainer,
13 Separator,
14 Tiles,
15 TextBlock,
16 Layout,
17} from "@shopify/post-purchase-ui-extensions-react";
19extend("Checkout::PostPurchase::ShouldRender", async ({ inputData, storage }) => {
20 const postPurchaseOffer = await fetch(
21 "https://<gadget-app-name>",
22 {
23 method: "POST",
24 headers: {
25 "Content-Type": "application/json",
26 },
27 body: JSON.stringify({ inputData }),
28 }
29 ).then((res) => res.json());
31 await storage.update(postPurchaseOffer);
33 return { render: true };
36render("Checkout::PostPurchase::Render", () => <App />);
38export function App() {
39 const { storage, inputData, calculateChangeset, applyChangeset, done } =
40 useExtensionInput();
41 const [loading, setLoading] = useState(true);
42 const [calculatedPurchase, setCalculatedPurchase] = useState();
44 useEffect(() => {
45 async function calculatePurchase() {
46 // Request Shopify to calculate shipping costs and taxes for the upsell
47 const result = await calculateChangeset({ changes });
49 setCalculatedPurchase(result.calculatedPurchase);
50 setLoading(false);
51 }
53 calculatePurchase();
54 }, []);
56 const {
57 variantId,
58 productTitle,
59 productImageURL,
60 productDescription,
61 originalPrice,
62 discountedPrice,
63 } = storage.initialData;
65 const changes = [{ type: "add_variant", variantId, quantity: 1 }];
67 // Extract values from the calculated purchase
68 const shipping =
69 calculatedPurchase?.addedShippingLines[0]?.priceSet?.presentmentMoney?.amount;
70 const taxes =
71 calculatedPurchase?.addedTaxLines[0]?.priceSet?.presentmentMoney?.amount;
72 const total = calculatedPurchase?.totalOutstandingSet.presentmentMoney.amount;
73 // const discountedPrice =
74 // calculatedPurchase?.updatedLineItems[0].totalPriceSet.presentmentMoney
75 // .amount;
76 // const originalPrice =
77 // calculatedPurchase?.updatedLineItems[0].priceSet.presentmentMoney.amount;
79 async function acceptOffer() {
80 setLoading(true);
82 // Make a request to your app server to sign the changeset
83 const token = await fetch(
84 "https://<gadget-app-name>",
85 {
86 method: "POST",
87 headers: { "Content-Type": "application/json" },
88 body: JSON.stringify({
89 referenceId: inputData.initialPurchase.referenceId,
90 changes: changes,
91 token: inputData.token,
92 }),
93 }
94 )
95 .then((response) => response.json())
96 .then((response) => response.token);
98 // Make a request to Shopify servers to apply the changeset
99 await applyChangeset(token);
101 // Redirect to the thank-you page
102 done();
103 }
105 function declineOffer() {
106 setLoading(true);
107 done();
108 }
110 return (
111 <BlockStack spacing="loose">
112 <CalloutBanner>
113 <BlockStack spacing="tight">
114 <TextContainer>
115 <Text size="medium" emphasized>
116 It&#39;s not too late to add this to your order
117 </Text>
118 </TextContainer>
119 <TextContainer>
120 <Text size="medium">Add the {productTitle} to your order and </Text>
121 <Text size="medium" emphasized>
122 save 15%.
123 </Text>
124 </TextContainer>
125 </BlockStack>
126 </CalloutBanner>
127 <Layout
128 media={[
129 { viewportSize: "small", sizes: [1, 0, 1], maxInlineSize: 0.9 },
130 { viewportSize: "medium", sizes: [532, 0, 1], maxInlineSize: 420 },
131 { viewportSize: "large", sizes: [560, 38, 340] },
132 ]}
133 >
134 <Image description="product photo" source={productImageURL} />
135 <BlockStack />
136 <BlockStack>
137 <Heading>{productTitle}</Heading>
138 <PriceHeader
139 discountedPrice={discountedPrice}
140 originalPrice={originalPrice}
141 loading={!calculatedPurchase}
142 />
143 <ProductDescription textLines={productDescription} />
144 <BlockStack spacing="tight">
145 <Separator />
146 <MoneyLine
147 label="Subtotal"
148 amount={discountedPrice}
149 loading={!calculatedPurchase}
150 />
151 <MoneyLine
152 label="Shipping"
153 amount={shipping}
154 loading={!calculatedPurchase}
155 />
156 <MoneyLine label="Taxes" amount={taxes} loading={!calculatedPurchase} />
157 <Separator />
158 <MoneySummary
159 label="Total"
160 amount={total}
161 loading={!calculatedPurchase}
162 />
163 </BlockStack>
164 <BlockStack>
165 <Button onPress={acceptOffer} submit loading={loading}>
166 Pay now ยท {formatCurrency(total)}
167 </Button>
168 <Button onPress={declineOffer} subdued loading={loading}>
169 Decline this offer
170 </Button>
171 </BlockStack>
172 </BlockStack>
173 </Layout>
174 </BlockStack>
175 );
178function PriceHeader({ discountedPrice, originalPrice, loading }) {
179 return (
180 <TextContainer alignment="leading" spacing="loose">
181 <Text role="deletion" size="large">
182 {!loading && formatCurrency(originalPrice)}
183 </Text>
184 <Text emphasized size="large" appearance="critical">
185 {" "}
186 {!loading && formatCurrency(discountedPrice)}
187 </Text>
188 </TextContainer>
189 );
192function ProductDescription({ textLines }) {
193 return (
194 <BlockStack spacing="xtight">
195 {, index) => (
196 <TextBlock key={index} subdued>
197 {text}
198 </TextBlock>
199 ))}
200 </BlockStack>
201 );
204function MoneyLine({ label, amount, loading = false }) {
205 return (
206 <Tiles>
207 <TextBlock size="small">{label}</TextBlock>
208 <TextContainer alignment="trailing">
209 <TextBlock emphasized size="small">
210 {loading ? "-" : formatCurrency(amount)}
211 </TextBlock>
212 </TextContainer>
213 </Tiles>
214 );
217function MoneySummary({ label, amount }) {
218 return (
219 <Tiles>
220 <TextBlock size="medium" emphasized>
221 {label}
222 </TextBlock>
223 <TextContainer alignment="trailing">
224 <TextBlock emphasized size="medium">
225 {formatCurrency(amount)}
226 </TextBlock>
227 </TextContainer>
228 </Tiles>
229 );
232function formatCurrency(amount) {
233 if (!amount || parseInt(amount, 10) === 0) {
234 return "Free";
235 }
236 return `$${amount}`;

If you don't copy and paste the above snippet, there are tweaks you need to make to the provided Shopify frontend code:

  • replace the postPurchaseOffer fetch request with the following snippet to change the /offers request to a POST, allowing you to send inputData to your Gadget app in the request body (make sure to replace the placeholder URL with the URL of your Gadget app!):
1extend("Checkout::PostPurchase::ShouldRender", async ({ inputData, storage }) => {
2 const postPurchaseOffer = await fetch(
3 "https://<gadget-app-name>",
4 {
5 method: "POST",
6 headers: {
7 "Content-Type": "application/json",
8 },
9 body: JSON.stringify({ inputData }),
10 }
11 ).then((res) => res.json());
13 await storage.update(postPurchaseOffer);
15 return { render: true };
  • replace the default URL for the /sign-changeset request with the URL for your Gadget app (for example:

  • the current Shopify demo is not making use of the returned originalPrice and purchasePrice; you can comment out the below snippets and instead include these fields as part of the storage.inputData destructuring.

1const {
2 variantId,
3 productTitle,
4 productImageURL,
5 productDescription,
6 originalPrice, // <-- add this field
7 discountedPrice, // <-- add this field
8} = storage.initialData;
10const changes = [{ type: "add_variant", variantId, quantity: 1 }];
12// Extract values from the calculated purchase
13const shipping =
14 calculatedPurchase?.addedShippingLines[0]?.priceSet?.presentmentMoney?.amount;
15const taxes =
16 calculatedPurchase?.addedTaxLines[0]?.priceSet?.presentmentMoney?.amount;
17const total = calculatedPurchase?.totalOutstandingSet.presentmentMoney.amount;
18// const discountedPrice =
19// calculatedPurchase?.updatedLineItems[0].totalPriceSet.presentmentMoney <-- comment out these fields
20// .amount;
21// const originalPrice =
22// calculatedPurchase?.updatedLineItems[0].priceSet.presentmentMoney.amount;

Try it out!

And you're done, congrats!

If you simulate a fake purchase in our Shopify store, you should now be redirected to the post-purchase upsell screen before our purchase summary. And choosing to purchase the offered product will add it to the order.

You can use this as a template for writing your own post-purchase upsell application. All you need to do is replace the business logic in the POST-offer.js file with your custom code!