Build a pre-purchase Shopify checkout UI extension 

Topics covered: Shopify connections, React frontends
Time to build: ~20 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 simple pre-purchase upsell extension that allows merchants to select a product in an embedded admin app that will then be offered to shoppers during checkout. Gadget will take care of your embedded app frontend, backend, and database, and the Shopify CLI will be used to manage the checkout UI extension.

A screenshot of the pre-purchase app built in this tutorial using Shopify checkout UI extensions
Requirements

To get the most out of this tutorial, you will need:

You can fork this Gadget project to quickly preview this app. There are two things you need to do after forking:

Fork on Gadget
Prefer a video?

Follow along and build a pre-purchase checkout extension. Learn how to connect to Shopify, build an embedded admin frontend, write data to metafields from Gadget, and set up a checkout UI extension.

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.

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

Connect to Shopify through the Partners dashboard 

Requirements

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

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

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

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

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

  • Enable the read scope for the Shopify Products API, and select the underlying Product model that we want to import into Gadget
Select Product API scope + model
  • Click Confirm

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

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

Now we need to install our Shopify app on a store, and sync data.

  • Go back to the Shopify Partners dashboard
  • Click on Apps to go to the Apps page again
  • Click on your custom app
  • Click on Select store
  • Click on the Select store button
  • Click on the store we want to use to develop our app
  • You may be prompted about Store transfer being disabled. This is okay, click Install anyway
  • 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 frontend/ShopPage.jsx.

App installation was a success

Set up is complete! We are now ready to build our Gadget application.

If we have created at least one product in our store we can test out the connection:

  • Go back to our Gadget Connections page and click on the Shop Installs button for the added app in the Shopify Apps section
  • A screenshot of the Connections page with the Shop Installs button highlighted
  • Click the Sync button for the installed store
  • The Installs page with the Sync button highlighted
  • We should see that the Sync action was a success
The Installs page displaying a successful sync notification

That's it! We have successfully set up a store and custom app in Shopify, connected our Gadget app to this store, and synced our store data with our Gadget app.

Note: For public Shopify apps, you can add a code effect to sync data on Shop install. For more information, check out our docs.

After your data sync is successful, you can view synced data in the Shopify Product Data page in Gadget. To view this page:

  • Click on the Shopify Product model in the Gadget nav
  • Click Data to view the Shopify Product data that has been synced to your Gadget app

Now that your app is connected to Shopify, the next step will be to start building your app frontend that will be embedded in the store admin.

Build an embedded frontend 

This is all the code you need to add a product picker to an embedded app. Shopify Polaris is used to draw components and Gadget's React tooling is used to manage reading from your Gadget app's backend API. An explanation of the Gadget tooling used is found below the snippet.

  • Copy and paste this snippet into frontend/ShopPage.jsx:
frontend/ShopPage.jsx
jsx
1import { useFindFirst, useFindMany } from "@gadgetinc/react";
2import { Banner, FooterHelp, Layout, Link, Page, Select, Spinner } from "@shopify/polaris";
3import { api } from "./api";
4import { useEffect, useState, useCallback } from "react";
5
6const ShopPage = () => {
7 // use React state to handle selected product and options
8 const [selectedProduct, setSelectedProduct] = useState("");
9 const [productOptions, setProductOptions] = useState([]);
10
11 // use the Gadget React hooks to fetch products and shop data
12 const [{ data, fetching, error }] = useFindMany(api.shopifyProduct);
13 const [{ data: shopData, fetching: shopFetching, error: shopFetchError }] = useFindFirst(api.shopifyShop, {
14 select: {
15 id: true,
16 },
17 });
18
19 // a React useEffect hook to build product options for the Select component
20 useEffect(() => {
21 if (data) {
22 const options = data.map((product) => ({
23 value: product.id,
24 label: product.title,
25 }));
26 setProductOptions(options);
27 }
28 }, [data]);
29
30 // a useCallback hook that will send the selected product id to Gadget (eventually...)
31 const saveSelection = useCallback(() => {
32 console.log({ selectedProduct, shopData });
33 }, [selectedProduct, shopData]);
34
35 return (
36 <Page
37 title="Select product for pre-purchase offer"
38 primaryAction={{ content: "Save", disabled: !selectedProduct, onAction: saveSelection }}
39 >
40 {fetching || shopFetching ? (
41 <Spinner size="large" />
42 ) : (
43 <Layout>
44 <Layout.Section>
45 <Select
46 label="Product for pre-purchase offer"
47 placeholder="-No product selected-"
48 options={productOptions}
49 onChange={setSelectedProduct}
50 value={selectedProduct}
51 />
52 </Layout.Section>
53 <Layout.Section>
54 <FooterHelp>
55 <p>
56 Powered by{" "}
57 <Link url="https://gadget.dev" external>
58 gadget.dev
59 </Link>
60 </p>
61 </FooterHelp>
62 </Layout.Section>
63 </Layout>
64 )}
65 </Page>
66 );
67};
68
69export default ShopPage;

