Build a pre-purchase Shopify checkout UI extension 

Expected time: 25 minutes

Checkout UI extensions are an important part of the Shopify developer ecosystem. Since Shopify announced the deprecation of checkout.liquid, it has become more important than ever for developers to be able to swiftly migrate existing Plus-merchant checkout functionality over to checkout UI extensions.

In this tutorial, you will build a pre-purchase upsell extension that allows merchants to select multiple products in an embedded admin app that can be offered to buyers during checkout. If more than one product is selected, the OpenAI connection and vector embeddings will be used to examine the cart contents and recommend a product to the buyer.

A screenshot of the pre-purchase app built in this tutorial using Shopify checkout UI extensions. A snowboard is being offered in the checkout. The product name and variant price are visible, along with an Add button available for buyers to add the item to their cart

Prerequisites 

Before starting this tutorial, you will need:

Step 1: Create a Gadget app and connect to Shopify 

Your first step will be to set up a Gadget project and connect to a Shopify store via the Shopify connection. Create a new Gadget application at gadget.new and select the Shopify app template.

Because you are adding an embedded frontend, you are going to connect to Shopify using the Partners connection.

Now we will set up a custom Shopify application in the Partners dashboard.

  1. Go to the Shopify Partner dashboard
  2. Click on the link to the Apps page

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
  1. Click the Create App button
Click on Create app button
  1. Click the Create app manually button and enter a name for your Shopify app
Shopify's app creation landing page in the Partners Dashboard
  1. Click on Settings in the side nav bar
  2. Click on Plugins in the modal that opens
  3. Select Shopify from the list of plugins and connections
The Gadget homescreen, with the Connections link highlighted
  1. 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
  1. 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.

  1. Select the scopes and models listed below and click Confirm to connect to the custom Shopify app
  • Enable the read scope for the Shopify Products API, and select the underlying Product, Product variant, and Product media models that we want to import into Gadget
Select Product API scope + model

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

  1. In your Shopify app in the Partner dashboard, click on Configuration in the side nav bar so you can edit the App URL and Allowed redirection URL(s) fields
  2. 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

Now we need to install our Shopify app on a store.

  1. Go back to the Shopify Partner dashboard
  2. Click on Apps to go to the Apps page again
  3. Click on your custom app
  4. Click on Select store
Click on the Select store button
  1. Click on the store we want to use to develop our app
  2. You may be prompted about Store transfer being disabled. This is okay, click Install anyway
  3. Click Install app to install your Gadget app on your Shopify store
Having an issue installing?

If you are getting a permissions denied error when installing your app, try logging in to the Shopify store Admin!

Click Install app to authorize our Gadget app with our store

You will be redirected to an embedded admin app that has been generated for you. The code for this app template can be found in web/routes/index.jsx.

Do not sync your data models yet! You will want to run product descriptions through OpenAI's embeddings API so they can be offered to buyers in the checkout.

Now that your app is connected to Shopify, the next step will be to start modifying your app backend and database to support vector embeddings.

Step 2: Add custom fields to the shopifyProduct model 

You will use vector embeddings to recommend a product to buyers based on the contents of their cart. To do this, you need to add a new field to the shopifyProduct model that will store the vector embeddings for each product.

You can also add a field to keep track of the number of times a product was offered to buyers in the checkout.

  1. Navigate to api/models/shopifyProduct/schema
  2. Add the following fields to the model:
Field nameField typeDefault valueReason for adding the field to the product model
embeddingvectorStores vector embeddings for product descriptions.
offerCountnumber0Count the number of times a product has been offered.

Step 3: Add the OpenAI connection 

You need to set up the OpenAI connection in Gadget to generate vector embeddings. The OpenAI connection gives you an authenticated API client used to call the OpenAI API.

  1. Navigate to SettingsPluginsOpenAI
  2. Click Add connection with Gadget development keys selected

Gadget gives you OpenAI credits to help you start building without requiring you to enter an OpenAI API key. If you have used your credits, you will need to enter your OpenAI API key.

Step 4: Add action to generate embeddings 

Now that you have finished data modeling and setting up the OpenAI connection, you can start customizing your backend.

First, we need an action we can call to generate the vector embedding for offered products.

  1. Add a new generateEmbedding.js action file at api/models/shopifyProduct/actions
  2. Paste the following code into the file:
