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.
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.
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.
Count 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.
Navigate to Settings → Plugins → OpenAI
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.
Add a new generateEmbedding.js action file at api/models/shopifyProduct/actions
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.
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.
Navigate to Settings → Plugins → Shopify → Installs
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.
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.
Navigate to accessControl/permissions in the Gadget editor
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.
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.
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
cd into your project and open it in an editor
Add the following workspaces and trustedDependencies to your package.json:
Select the same Partner app and development store you used to connect to Shopify when prompted by Shopify's CLI
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.
cd into the extensions/ai-pre-purchase-ext folder of your app
Run the following in your terminal:
install the required packages
yarn add @gadgetinc/shopify-extensions
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.
Turn on network access in your extensions/ai-pre-purchase-ext/shopify.extension.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.
Now for the extension itself. The entire src/Checkout.jsx code file is provided, with additional details provided below the snippet.
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
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.
Start the extension dev server by running shopify app dev from your app root
When prompted, make sure you select the same development store you used to connect your Gadget app to Shopify
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.
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: