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 

To learn how to create a Shopify connection, follow the Shopify quickstart guide. Note that you may also use the assistant to create a new Shopify connection.

For this tutorial, we will need the read_products scope and the product, productVariant and productMedia models.

Select Product API scope + model

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
import { applyParams, save, ActionOptions } from "gadget-server"; import { preventCrossShopDataAccess } from "gadget-server/shopify"; export const run: ActionRun = async ({ params, record, logger, api, connections, }) => { applyParams(params, record); await preventCrossShopDataAccess(params, record); try { // get an embedding for the product title + description using the OpenAI connection const response = await connections.openai.embeddings.create({ input: `${record.title}: ${record.body}`, model: "text-embedding-3-small", }); const embedding = response.data[0].embedding; // write to the Gadget Logs logger.info({ id: record.id }, "generated product embedding"); // use the internal API to store vector embedding in Gadget database // on shopifyProduct model await api.internal.shopifyProduct.update(record.id, { embedding }); } catch (error) { logger.error({ error }, "error creating embedding"); } await save(record); }; export const onSuccess: ActionOnSuccess = async ({ params, record, logger, api, connections, }) => {}; export const options: ActionOptions = { actionType: "custom", triggers: { api: true, }, };
import { applyParams, save, ActionOptions } from "gadget-server"; import { preventCrossShopDataAccess } from "gadget-server/shopify"; export const run: ActionRun = async ({ params, record, logger, api, connections, }) => { applyParams(params, record); await preventCrossShopDataAccess(params, record); try { // get an embedding for the product title + description using the OpenAI connection const response = await connections.openai.embeddings.create({ input: `${record.title}: ${record.body}`, model: "text-embedding-3-small", }); const embedding = response.data[0].embedding; // write to the Gadget Logs logger.info({ id: record.id }, "generated product embedding"); // use the internal API to store vector embedding in Gadget database // on shopifyProduct model await api.internal.shopifyProduct.update(record.id, { embedding }); } catch (error) { logger.error({ error }, "error creating embedding"); } await save(record); }; export const onSuccess: ActionOnSuccess = async ({ params, record, logger, api, connections, }) => {}; export const options: ActionOptions = { actionType: "custom", triggers: { api: true, }, };

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
import { applyParams, save, ActionOptions } from "gadget-server"; import { preventCrossShopDataAccess } from "gadget-server/shopify"; export const run: ActionRun = async ({ params, record }) => { applyParams(params, record); await preventCrossShopDataAccess(params, record); await save(record); }; export const onSuccess: ActionOnSuccess = async ({ record, api }) => { // check to see if the product title or description has changed if (record.changes("title") || record.changes("body")) { // enqueue generateEmbedding action to background action queue await api.enqueue(api.shopifyProduct.generateEmbedding, { id: record.id }); } }; export const options: ActionOptions = { actionType: "create" };
import { applyParams, save, ActionOptions } from "gadget-server"; import { preventCrossShopDataAccess } from "gadget-server/shopify"; export const run: ActionRun = async ({ params, record }) => { applyParams(params, record); await preventCrossShopDataAccess(params, record); await save(record); }; export const onSuccess: ActionOnSuccess = async ({ record, api }) => { // check to see if the product title or description has changed if (record.changes("title") || record.changes("body")) { // enqueue generateEmbedding action to background action queue await api.enqueue(api.shopifyProduct.generateEmbedding, { id: record.id }); } }; export 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
export const run: ActionRun = async ({ params, api, connections }) => { // get passed in product ids const { productIds } = params; if (!productIds) { throw new Error("productIds is required"); } const pIds = productIds.map((productId) => productId.split("/").pop()!); // generate an embedding for the contents of the current cart let cartContents = "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: "; for (let id of pIds) { const product = await api.shopifyProduct.findOne(id, { select: { body: true, title: true }, }); // get product title and description for each product in cart and add to embedding input cartContents += product.body ? `${product.title}: ${product.body},` : `${product.title},`; } // use OpenAI to generate embedding for cart const response = await connections.openai.embeddings.create({ input: cartContents, model: "text-embedding-3-small", }); const embedding = response.data[0].embedding; // find the most closely-related product const productToRecommend = await api.shopifyProduct.findFirst({ select: { id: true, title: true, // get variants from related productVariant model variants: { edges: { node: { id: true, price: true, }, }, }, // get image from featured productMedia record featuredMedia: { file: { image: true, }, }, // get the currency for the shop shop: { currency: true, }, }, // use cosine similarity sort using the cart content embedding to find closest match sort: { embedding: { cosineSimilarityTo: embedding, }, }, // filter out products that are already in the cart filter: { id: { notIn: pIds, }, }, }); // prep payload for response const { id, title, featuredMedia, variants, shop } = productToRecommend; const currency = shop?.currency; // increment offerCount atomically so count is always accurate in db await api.internal.shopifyProduct.update(id, { _atomics: { offerCount: { increment: 1 }, }, }); // return the recommendation to the extension return { id, title, imgSrc: (featuredMedia?.file?.image as Record<string, any> | undefined) ?.originalSrc, variant: variants.edges[0].node, currency, }; }; // define custom param: an array of product ids export const params = { productIds: { type: "array", items: { type: "string", }, }, };
export const run: ActionRun = async ({ params, api, connections }) => { // get passed in product ids const { productIds } = params; if (!productIds) { throw new Error("productIds is required"); } const pIds = productIds.map((productId) => productId.split("/").pop()!); // generate an embedding for the contents of the current cart let cartContents = "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: "; for (let id of pIds) { const product = await api.shopifyProduct.findOne(id, { select: { body: true, title: true }, }); // get product title and description for each product in cart and add to embedding input cartContents += product.body ? `${product.title}: ${product.body},` : `${product.title},`; } // use OpenAI to generate embedding for cart const response = await connections.openai.embeddings.create({ input: cartContents, model: "text-embedding-3-small", }); const embedding = response.data[0].embedding; // find the most closely-related product const productToRecommend = await api.shopifyProduct.findFirst({ select: { id: true, title: true, // get variants from related productVariant model variants: { edges: { node: { id: true, price: true, }, }, }, // get image from featured productMedia record featuredMedia: { file: { image: true, }, }, // get the currency for the shop shop: { currency: true, }, }, // use cosine similarity sort using the cart content embedding to find closest match sort: { embedding: { cosineSimilarityTo: embedding, }, }, // filter out products that are already in the cart filter: { id: { notIn: pIds, }, }, }); // prep payload for response const { id, title, featuredMedia, variants, shop } = productToRecommend; const currency = shop?.currency; // increment offerCount atomically so count is always accurate in db await api.internal.shopifyProduct.update(id, { _atomics: { offerCount: { increment: 1 }, }, }); // return the recommendation to the extension return { id, title, imgSrc: (featuredMedia?.file?.image as Record<string, any> | undefined) ?.originalSrc, variant: variants.edges[0].node, currency, }; }; // define custom param: an array of product ids export const params = { productIds: { type: "array", items: { type: "string", }, }, };

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
import { AutoTable } from "@gadgetinc/react/auto/polaris"; import { Card, Layout, Page } from "@shopify/polaris"; import { api } from "../api"; export default function () { return ( <Page title="Products offered in checkout"> <Layout> <Layout.Section> <Card> <AutoTable model={api.shopifyProduct} columns={["title", "offerCount"]} initialSort={{ offerCount: "Descending" }} actions={[]} live /> </Card> </Layout.Section> </Layout> </Page> ); }
import { AutoTable } from "@gadgetinc/react/auto/polaris"; import { Card, Layout, Page } from "@shopify/polaris"; import { api } from "../api"; export default function () { return ( <Page title="Products offered in checkout"> <Layout> <Layout.Section> <Card> <AutoTable model={api.shopifyProduct} columns={["title", "offerCount"]} initialSort={{ offerCount: "Descending" }} actions={[]} live /> </Card> </Layout.Section> </Layout> </Page> ); }

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 the @gadgetinc/shopify-extensions package 

You can 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 @gadgetinc/shopify-extensions
  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
import { Client } from "@gadget-client/<YOUR-APP-SLUG>"; import { useGlobalAction } from "@gadgetinc/react"; import { Provider, useGadget } from "@gadgetinc/shopify-extensions/react"; import { Banner, BlockStack, Button, Divider, Heading, Image, InlineLayout, reactExtension, SkeletonImage, SkeletonText, Text, useApi, useApplyCartLinesChange, useCartLines, } from "@shopify/ui-extensions-react/checkout"; import { useEffect, useState } from "react"; // initialize a new Client for your Gadget API const client = new Client(); export default reactExtension("purchase.checkout.block.render", () => <GadgetExtension />); // component to set up the Provider with the sessionToken from Shopify function GadgetExtension() { const { sessionToken } = useApi(); return ( <Provider api={client} sessionToken={sessionToken}> <Extension /> </Provider> ); } function Extension() { const [fetched, setFetched] = useState(false); const [adding, setAdding] = useState(false); const [showError, setShowError] = useState(false); // Use `i18n` to format currencies, numbers, and translate strings const { i18n } = useApi(); // Get the current state of the cart const cartLines = useCartLines(); // Get a reference to the function that will apply changes to the cart lines from the imported hook const applyCartLinesChange = useApplyCartLinesChange(); // get a 'ready' boolean from the useGadget hook const { ready, api } = useGadget<typeof client>(); const [{ data: offer, fetching, error: getOfferError }, getOffer] = useGlobalAction(api.recommendProduct); // fetch the product to offer with Gadget action call useEffect(() => { const fetchOffer = async () => { const productIds = cartLines.map((cartLine) => cartLine.merchandise.product.id); // call getOffer callback provided by hook await getOffer({ productIds }); }; if (cartLines && !fetched) { setFetched(true); fetchOffer() // make sure to catch any error .catch(console.error); } }, [cartLines, fetched]); // If an offer is added and an error occurs, then show some error feedback using a banner useEffect(() => { if (showError) { const timer = setTimeout(() => setShowError(false), 3000); return () => clearTimeout(timer); } }, [showError]); // loading state while fetching offer if ((!offer && !getOfferError) || fetching) { return ( <BlockStack spacing="loose"> <Divider /> <Heading level={2}>You might also like</Heading> <BlockStack spacing="loose"> <InlineLayout spacing="base" columns={[64, "fill", "auto"]} blockAlignment="center"> <SkeletonImage aspectRatio={1} /> <BlockStack spacing="none"> <SkeletonText inlineSize="large" /> <SkeletonText inlineSize="small" /> </BlockStack> <Button kind="secondary" disabled={true}> Add </Button> </InlineLayout> </BlockStack> </BlockStack> ); } // Get the IDs of all product variants in the cart const cartLineProductVariantIds = cartLines.map((item) => item.merchandise.id); // check to see if the product is already in the cart const productInCart = cartLineProductVariantIds.includes(`gid://shopify/ProductVariant/${offer.variant.id}`); // If the product is in the cart, or there is an error fetching the offer // then don't show the offer if (productInCart || getOfferError) { return null; } // Choose the first available product variant on offer const { imgSrc, title, variant, currency } = offer; // Localize the currency for international merchants and customers const renderPrice = i18n.formatCurrency(variant.price, { currency }); // Use the first product image or a placeholder if the product has no images const imageUrl = imgSrc ?? "https://cdn.shopify.com/s/files/1/0533/2089/files/placeholder-images-image_medium.png?format=webp&v=1530129081"; return ( <BlockStack spacing="loose"> <Divider /> <Heading level={2}>You might also like</Heading> <BlockStack spacing="loose"> <InlineLayout spacing="base" // Use the `columns` property to set the width of the columns // Image: column should be 64px wide // BlockStack: column, which contains the title and price, should "fill" all available space // Button: column should "auto" size based on the intrinsic width of the elements columns={[64, "fill", "auto"]} blockAlignment="center" > <Image border="base" borderWidth="base" borderRadius="loose" source={imageUrl} accessibilityDescription={title} aspectRatio={1} /> <BlockStack spacing="none"> <Text size="medium" emphasis="bold"> {title} </Text> <Text appearance="subdued">{renderPrice}</Text> </BlockStack> <Button kind="secondary" loading={adding} accessibilityLabel={`Add ${title} to cart`} onPress={async () => { setAdding(true); // Apply the cart lines change const result = await applyCartLinesChange({ type: "addCartLine", merchandiseId: `gid://shopify/ProductVariant/${variant.id}`, quantity: 1, }); setAdding(false); if (result.type === "error") { // An error occurred adding the cart line // Verify that you're using a valid product variant ID // For example, 'gid://shopify/ProductVariant/123' setShowError(true); console.error(result.message); } }} > Add </Button> </InlineLayout> </BlockStack> {showError && <Banner status="critical">There was an issue adding this product. Please try again.</Banner>} </BlockStack> ); }
import { Client } from "@gadget-client/<YOUR-APP-SLUG>"; import { useGlobalAction } from "@gadgetinc/react"; import { Provider, useGadget } from "@gadgetinc/shopify-extensions/react"; import { Banner, BlockStack, Button, Divider, Heading, Image, InlineLayout, reactExtension, SkeletonImage, SkeletonText, Text, useApi, useApplyCartLinesChange, useCartLines, } from "@shopify/ui-extensions-react/checkout"; import { useEffect, useState } from "react"; // initialize a new Client for your Gadget API const client = new Client(); export default reactExtension("purchase.checkout.block.render", () => <GadgetExtension />); // component to set up the Provider with the sessionToken from Shopify function GadgetExtension() { const { sessionToken } = useApi(); return ( <Provider api={client} sessionToken={sessionToken}> <Extension /> </Provider> ); } function Extension() { const [fetched, setFetched] = useState(false); const [adding, setAdding] = useState(false); const [showError, setShowError] = useState(false); // Use `i18n` to format currencies, numbers, and translate strings const { i18n } = useApi(); // Get the current state of the cart const cartLines = useCartLines(); // Get a reference to the function that will apply changes to the cart lines from the imported hook const applyCartLinesChange = useApplyCartLinesChange(); // get a 'ready' boolean from the useGadget hook const { ready, api } = useGadget<typeof client>(); const [{ data: offer, fetching, error: getOfferError }, getOffer] = useGlobalAction(api.recommendProduct); // fetch the product to offer with Gadget action call useEffect(() => { const fetchOffer = async () => { const productIds = cartLines.map((cartLine) => cartLine.merchandise.product.id); // call getOffer callback provided by hook await getOffer({ productIds }); }; if (cartLines && !fetched) { setFetched(true); fetchOffer() // make sure to catch any error .catch(console.error); } }, [cartLines, fetched]); // If an offer is added and an error occurs, then show some error feedback using a banner useEffect(() => { if (showError) { const timer = setTimeout(() => setShowError(false), 3000); return () => clearTimeout(timer); } }, [showError]); // loading state while fetching offer if ((!offer && !getOfferError) || fetching) { return ( <BlockStack spacing="loose"> <Divider /> <Heading level={2}>You might also like</Heading> <BlockStack spacing="loose"> <InlineLayout spacing="base" columns={[64, "fill", "auto"]} blockAlignment="center"> <SkeletonImage aspectRatio={1} /> <BlockStack spacing="none"> <SkeletonText inlineSize="large" /> <SkeletonText inlineSize="small" /> </BlockStack> <Button kind="secondary" disabled={true}> Add </Button> </InlineLayout> </BlockStack> </BlockStack> ); } // Get the IDs of all product variants in the cart const cartLineProductVariantIds = cartLines.map((item) => item.merchandise.id); // check to see if the product is already in the cart const productInCart = cartLineProductVariantIds.includes(`gid://shopify/ProductVariant/${offer.variant.id}`); // If the product is in the cart, or there is an error fetching the offer // then don't show the offer if (productInCart || getOfferError) { return null; } // Choose the first available product variant on offer const { imgSrc, title, variant, currency } = offer; // Localize the currency for international merchants and customers const renderPrice = i18n.formatCurrency(variant.price, { currency }); // Use the first product image or a placeholder if the product has no images const imageUrl = imgSrc ?? "https://cdn.shopify.com/s/files/1/0533/2089/files/placeholder-images-image_medium.png?format=webp&v=1530129081"; return ( <BlockStack spacing="loose"> <Divider /> <Heading level={2}>You might also like</Heading> <BlockStack spacing="loose"> <InlineLayout spacing="base" // Use the `columns` property to set the width of the columns // Image: column should be 64px wide // BlockStack: column, which contains the title and price, should "fill" all available space // Button: column should "auto" size based on the intrinsic width of the elements columns={[64, "fill", "auto"]} blockAlignment="center" > <Image border="base" borderWidth="base" borderRadius="loose" source={imageUrl} accessibilityDescription={title} aspectRatio={1} /> <BlockStack spacing="none"> <Text size="medium" emphasis="bold"> {title} </Text> <Text appearance="subdued">{renderPrice}</Text> </BlockStack> <Button kind="secondary" loading={adding} accessibilityLabel={`Add ${title} to cart`} onPress={async () => { setAdding(true); // Apply the cart lines change const result = await applyCartLinesChange({ type: "addCartLine", merchandiseId: `gid://shopify/ProductVariant/${variant.id}`, quantity: 1, }); setAdding(false); if (result.type === "error") { // An error occurred adding the cart line // Verify that you're using a valid product variant ID // For example, 'gid://shopify/ProductVariant/123' setShowError(true); console.error(result.message); } }} > Add </Button> </InlineLayout> </BlockStack> {showError && <Banner status="critical">There was an issue adding this product. Please try again.</Banner>} </BlockStack> ); }

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?