api/models/shopifyProduct/actions/generateEmbedding.js
JavaScript
1import { applyParams, save, ActionOptions } from "gadget-server";
2import { preventCrossShopDataAccess } from "gadget-server/shopify";
3
4export const run: ActionRun = async ({
5 params,
6 record,
7 logger,
8 api,
9 connections,
10}) => {
11 applyParams(params, record);
12 await preventCrossShopDataAccess(params, record);
13
14 try {
15 // get an embedding for the product title + description using the OpenAI connection
16 const response = await connections.openai.embeddings.create({
17 input: `${record.title}: ${record.body}`,
18 model: "text-embedding-3-small",
19 });
20 const embedding = response.data[0].embedding;
21
22 // write to the Gadget Logs
23 logger.info({ id: record.id }, "generated product embedding");
24
25 // use the internal API to store vector embedding in Gadget database
26 // on shopifyProduct model
27 await api.internal.shopifyProduct.update(record.id, { embedding });
28 } catch (error) {
29 logger.error({ error }, "error creating embedding");
30 }
31
32 await save(record);
33};
34
35export const onSuccess: ActionOnSuccess = async ({
36 params,
37 record,
38 logger,
39 api,
40 connections,
41}) => {};
42
43export const options: ActionOptions = {
44 actionType: "custom",
45 triggers: {
46 api: true,
47 },
48};
1import { applyParams, save, ActionOptions } from "gadget-server";
2import { preventCrossShopDataAccess } from "gadget-server/shopify";
3
4export const run: ActionRun = async ({
5 params,
6 record,
7 logger,
8 api,
9 connections,
10}) => {
11 applyParams(params, record);
12 await preventCrossShopDataAccess(params, record);
13
14 try {
15 // get an embedding for the product title + description using the OpenAI connection
16 const response = await connections.openai.embeddings.create({
17 input: `${record.title}: ${record.body}`,
18 model: "text-embedding-3-small",
19 });
20 const embedding = response.data[0].embedding;
21
22 // write to the Gadget Logs
23 logger.info({ id: record.id }, "generated product embedding");
24
25 // use the internal API to store vector embedding in Gadget database
26 // on shopifyProduct model
27 await api.internal.shopifyProduct.update(record.id, { embedding });
28 } catch (error) {
29 logger.error({ error }, "error creating embedding");
30 }
31
32 await save(record);
33};
34
35export const onSuccess: ActionOnSuccess = async ({
36 params,
37 record,
38 logger,
39 api,
40 connections,
41}) => {};
42
43export const options: ActionOptions = {
44 actionType: "custom",
45 triggers: {
46 api: true,
47 },
48};

This action:

  • uses connections.openai to get an OpenAI client
  • calls the embeddings.create API to generate a vector embedding for the product title and description
  • uses the internal API to save the embedding with api.internal.shopifyProduct.update()

Now you need to call this action when a product's create (or update) action is run.

Calling createEmbedding when a product record is created 

You want to reliably generate embeddings for each product so they can all be offered as part of the pre-purchase upsell. Because you want this reliability, you can use Gadget's built-in background actions system to enqueue calls to generateEmbedding actions.

Background actions have built-in concurrency control and retry handling, making it easier to ensure that each product gets a generated embedding.

  1. Paste the following code snippet in api/models/shopifyProduct/actions/create.js:
api/models/shopifyProduct/actions/create.js
JavaScript
1import { applyParams, save, ActionOptions } from "gadget-server";
2import { preventCrossShopDataAccess } from "gadget-server/shopify";
3
4export const run: ActionRun = async ({ params, record }) => {
5 applyParams(params, record);
6 await preventCrossShopDataAccess(params, record);
7 await save(record);
8};
9
10export const onSuccess: ActionOnSuccess = async ({ record, api }) => {
11 // check to see if the product title or description has changed
12 if (record.changes("title") || record.changes("body")) {
13 // enqueue generateEmbedding action to background action queue
14 await api.enqueue(api.shopifyProduct.generateEmbedding, { id: record.id });
15 }
16};
17
18export const options: ActionOptions = { actionType: "create" };
1import { applyParams, save, ActionOptions } from "gadget-server";
2import { preventCrossShopDataAccess } from "gadget-server/shopify";
3
4export const run: ActionRun = async ({ params, record }) => {
5 applyParams(params, record);
6 await preventCrossShopDataAccess(params, record);
7 await save(record);
8};
9
10export const onSuccess: ActionOnSuccess = async ({ record, api }) => {
11 // check to see if the product title or description has changed
12 if (record.changes("title") || record.changes("body")) {
13 // enqueue generateEmbedding action to background action queue
14 await api.enqueue(api.shopifyProduct.generateEmbedding, { id: record.id });
15 }
16};
17
18export const options: ActionOptions = { actionType: "create" };
Tip for production apps

You can also use the code in the onSuccess function in api/model/shopifyShop/update.js so embeddings are re-generated when the product description changes.

You can create a new file, export a function from that file, and import that shared function in both your create and update actions.

Step 5: Sync your Shopify data 

Now that embeddings will be generated when your shopifyProduct.create action is called, you can sync data from Shopify. This data sync will call the create action for each product, which enqueues the generateEmbedding action.

A data sync will sync all existing products for your installed Shopify store. If you have a lot of products, this may take a while and use your OpenAI credits. This will work for large collections of products, but you may want to test with less products for this tutorial.

  1. Navigate to SettingsPluginsShopifyInstalls
  2. Click Sync to start a data sync

You will see your Queues count change in the Gadget editor as generateEmbedding actions are enqueued and then executed.

If you navigate to api/models/shopifyProduct/data, you should see that the embedding field has data.

Step 6: Add an action to fetch offers 

The last step before building the extension is creating an action that can be called that will recommend a product to buyers based on the contents of their cart.

This action will be called from the checkout extension.

  1. Add a global action to your app at api/actions/recommendProduct.js and add the following code:
api/actions/recommendProduct.js
JavaScript
1export const run: ActionRun = async ({ params, api, connections }) => {
2 // get passed in product ids
3 const { productIds } = params;
4
5 if (!productIds) {
6 throw new Error("productIds is required");
7 }
8
9 const pIds = productIds.map((productId) => productId.split("/").pop()!);
10
11 // generate an embedding for the contents of the current cart
12 let cartContents =
13 "This is a list of product titles and descriptions in a shopping cart. I want to find a product that can be recommended based on the similarity to these products: ";
14 for (let id of pIds) {
15 const product = await api.shopifyProduct.findOne(id, {
16 select: { body: true, title: true },
17 });
18 // get product title and description for each product in cart and add to embedding input
19 cartContents += product.body
20 ? `${product.title}: ${product.body},`
21 : `${product.title},`;
22 }
23
24 // use OpenAI to generate embedding for cart
25 const response = await connections.openai.embeddings.create({
26 input: cartContents,
27 model: "text-embedding-3-small",
28 });
29 const embedding = response.data[0].embedding;
30
31 // find the most closely-related product
32 const productToRecommend = await api.shopifyProduct.findFirst({
33 select: {
34 id: true,
35 title: true,
36 // get variants from related productVariant model
37 variants: {
38 edges: {
39 node: {
40 id: true,
41 price: true,
42 },
43 },
44 },
45 // get image from featured productMedia record
46 featuredMedia: {
47 file: {
48 image: true,
49 },
50 },
51 // get the currency for the shop
52 shop: {
53 currency: true,
54 },
55 },
56 // use cosine similarity sort using the cart content embedding to find closest match
57 sort: {
58 embedding: {
59 cosineSimilarityTo: embedding,
60 },
61 },
62 // filter out products that are already in the cart
63 filter: {
64 id: {
65 notIn: pIds,
66 },
67 },
68 });
69
70 // prep payload for response
71 const { id, title, featuredMedia, variants, shop } = productToRecommend;
72 const currency = shop?.currency;
73
74 // increment offerCount atomically so count is always accurate in db
75 await api.internal.shopifyProduct.update(id, {
76 _atomics: {
77 offerCount: { increment: 1 },
78 },
79 });
80
81 // return the recommendation to the extension
82 return {
83 id,
84 title,
85 imgSrc: (featuredMedia?.file?.image as Record<string, any> | undefined)
86 ?.originalSrc,
87 variant: variants.edges[0].node,
88 currency,
89 };
90};
91
92// define custom param: an array of product ids
93export const params = {
94 productIds: {
95 type: "array",
96 items: {
97 type: "string",
98 },
99 },
100};
1export const run: ActionRun = async ({ params, api, connections }) => {
2 // get passed in product ids
3 const { productIds } = params;
4
5 if (!productIds) {
6 throw new Error("productIds is required");
7 }
8
9 const pIds = productIds.map((productId) => productId.split("/").pop()!);
10
11 // generate an embedding for the contents of the current cart
12 let cartContents =
13 "This is a list of product titles and descriptions in a shopping cart. I want to find a product that can be recommended based on the similarity to these products: ";
14 for (let id of pIds) {
15 const product = await api.shopifyProduct.findOne(id, {
16 select: { body: true, title: true },
17 });
18 // get product title and description for each product in cart and add to embedding input
19 cartContents += product.body
20 ? `${product.title}: ${product.body},`
21 : `${product.title},`;
22 }
23
24 // use OpenAI to generate embedding for cart
25 const response = await connections.openai.embeddings.create({
26 input: cartContents,
27 model: "text-embedding-3-small",
28 });
29 const embedding = response.data[0].embedding;
30
31 // find the most closely-related product
32 const productToRecommend = await api.shopifyProduct.findFirst({
33 select: {
34 id: true,
35 title: true,
36 // get variants from related productVariant model
37 variants: {
38 edges: {
39 node: {
40 id: true,
41 price: true,
42 },
43 },
44 },
45 // get image from featured productMedia record
46 featuredMedia: {
47 file: {
48 image: true,
49 },
50 },
51 // get the currency for the shop
52 shop: {
53 currency: true,
54 },
55 },
56 // use cosine similarity sort using the cart content embedding to find closest match
57 sort: {
58 embedding: {
59 cosineSimilarityTo: embedding,
60 },
61 },
62 // filter out products that are already in the cart
63 filter: {
64 id: {
65 notIn: pIds,
66 },
67 },
68 });
69
70 // prep payload for response
71 const { id, title, featuredMedia, variants, shop } = productToRecommend;
72 const currency = shop?.currency;
73
74 // increment offerCount atomically so count is always accurate in db
75 await api.internal.shopifyProduct.update(id, {
76 _atomics: {
77 offerCount: { increment: 1 },
78 },
79 });
80
81 // return the recommendation to the extension
82 return {
83 id,
84 title,
85 imgSrc: (featuredMedia?.file?.image as Record<string, any> | undefined)
86 ?.originalSrc,
87 variant: variants.edges[0].node,
88 currency,
89 };
90};
91
92// define custom param: an array of product ids
93export const params = {
94 productIds: {
95 type: "array",
96 items: {
97 type: "string",
98 },
99 },
100};