Most of this code is just Polaris and React - the Select component is used to display a dropdown that merchants can use to select what product is offered to shoppers during checkout.

A screenshot of the app frontend embedded in the Shopify admin. The app is a simple drop-down and save button

Your Gadget app's API client is already set up in frontend/api.js and handles auth and session token management for you. This client is imported into ShopPage.jsx and used alongside the useFindFirst and useFindMany React hooks to read product data from your app's API. Both useFindFirst and useFindMany are imported from the @gadgetinc/react package. These are hooks provided by Gadget and allow for a reactive way of interacting with your Gadget app's API.

useFindMany to read product data 

The useFindMany hook is used to read Shopify product data that has been synced to Gadget:

frontend/ShopPage.jsx
jsx
const [{ data, fetching, error }] = useFindMany(api.shopifyProduct);

The returned properties are then used to handle the request's response reactively. The data property will contain product data returned from this request. Note that pagination is not implemented here, so 50 Shopify Product records will be returned by default, up to a maximum of 250 without pagination. The fetching property is a boolean that can be used to display a loading state while data is being retrieved. And finally, error contains any error information that can be handled or displayed to the user.

useFindFirst to read shop data 

To retrieve the current shop id, a useFindFirst hook is used. Because shop tenancy is automatically handled for Shopify models when you connect your Gadget app to Shopify, you will only read a single shop's data from inside an embedded admin. This default filter can be modified in tenancy/shopifyShop.gelly, but it isn't recommended!

frontend/ShopPage.jsx
jsx
const [{ data: shopData, fetching: shopFetching, error: shopFetchError }] = useFindFirst(api.shopifyShop. {
select: {
id: true,
}
});

Because the ID field on the Shopify Shop model is the only required field, a select query is added to the hook so only the ID field is returned.

Your embedded frontend app is almost complete. It is missing one crucial piece: writing the selected product back to Gadget.

Save product reference metafield 

There is one thing missing from your embedded frontend app - you need to be able to save the selected product somewhere so that it can be fed into the checkout UI extension. The best way to do this is to store the product ID inside a metafield. To save this metafield, you will create a new action on the Shopify Shop model and write some custom code to save the product ID to a metafield in Shopify. You will also add a new field to the Shopify Shop so the metafield data is synced to Gadget.

Add save action and custom code 

  • Go to the Shopify Shop Model page
  • Click + in the ACTIONS section of the model page and name the new action savePrePurchaseProduct
A screenshot showing a custom action called savePrePurchaseProduct defined on the Shopify Shop model
  • Click the Add Code Snippet button to add a new Success Effect
  • Paste the following code snippet into the generated code file
shopifyShop/savePrePurchaseProduct/onSavePrePurchaseProduct.js
JavaScript
1/**
2 * Effect code for savePrePurchaseProduct on Shopify Shop
3 * @param { import("gadget-server").SavePrePurchaseProductShopifyShopActionContext } context - Everything for running this effect, like the api client, current record, params, etc. More on effect context: https://docs.gadget.dev/guides/extending-with-code#effect-context
4 */
5module.exports = async ({ api, record, params, logger, connections }) => {
6 // get product id from params
7 const { productId } = params;
8
9 // save the selected pre-purchase product in a SHOP owned metafield
10 const response = await connections.shopify.current?.graphql(
11 `mutation setMetafield($metafields: [MetafieldsSetInput!]!) {
12 metafieldsSet(metafields: $metafields) {
13 metafields {
14 id
15 value
16 ownerType
17 key
18 namespace
19 }
20 userErrors {
21 field
22 message
23 }
24 }
25 }`,
26 {
27 metafields: [
28 {
29 key: "pre-purchase-product",
30 namespace: "gadget-tutorial",
31 ownerId: `gid://shopify/Shop/${record.id}`,
32 type: "product_reference",
33 value: `gid://shopify/Product/${productId}`,
34 },
35 ],
36 }
37 );
38
39 // print to the Gadget Logs
40 logger.info({ response }, "add metafields response");
41};
42
43// define a custom parameter for the id of the product selected by merchants
44module.exports.params = {
45 productId: { type: "string" },
46};

This code file is writing a metafield to Shopify using Shopify's metafieldsSet GraphQL API. The ownerId is set to the current Shopify shop, so each shop that installs your app can have a unique pre-purchase-product metafield.

Input parameters, which are available in the params arg in code effects, are defined with the module.exports.params declaration at the bottom of the file. Here, you are declaring a new string parameter called productId. This param is then used here: const { productId } = params;.

Add metafield to Shopify Shop model 

This metafield can also be added to your Shopify Shop model. In this app, the synced metafield is only used for testing, but it could also be used to display the currently selected product in the admin app if you want to add that on your own.

  • Go to the Shopify Shop Model page
  • Click + in the FIELDS section of the model page and name the new field Pre-Purchase Product
  • Check the Store data from Shopify Metafield option
  • Enter the metafield's namespace: gadget-tutorial
  • Click the Register Namespace button
  • Enter your metafield's key pre-purchase-product
  • Select the Product Reference metafield type
Screenshot of the new Pre-Purchase Product field on the Shopify Shop model. The field is storing data from a Shopify Metafield, and has the namespace 'gadget-tutorial', and the key 'pre-purchase-product'

Your metafield is now set up in Gadget and you are subscribed to any metafield changes.

Update permissions on new action 

You've created your new action and are almost ready to test it out! If you hook up the savePrePurchaseProduct action to your frontend now your request will return an error: GGT_PERMISSION_DENIED. By default, all custom actions (or CRUD actions for custom models) will not have permission granted to the Shopify App User access role, which is the default role used when making requests to Gadget from an embedded app.

To enable permission:

  • Go to Settings -> Roles & Permissions
  • Find the savePrePurchaseProduct action in the Shopify Shop model
  • Give the Shopify App User role permission to access savePrePurchaseProduct by clicking the checkbox
A gif of the savePrePurchaseProduct action being enabled for the Shopify App Users role in Gadget

You can now call your new action from the frontend without errors! The next step is to wire it up to the frontend.

Call new action from frontend 

Return to your frontend/ShopPage.jsx file. To call your new action from the frontend, you can use another Gadget React hook: useAction.

  • Import the useAction hook from @gadgetinc/react
frontend/ShopPage.jsx
jsx
// add useAction to this import statement
import { useFindFirst, useFindMany, useAction } from "@gadgetinc/react";
  • Paste the following line of code into frontend/ShopPage.jsx:
frontend/ShopPage.jsx
jsx
1/** imports */
2const ShopPage = () => {
3 /* useFindMany and useFindFirst hooks */
4
5 // the useAction Gadget React hook is used to call the savePrePurchaseProduct action on the Shopify Shop model
6 const [{ data: saveResponse, fetching: saving, error: saveError }, saveProduct] = useAction(api.shopifyShop.savePrePurchaseProduct);
7
8 /* useEffect for setting product options */
9
10 return (/* return block */)
11}

Similar to the useFindFirst and useFindMany hooks, useAction gives you a response object you can use to determine what to render in your frontend. The second returned parameter from useAction is a function that can be used to call your Gadget app's API. In this case, it is called saveProduct.

  • Modify the saveSelection callback so that it calls saveProduct:
frontend/ShopPage.jsx
jsx
1// a useCallback hook that calls the savePrePurchaseProduct action on the Shopify Shop model to save the selected product
2const saveSelection = useCallback(() => {
3 if (shopData && selectedProduct) {
4 void saveProduct({ id: shopData.id, productId: selectedProduct });
5 }
6}, [selectedProduct, shopData]);

Now when you select a product and click Save in your admin app, the product ID will be saved to a metafield!

You can also use the fetching param, aliased as saving from the useAction hook to disable the Page primaryAction button and Select components, and the data param, aliased as saveResponse to show a success banner.

  • Replace the contents of the return statement with the following JSX snippet:
frontend/ShopPage.jsx
jsx
1return (
2 <Page
3 title="Select product for pre-purchase offer"
4 primaryAction={{ content: "Save", disabled: !selectedProduct || saving, onAction: saveSelection }}
5 >
6 {fetching || shopFetching ? (
7 <Spinner size="large" />
8 ) : (
9 <Layout>
10 {saveResponse && (
11 <Layout.Section>
12 <Banner title="Pre-purchase product saved!" status="success" />
13 </Layout.Section>
14 )}
15 <Layout.Section>
16 <Select
17 label="Product for pre-purchase offer"
18 placeholder="-No product selected-"
19 options={productOptions}
20 onChange={setSelectedProduct}
21 value={selectedProduct}
22 disabled={saving}
23 />
24 </Layout.Section>
25 <Layout.Section>
26 <FooterHelp>
27 <p>
28 Powered by{" "}
29 <Link url="https://gadget.dev" external>
30 gadget.dev
31 </Link>
32 </p>
33 </FooterHelp>
34 </Layout.Section>
35 </Layout>
36 )}
37 </Page>
38);

You are done with the embedded app and backend. Now the only thing left to do is build the checkout UI extension.

Build a pre-purchase checkout UI extension 

To generate any Shopify extension, you need to create a new Shopify CLI app. The Shopify CLI app will simply be used to manage your extensions, the rest of your app is built in Gadget. These setup steps can be used for any Shopify extension, including Checkout UI extensions and Functions!

Checkout UI extensions will not work unless you have the checkout extensibility developer preview enabled on your development store. To learn how to enable the checkout extensibility preview, see Shopify's documentation.

terminal
npm init @shopify/[email protected]
yarn create @shopify/app
  • Once the app is generated, cd into the app's root
  • From the app's root, delete the web folder
terminal
bash
rm -rf web/
  • Generate a checkout UI extension
terminal
npm run shopify app generate extension -- --type checkout_ui --name=pre-purchase-ext
yarn shopify app generate extension --type checkout_ui --name=pre-purchase-ext

Make sure you connect to the same Partners app you used to set up your Shopify connection in Gadget, and select the same development store you installed your app on.

The extension code sample below is written in JavaScript React.

This creates an extensions folder at the root of your CLI app. A pre-purchase-ext folder containing your extension code will also be created. Shopify checkout UI extensions have two files that you will need to edit, a configuration file: shopify.ui.extension.toml and the extension source: src/index.jsx. Both of these files will require some changes to pull in your metafield as input.

Checkout UI extension API

Checkout UI extensions have their own API and set of components, separate from the Admin API and Polaris component library. More details about different components and endpoints can be found in Shopify's Checkout UI extension API docs.