This action:

  • generates an embedding based on the titles and descriptions of the products in a buyer's cart
  • finds a recommended product using api.shopifyProduct.findFirst, which uses cosine similarity sorting with the generated cart embedding to find the product that most closely matches the current products in the cart

Step 7: Update action permissions 

Authorization is built into Gadget, which means that you need to give buyers access to your app's API.

Roles are already created for both merchants and buyers. Buyers in the checkout will be granted the unauthenticated role.

  1. Navigate to accessControl/permissions in the Gadget editor
  2. Grant the unauthenticated role access to the recommendProduct action

Now that buyers in the checkout have permission to call your action, you can build your extension.

Step 8: Add embedded frontend to track what was offered 

You can also modify the admin-embedded frontend to display the count of what products are offered to buyers.

  1. Paste the following code in web/routes/index.jsx:
web/routes/index.jsx
React
1import { AutoTable } from "@gadgetinc/react/auto/polaris";
2import { Card, Layout, Page } from "@shopify/polaris";
3import { api } from "../api";
4
5export default function () {
6 return (
7 <Page title="Products offered in checkout">
8 <Layout>
9 <Layout.Section>
10 <Card>
11 <AutoTable
12 model={api.shopifyProduct}
13 columns={["title", "offerCount"]}
14 initialSort={{ offerCount: "Descending" }}
15 actions={[]}
16 live
17 />
18 </Card>
19 </Layout.Section>
20 </Layout>
21 </Page>
22 );
23}
1import { AutoTable } from "@gadgetinc/react/auto/polaris";
2import { Card, Layout, Page } from "@shopify/polaris";
3import { api } from "../api";
4
5export default function () {
6 return (
7 <Page title="Products offered in checkout">
8 <Layout>
9 <Layout.Section>
10 <Card>
11 <AutoTable
12 model={api.shopifyProduct}
13 columns={["title", "offerCount"]}
14 initialSort={{ offerCount: "Descending" }}
15 actions={[]}
16 live
17 />
18 </Card>
19 </Layout.Section>
20 </Layout>
21 </Page>
22 );
23}

This code makes use of Gadget's AutoTable autocomponent, and will render a Polaris table without any additional configuration.

The live property on AutoTable means that the table will get realtime updates as the value in the database changes.

Step 9: Build a checkout UI extension 

Now you can build a checkout UI extension to offer the product to buyers during checkout.

Shopify hosts all extensions on their infrastructure, so you only need to write the extension code and deploy it using the Shopify CLI.

To manage Shopify extensions in your Gadget project, you need to use ggt to pull down your project to your local machine.

  1. In your local terminal, run the ggt dev command replacing <YOUR APP SLUG> to pull down your app to your local machine:
terminal
ggt dev ~/gadget/<YOUR APP SLUG> --app=<YOUR APP SLUG> --env=development
  1. cd into your project and open it in an editor
  2. Add the following workspaces and trustedDependencies to your package.json:
package.json
json
{
"workspaces": ["extensions/*"],
"trustedDependencies": ["@shopify/plugin-cloudflare"]
}
  1. Add a .ignore file to the root of your project
  2. Add the following to .ignore:
add to .ignore and .gitignore
extensions/*/dist
extensions/*/node_modules
  1. If it does not already exist, add an empty shopify.app.toml file to your project root. This is required to run the Shopify CLI commands.
  2. Use the Shopify CLI in your local terminal to generate your checkout UI extension:
terminal
shopify app generate extension --template checkout_ui --name=ai-pre-purchase-ext
  1. Select the same Partner app and development store you used to connect to Shopify when prompted by Shopify's CLI
  2. Select JavaScript React when prompted for the extension language

This command will generate an extensions folder at your project root, and your extension will be generated by Shopify.

Install your Gadget API client and React hooks 

You can install your Gadget app's API client directly inside the extension. You can also install the @gadgetinc/shopify-extensions package that contains tooling to make it easier to work with your client inside extensions.

  1. cd into the extensions/ai-pre-purchase-ext folder of your app
  2. Run the following in your terminal:
install the required packages
yarn add @gadget-client/example-app @gadgetinc/shopify-extensions

You may need to set up the Gadget NPM registry on your machine to be able to install your API client.

set up Gadget NPM registry
npm config set @gadget-client:registry https://registry.gadget.dev/npm
  1. cd back to your project root

Modify the extension toml configuration file 

The first thing you need to do is modify your extension's shopify.extension.toml file. You need to define your metafield as input and allow access to the Storefront API.

  1. Turn on network access in your extensions/ai-pre-purchase-ext/shopify.extension.toml:
extensions/ai-pre-purchase-ext/shopify.extension.toml
toml
# Gives your extension access to make external network calls, using the
# JavaScript `fetch()` API. Learn more:
# https://shopify.dev/docs/api/checkout-ui-extensions/unstable/configuration#network-access
network_access = true

This will grant your extension network access so it can make requests to your Gadget backend.

You must request access to use the network_access capability before publishing your extension. Read more in Shopify's documentation.

Write checkout UI extension code 

Now for the extension itself. The entire src/Checkout.jsx code file is provided, with additional details provided below the snippet.

  1. Paste the following into your extension's extensions/ai-pre-purchase-ext/src/Checkout.jsx file:

Make sure to replace <YOUR-APP-SLUG> when importing the API Client below!