Modify the configuration file 

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

  • Paste the following into shopify.ui.extension.toml:
extensions/pre-purchase-ext/shopify.ui.extension.toml
toml
1type = "checkout_ui_extension"
2name = "pre-purchase-ext"
3
4extension_points = [
5 'Checkout::Dynamic::Render'
6]
7
8[[metafields]]
9namespace = "gadget-tutorial"
10key = "pre-purchase-product"
11
12[capabilities]
13api_access = true

The [[metafields]] definition allows you to pull in the metafield as input to your extension. The api_access [capabilities] setting was also enabled, which allows you to query using the Storefront API.

Write checkout UI extension code 

Now for the extension itself. This borrows heavily from Shopify's own pre-purchase tutorial, with a couple small modifications. The entire src/index.jsx code file is provided, with additional details provided below the snippet.

  • Paste the following into your extension's src/index.jsx file:
extensions/pre-purchase-ext/src/index.jsx
jsx
1import React, { useEffect, useState } from "react";
2import {
3 render,
4 Divider,
5 Image,
6 Banner,
7 Heading,
8 Button,
9 InlineLayout,
10 BlockStack,
11 Text,
12 SkeletonText,
13 SkeletonImage,
14 useCartLines,
15 useApplyCartLinesChange,
16 useExtensionApi,
17 useAppMetafields,
18} from "@shopify/checkout-ui-extensions-react";
19
20// Set up the entry point for the extension
21render("Checkout::Dynamic::Render", () => <App />);
22
23// The function that will render the app
24function App() {
25 // Use `query` for fetching product data from the Storefront API, and use `i18n` to format
26 // currencies, numbers, and translate strings
27 const { query, i18n } = useExtensionApi();
28 // Get a reference to the function that will apply changes to the cart lines from the imported hook
29 const applyCartLinesChange = useApplyCartLinesChange();
30
31 // get passed in metafield
32 const [prePurchaseProduct] = useAppMetafields();
33
34 // Set up the states
35 const [product, setProduct] = useState(null);
36 const [loading, setLoading] = useState(false);
37 const [adding, setAdding] = useState(false);
38 const [showError, setShowError] = useState(false);
39
40 // On initial load, fetch the product variants
41 useEffect(() => {
42 if (prePurchaseProduct) {
43 // Set the loading state to show some UI if you're waiting
44 setLoading(true);
45 // Use `query` api method to send graphql queries to the Storefront API
46 query(
47 `query ($id: ID!) {
48 product(id: $id) {
49 id
50 title
51 images(first:1){
52 nodes {
53 url
54 }
55 }
56 variants(first: 1) {
57 nodes {
58 id
59 price {
60 amount
61 }
62 }
63 }
64 }
65 }`,
66 {
67 variables: { id: prePurchaseProduct.metafield.value },
68 }
69 )
70 .then(({ data }) => {
71 // Set the `product` so that you can reference it
72 setProduct(data.product);
73 })
74 .catch((error) => console.error(error))
75 .finally(() => setLoading(false));
76 }
77 }, [prePurchaseProduct]);
78
79 // If an offer is added and an error occurs, then show some error feedback using a banner
80 useEffect(() => {
81 if (showError) {
82 const timer = setTimeout(() => setShowError(false), 3000);
83 return () => clearTimeout(timer);
84 }
85 }, [showError]);
86
87 // Access the current cart lines and subscribe to changes
88 const lines = useCartLines();
89
90 // Show a loading UI if you're waiting for product variant data
91 // Use Skeleton components to keep placement from shifting when content loads
92 if (loading) {
93 return (
94 <BlockStack spacing="loose">
95 <Divider />
96 <Heading level={2}>You might also like</Heading>
97 <BlockStack spacing="loose">
98 <InlineLayout spacing="base" columns={[64, "fill", "auto"]} blockAlignment="center">
99 <SkeletonImage aspectRatio={1} />
100 <BlockStack spacing="none">
101 <SkeletonText inlineSize="large" />
102 <SkeletonText inlineSize="small" />
103 </BlockStack>
104 <Button kind="secondary" disabled={true}>
105 Add
106 </Button>
107 </InlineLayout>
108 </BlockStack>
109 </BlockStack>
110 );
111 }
112 // If product variants can't be loaded, then show nothing
113 if (!loading && !product) {
114 return null;
115 }
116
117 // Get the IDs of all product variants in the cart
118 const cartLineProductVariantIds = lines.map((item) => item.merchandise.id);
119
120 // check to see if the product is already in the cart
121 const productInCart = !!product.variants.nodes.some(({ id }) => cartLineProductVariantIds.includes(id));
122
123 // If the product is in the cart, then don't show the offer
124 if (productInCart) {
125 return null;
126 }
127
128 // Choose the first available product variant on offer
129 const { images, title, variants } = product;
130
131 // Localize the currency for international merchants and customers
132 const renderPrice = i18n.formatCurrency(variants.nodes[0].price.amount);
133
134 // Use the first product image or a placeholder if the product has no images
135 const imageUrl =
136 images.nodes[0]?.url ??
137 "https://cdn.shopify.com/s/files/1/0533/2089/files/placeholder-images-image_medium.png?format=webp&v=1530129081";
138
139 return (
140 <BlockStack spacing="loose">
141 <Divider />
142 <Heading level={2}>You might also like</Heading>
143 <BlockStack spacing="loose">
144 <InlineLayout
145 spacing="base"
146 // Use the `columns` property to set the width of the columns
147 // Image: column should be 64px wide
148 // BlockStack: column, which contains the title and price, should "fill" all available space
149 // Button: column should "auto" size based on the intrinsic width of the elements
150 columns={[64, "fill", "auto"]}
151 blockAlignment="center"
152 >
153 <Image border="base" borderWidth="base" borderRadius="loose" source={imageUrl} description={title} aspectRatio={1} />
154 <BlockStack spacing="none">
155 <Text size="medium" emphasis="strong">
156 {title}
157 </Text>
158 <Text appearance="subdued">{renderPrice}</Text>
159 </BlockStack>
160 <Button
161 kind="secondary"
162 loading={adding}
163 accessibilityLabel={`Add ${title} to cart`}
164 onPress={async () => {
165 setAdding(true);
166 // Apply the cart lines change
167 const result = await applyCartLinesChange({
168 type: "addCartLine",
169 merchandiseId: variants.nodes[0].id,
170 quantity: 1,
171 });
172 setAdding(false);
173 if (result.type === "error") {
174 // An error occurred adding the cart line
175 // Verify that you're using a valid product variant ID
176 // For example, 'gid://shopify/ProductVariant/123'
177 setShowError(true);
178 console.error(result.message);
179 }
180 }}
181 >
182 Add
183 </Button>
184 </InlineLayout>
185 </BlockStack>
186 {showError && <Banner status="critical">There was an issue adding this product. Please try again.</Banner>}
187 </BlockStack>
188 );
189}