extensions/ai-pre-purchase-ext/src/Checkout.jsx
React
1import { Client } from "@gadget-client/<YOUR-APP-SLUG>";
2import { useGlobalAction } from "@gadgetinc/react";
3import { Provider, useGadget } from "@gadgetinc/shopify-extensions/react";
4import {
5 Banner,
6 BlockStack,
7 Button,
8 Divider,
9 Heading,
10 Image,
11 InlineLayout,
12 reactExtension,
13 SkeletonImage,
14 SkeletonText,
15 Text,
16 useApi,
17 useApplyCartLinesChange,
18 useCartLines,
19} from "@shopify/ui-extensions-react/checkout";
20import { useEffect, useState } from "react";
21
22// initialize a new Client for your Gadget API
23const client = new Client();
24
25export default reactExtension("purchase.checkout.block.render", () => <GadgetExtension />);
26
27// component to set up the Provider with the sessionToken from Shopify
28function GadgetExtension() {
29 const { sessionToken } = useApi();
30
31 return (
32 <Provider api={client} sessionToken={sessionToken}>
33 <Extension />
34 </Provider>
35 );
36}
37
38function Extension() {
39 const [fetched, setFetched] = useState(false);
40 const [adding, setAdding] = useState(false);
41 const [showError, setShowError] = useState(false);
42
43 // Use `i18n` to format currencies, numbers, and translate strings
44 const { i18n } = useApi();
45 // Get the current state of the cart
46 const cartLines = useCartLines();
47 // Get a reference to the function that will apply changes to the cart lines from the imported hook
48 const applyCartLinesChange = useApplyCartLinesChange();
49
50 // get a 'ready' boolean from the useGadget hook
51 const { ready, api } = useGadget<typeof client>();
52
53 const [{ data: offer, fetching, error: getOfferError }, getOffer] = useGlobalAction(api.recommendProduct);
54
55 // fetch the product to offer with Gadget action call
56 useEffect(() => {
57 const fetchOffer = async () => {
58 const productIds = cartLines.map((cartLine) => cartLine.merchandise.product.id);
59 // call getOffer callback provided by hook
60 await getOffer({ productIds });
61 };
62
63 if (cartLines && !fetched) {
64 setFetched(true);
65 fetchOffer()
66 // make sure to catch any error
67 .catch(console.error);
68 }
69 }, [cartLines, fetched]);
70
71 // If an offer is added and an error occurs, then show some error feedback using a banner
72 useEffect(() => {
73 if (showError) {
74 const timer = setTimeout(() => setShowError(false), 3000);
75 return () => clearTimeout(timer);
76 }
77 }, [showError]);
78
79 // loading state while fetching offer
80 if ((!offer && !getOfferError) || fetching) {
81 return (
82 <BlockStack spacing="loose">
83 <Divider />
84 <Heading level={2}>You might also like</Heading>
85 <BlockStack spacing="loose">
86 <InlineLayout spacing="base" columns={[64, "fill", "auto"]} blockAlignment="center">
87 <SkeletonImage aspectRatio={1} />
88 <BlockStack spacing="none">
89 <SkeletonText inlineSize="large" />
90 <SkeletonText inlineSize="small" />
91 </BlockStack>
92 <Button kind="secondary" disabled={true}>
93 Add
94 </Button>
95 </InlineLayout>
96 </BlockStack>
97 </BlockStack>
98 );
99 }
100
101 // Get the IDs of all product variants in the cart
102 const cartLineProductVariantIds = cartLines.map((item) => item.merchandise.id);
103
104 // check to see if the product is already in the cart
105 const productInCart = cartLineProductVariantIds.includes(`gid://shopify/ProductVariant/${offer.variant.id}`);
106
107 // If the product is in the cart, or there is an error fetching the offer
108 // then don't show the offer
109 if (productInCart || getOfferError) {
110 return null;
111 }
112
113 // Choose the first available product variant on offer
114 const { imgSrc, title, variant, currency } = offer;
115
116 // Localize the currency for international merchants and customers
117 const renderPrice = i18n.formatCurrency(variant.price, { currency });
118
119 // Use the first product image or a placeholder if the product has no images
120 const imageUrl =
121 imgSrc ?? "https://cdn.shopify.com/s/files/1/0533/2089/files/placeholder-images-image_medium.png?format=webp&v=1530129081";
122
123 return (
124 <BlockStack spacing="loose">
125 <Divider />
126 <Heading level={2}>You might also like</Heading>
127 <BlockStack spacing="loose">
128 <InlineLayout
129 spacing="base"
130 // Use the `columns` property to set the width of the columns
131 // Image: column should be 64px wide
132 // BlockStack: column, which contains the title and price, should "fill" all available space
133 // Button: column should "auto" size based on the intrinsic width of the elements
134 columns={[64, "fill", "auto"]}
135 blockAlignment="center"
136 >
137 <Image border="base" borderWidth="base" borderRadius="loose" source={imageUrl} accessibilityDescription={title} aspectRatio={1} />
138 <BlockStack spacing="none">
139 <Text size="medium" emphasis="bold">
140 {title}
141 </Text>
142 <Text appearance="subdued">{renderPrice}</Text>
143 </BlockStack>
144 <Button
145 kind="secondary"
146 loading={adding}
147 accessibilityLabel={`Add ${title} to cart`}
148 onPress={async () => {
149 setAdding(true);
150 // Apply the cart lines change
151 const result = await applyCartLinesChange({
152 type: "addCartLine",
153 merchandiseId: `gid://shopify/ProductVariant/${variant.id}`,
154 quantity: 1,
155 });
156 setAdding(false);
157 if (result.type === "error") {
158 // An error occurred adding the cart line
159 // Verify that you're using a valid product variant ID
160 // For example, 'gid://shopify/ProductVariant/123'
161 setShowError(true);
162 console.error(result.message);
163 }
164 }}
165 >
166 Add
167 </Button>
168 </InlineLayout>
169 </BlockStack>
170 {showError && <Banner status="critical">There was an issue adding this product. Please try again.</Banner>}
171 </BlockStack>
172 );
173}
1import { Client } from "@gadget-client/<YOUR-APP-SLUG>";
2import { useGlobalAction } from "@gadgetinc/react";
3import { Provider, useGadget } from "@gadgetinc/shopify-extensions/react";
4import {
5 Banner,
6 BlockStack,
7 Button,
8 Divider,
9 Heading,
10 Image,
11 InlineLayout,
12 reactExtension,
13 SkeletonImage,
14 SkeletonText,
15 Text,
16 useApi,
17 useApplyCartLinesChange,
18 useCartLines,
19} from "@shopify/ui-extensions-react/checkout";
20import { useEffect, useState } from "react";
21
22// initialize a new Client for your Gadget API
23const client = new Client();
24
25export default reactExtension("purchase.checkout.block.render", () => <GadgetExtension />);
26
27// component to set up the Provider with the sessionToken from Shopify
28function GadgetExtension() {
29 const { sessionToken } = useApi();
30
31 return (
32 <Provider api={client} sessionToken={sessionToken}>
33 <Extension />
34 </Provider>
35 );
36}
37
38function Extension() {
39 const [fetched, setFetched] = useState(false);
40 const [adding, setAdding] = useState(false);
41 const [showError, setShowError] = useState(false);
42
43 // Use `i18n` to format currencies, numbers, and translate strings
44 const { i18n } = useApi();
45 // Get the current state of the cart
46 const cartLines = useCartLines();
47 // Get a reference to the function that will apply changes to the cart lines from the imported hook
48 const applyCartLinesChange = useApplyCartLinesChange();
49
50 // get a 'ready' boolean from the useGadget hook
51 const { ready, api } = useGadget<typeof client>();
52
53 const [{ data: offer, fetching, error: getOfferError }, getOffer] = useGlobalAction(api.recommendProduct);
54
55 // fetch the product to offer with Gadget action call
56 useEffect(() => {
57 const fetchOffer = async () => {
58 const productIds = cartLines.map((cartLine) => cartLine.merchandise.product.id);
59 // call getOffer callback provided by hook
60 await getOffer({ productIds });
61 };
62
63 if (cartLines && !fetched) {
64 setFetched(true);
65 fetchOffer()
66 // make sure to catch any error
67 .catch(console.error);
68 }
69 }, [cartLines, fetched]);
70
71 // If an offer is added and an error occurs, then show some error feedback using a banner
72 useEffect(() => {
73 if (showError) {
74 const timer = setTimeout(() => setShowError(false), 3000);
75 return () => clearTimeout(timer);
76 }
77 }, [showError]);
78
79 // loading state while fetching offer
80 if ((!offer && !getOfferError) || fetching) {
81 return (
82 <BlockStack spacing="loose">
83 <Divider />
84 <Heading level={2}>You might also like</Heading>
85 <BlockStack spacing="loose">
86 <InlineLayout spacing="base" columns={[64, "fill", "auto"]} blockAlignment="center">
87 <SkeletonImage aspectRatio={1} />
88 <BlockStack spacing="none">
89 <SkeletonText inlineSize="large" />
90 <SkeletonText inlineSize="small" />
91 </BlockStack>
92 <Button kind="secondary" disabled={true}>
93 Add
94 </Button>
95 </InlineLayout>
96 </BlockStack>
97 </BlockStack>
98 );
99 }
100
101 // Get the IDs of all product variants in the cart
102 const cartLineProductVariantIds = cartLines.map((item) => item.merchandise.id);
103
104 // check to see if the product is already in the cart
105 const productInCart = cartLineProductVariantIds.includes(`gid://shopify/ProductVariant/${offer.variant.id}`);
106
107 // If the product is in the cart, or there is an error fetching the offer
108 // then don't show the offer
109 if (productInCart || getOfferError) {
110 return null;
111 }
112
113 // Choose the first available product variant on offer
114 const { imgSrc, title, variant, currency } = offer;
115
116 // Localize the currency for international merchants and customers
117 const renderPrice = i18n.formatCurrency(variant.price, { currency });
118
119 // Use the first product image or a placeholder if the product has no images
120 const imageUrl =
121 imgSrc ?? "https://cdn.shopify.com/s/files/1/0533/2089/files/placeholder-images-image_medium.png?format=webp&v=1530129081";
122
123 return (
124 <BlockStack spacing="loose">
125 <Divider />
126 <Heading level={2}>You might also like</Heading>
127 <BlockStack spacing="loose">
128 <InlineLayout
129 spacing="base"
130 // Use the `columns` property to set the width of the columns
131 // Image: column should be 64px wide
132 // BlockStack: column, which contains the title and price, should "fill" all available space
133 // Button: column should "auto" size based on the intrinsic width of the elements
134 columns={[64, "fill", "auto"]}
135 blockAlignment="center"
136 >
137 <Image border="base" borderWidth="base" borderRadius="loose" source={imageUrl} accessibilityDescription={title} aspectRatio={1} />
138 <BlockStack spacing="none">
139 <Text size="medium" emphasis="bold">
140 {title}
141 </Text>
142 <Text appearance="subdued">{renderPrice}</Text>
143 </BlockStack>
144 <Button
145 kind="secondary"
146 loading={adding}
147 accessibilityLabel={`Add ${title} to cart`}
148 onPress={async () => {
149 setAdding(true);
150 // Apply the cart lines change
151 const result = await applyCartLinesChange({
152 type: "addCartLine",
153 merchandiseId: `gid://shopify/ProductVariant/${variant.id}`,
154 quantity: 1,
155 });
156 setAdding(false);
157 if (result.type === "error") {
158 // An error occurred adding the cart line
159 // Verify that you're using a valid product variant ID
160 // For example, 'gid://shopify/ProductVariant/123'
161 setShowError(true);
162 console.error(result.message);
163 }
164 }}
165 >
166 Add
167 </Button>
168 </InlineLayout>
169 </BlockStack>
170 {showError && <Banner status="critical">There was an issue adding this product. Please try again.</Banner>}
171 </BlockStack>
172 );
173}

This extension file:

  • initializes the Gadget Client using a Provider and sessionToken from Shopify
  • calls the api.recommendProduct action using a useGlobalAction React hook
  • displays a loading widget until the offer is fetched from Gadget
  • checks to see if the offer is already in the cart or there is an error with the fetch
  • displays the offer to buyers
  • adds the offered product to the cart at the press of a button

Test your extension 

Now that you've built your extension, you need to test it out in a checkout.

  1. Start the extension dev server by running shopify app dev from your app root
  2. When prompted, make sure you select the same development store you used to connect your Gadget app to Shopify
  3. Open the Preview URL to access the Shopify Developer Console and open the provided Checkout UI extension URL

You should be able to see the extension in the checkout UI.

A screenshot of the extension rendered in a Shopify checkout

See the offer in your admin embedded UI 

You can go back to your Shopify store admin and see that the count in your table has increased. If you refresh your Shopify checkout tab, the table will update in real time.

Congrats! You have successfully built a custom pre-purchase checkout extension with Gadget!

Next steps 

Have questions about the tutorial? Join Gadget's developer Discord to ask Gadget employees and join the Gadget developer community!

Extend this tutorial 

If you want to extend this tutorial, you could:

  • build an embedded frontend for merchants to select what products can be offered in the checkout
  • build an embedded frontend that serves as an analytics dashboard and lets merchants know what products have been added to a buyer's cart and purchased

Other 

Want to learn more about data modeling and writing custom code effects in Gadget? Try out the product tagger tutorial:

Was this page helpful?