Shopify goes though a step-by-step build of the extension in their tutorial. The differences will be highlighted here. Notice that none of the extension code is Gadget-specific! When working with Shopify checkout UI extensions, you are largely working completely in the Shopify ecosystem.

To pull in your metafield, the useAppMetafields() function is called. This, along with all imported components and APIs, is imported from the @shopify/checkout-ui-extensions-react package:

extensions/pre-purchase-ext/src/index.jsx
jsx
const [prePurchaseProduct] = useAppMetafields();

This metafield value, which contains the product ID, is then used to pull the first product variant and first product image available using the Storefront API. The query is wrapped in a useEffect with prePurchaseProduct as the input. The resulting data is then saved to the product state.

extensions/pre-purchase-ext/src/index.jsx
jsx
1// On initial load, fetch the product variants
2useEffect(() => {
3 if (prePurchaseProduct) {
4 // Set the loading state to show some UI if you're waiting
5 setLoading(true);
6 // Use `query` api method to send graphql queries to the Storefront API
7 query(
8 `query ($id: ID!) {
9 product(id: $id) {
10 id
11 title
12 images(first:1){
13 nodes {
14 url
15 }
16 }
17 variants(first: 1) {
18 nodes {
19 id
20 price {
21 amount
22 }
23 }
24 }
25 }
26 }`,
27 {
28 variables: { id: prePurchaseProduct.metafield.value },
29 }
30 )
31 .then(({ data }) => {
32 // Set the `product` so that you can reference it
33 setProduct(data.product);
34 })
35 .catch((error) => console.error(error))
36 .finally(() => setLoading(false));
37 }
38}, [prePurchaseProduct]);
Skip the Storefront API call

There is a good chance you want to pick a particular variant and image in the Shopify admin app when selecting a product for pre-purchase. Or maybe the customer can pick a variant! All this can be done with additional metafields or additional code in the checkout UI extension.

There is some code to check if the product is already in the cart. If it is, the pre-purchase offer is skipped. A null return value simply means that the extension will not be rendered in the checkout:

extensions/pre-purchase-ext/src/index.jsx
jsx
1// Get the IDs of all product variants in the cart
2const cartLineProductVariantIds = lines.map((item) => item.merchandise.id);
3
4// check to see if the product is already in the cart
5const productInCart = !!product.variants.nodes.some(({ id }) => cartLineProductVariantIds.includes(id));
6
7// If the product is in the cart, then don't show the offer
8if (productInCart) {
9 return null;
10}

Finally, the last block of code is the return statement that will render the UI extension. The most interesting block of code is the onPress function param on the Button component. It uses the applyCartLinesChange function to add the additional product variant to the cart.

extensions/pre-purchase-ext/src/index.jsx
jsx
1onPress={async () => {
2 setAdding(true);
3 // Apply the cart lines change
4 const result = await applyCartLinesChange({
5 type: "addCartLine",
6 merchandiseId: variants.nodes[0].id,
7 quantity: 1,
8 });
9 setAdding(false);
10 if (result.type === "error") {
11 // An error occurred adding the cart line
12 // Verify that you're using a valid product variant ID
13 // For example, 'gid://shopify/ProductVariant/123'
14 setShowError(true);
15 console.error(result.message);
16 }
17}}

Test your extension 

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

  • Start the CLI app and extension by running npm run dev or yarn dev from your CLI app's root
    • If you haven't already, 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
  • Your extension should be previewed in the checkout!
A screenshot of the extension rendered in a Shopify checkout

Deploy your extension 

Shopify hosts all checkout UI extension code for you. You don't need to set up hosting yourself, but you do need to deploy your extension to Shopify's infrastructure.

  • Stop the dev command you ran in the previous step and run npm run deploy or yarn deploy to push your extension to Shopify
  • Go to your app's Extension page in the Partners dashboard
  • Click on your extension
  • Click Create version to create a new version of your extension and the Create button in the modal
    • You can select either Minor version or Major version for this tutorial
  • Enable the development store preview
  • Click Publish on your new extension version in the Version history to publish your extension
Screenshot of the Extension page in the Shopify Partners dashboard, with the tutorial extension already versioned and published

Now you need to place your extension in the checkout so it is visible for shoppers!

  • Go to your development store
  • Click Settings -> Checkout -> and then the Customize button for the current theme
  • Click Add app in the bottom of the right panel of the checkout editor
  • Select your extension, and drag and drop it in the checkout editor to place it
  • Click Save in the top right corner of the screenshot

You should now be able to see your extension when you go through your development store's checkout!

Congrats! You have successfully built a fullstack pre-purchase app that includes an admin-embedded frontend and checkout UI extension!

Next steps 

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

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

Automated product tagger
20 mins

Build an embedded Shopify application that automatically tags products in a Shopify store based on description keywords.