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

Step 1: Create a Gadget app and connect to Shopify 

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

A screenshot of the Shopify app template tile selected on the new app modal, with a domain entered

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 Connections in Settings underneath the Plugins 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 Configuration 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 on the shopifyProduct Data page in Gadget. To view this page:

  • Click on the shopifyProduct 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 modifying your app backend and database to support adding and storing a Shopify metafield used to save the product offered to shoppers.

Step 2: Save product reference metafield 

The best way to save a selected product to be offered in this extension is to store the product ID inside a metafield. You will add a new field to the shopifyShop model so the metafield data is synced to Gadget. To save this metafield, you will also create a new action on the shopifyShop model and write some custom code to save the product ID to a metafield in Shopify.

Add metafield to shopifyShop model 

This metafield can also be added to your shopifyShop 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.

  • Click on the shopifyShop data model
  • Click + in the FIELDS section of the model page and give the field the identifier prePurchaseProduct
  • 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 shopifyShop model. The field stores 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. Setting this field as a relationship will link the metafield value to the existing record in the Gadget database.

Add save action and custom code 

  • Click on the shopifyShop data model
  • 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 shopifyShop model
  • Paste the following code snippet into shopifyShop/actions/savePrePurchaseProduct.js to update the onSuccess function of the action:
shopifyShop/actions/savePrePurchaseProduct.js
JavaScript
1import {
2 applyParams,
3 preventCrossShopDataAccess,
4 save,
5 ActionOptions,
6 SavePrePurchaseProductShopifyShopActionContext,
7} from "gadget-server";
8
9// define a productId custom param for this action
10export const params = {
11 productId: { type: "string" },
12};
13
14/**
15 * @param { SavePrePurchaseProductShopifyShopActionContext } context
16 */
17export async function run({ params, record, logger, api }) {
18 applyParams(params, record);
19 await preventCrossShopDataAccess(params, record);
20 await save(record);
21}
22
23/**
24 * @param { SavePrePurchaseProductShopifyShopActionContext } context
25 */
26export async function onSuccess({ params, record, logger, connections }) {
27 // get the product id passed in as a custom param
28 const { productId } = params;
29
30 // save the selected pre-purchase product in a SHOP-owned metafield
31 const response = await connections.shopify.current?.graphql(
32 `mutation setMetafield($metafields: [MetafieldsSetInput!]!) {
33 metafieldsSet(metafields: $metafields) {
34 metafields {
35 id
36 value
37 ownerType
38 key
39 namespace
40 }
41 userErrors {
42 field
43 message
44 }
45 }
46 }`,
47 {
48 metafields: [
49 {
50 key: "pre-purchase-product",
51 namespace: "gadget-tutorial",
52 ownerId: `gid://shopify/Shop/${record.id}`,
53 type: "product_reference",
54 value: productId,
55 },
56 ],
57 }
58 );
59
60 // print to the Gadget Logs
61 logger.info({ response }, "add metafields response");
62}
63
64/** @type { ActionOptions } */
65export const options = {
66 actionType: "update",
67};

This code file is writing a metafield to Shopify using Shopify's metafieldsSet GraphQL API. The ownerId is set to the current 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 export const params = {} declaration at the top of the file. Here, you are declaring a new string parameter called productId. This param is then used in the onSuccess function.

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 shopifyShop 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 build the frontend.

Step 3: 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, useActionForm, Controller } from "@gadgetinc/react";
2import { Banner, FooterHelp, Layout, Link, Page, Select, Spinner, Form, Button, FormLayout, SkeletonDisplayText } from "@shopify/polaris";
3import { api } from "./api";
4import { useEffect, useState } from "react";
5
6const PrePurchaseForm = ({ products, shop }) => {
7 // useActionForm used to handle form state and submission
8 const { submit, control, formState, error, setValue, watch } = useActionForm(api.shopifyShop.savePrePurchaseProduct, {
9 findBy: shop.id,
10 select: {
11 id: true,
12 prePurchaseProduct: true,
13 },
14 // send productId as a custom param
15 send: ["id", "productId"],
16 });
17
18 // use watch to listen for updates to the form state
19 const updateProductId = watch("shopifyShop.prePurchaseProduct");
20 // save as productId value in form state to send custom param
21 useEffect(() => {
22 setValue("productId", updateProductId);
23 }, [updateProductId]);
24
25 return (
26 <Form onSubmit={submit}>
27 <FormLayout>
28 {formState?.isSubmitSuccessful && <Banner title="Pre-purchase product saved!" tone="success" />}
29 {error && (
30 <Banner title="Error saving selection" tone="critical">
31 {error.message}
32 </Banner>
33 )}
34 {formState?.isLoading ? (
35 <SkeletonDisplayText size="large" />
36 ) : (
37 <Controller
38 name="shopifyShop.prePurchaseProduct"
39 control={control}
40 required
41 render={({ field }) => {
42 const { ref, ...fieldProps } = field;
43 return (
44 <Select
45 label="Product for pre-purchase offer"
46 placeholder="-No product selected-"
47 options={products}
48 disabled={formState.isSubmitting}
49 {...fieldProps}
50 />
51 );
52 }}
53 />
54 )}
55
56 <Button submit disabled={formState.isSubmitting} variant="primary">
57 Save
58 </Button>
59 </FormLayout>
60 </Form>
61 );
62};
63
64const ShopPage = () => {
65 // use React state to handle selected product and options
66 const [productOptions, setProductOptions] = useState([]);
67
68 // use the Gadget React hooks to fetch products as options for Select component
69 const [{ data: products, fetching: productsFetching, error: productsFetchError }] = useFindMany(api.shopifyProduct);
70 // get the current shop id (shop tenancy applied automatically, only one shop available)
71 const [{ data: shopData, fetching: shopFetching, error: shopFetchError }] = useFindFirst(api.shopifyShop, {
72 select: {
73 id: true,
74 },
75 });
76
77 // a React useEffect hook to build product options for the Select component
78 useEffect(() => {
79 if (products) {
80 const options = products.map((product) => ({
81 value: `gid://shopify/Product/${product.id}`,
82 label: product.title,
83 }));
84 setProductOptions(options);
85 }
86 }, [products]);
87
88 return (
89 <Page title="Select product for pre-purchase offer">
90 {productsFetching || shopFetching || productOptions.length === 0 ? (
91 <Spinner size="large" />
92 ) : (
93 <Layout>
94 {(productsFetchError || shopFetchError) && (
95 <Layout.Section>
96 <Banner title="Error loading data" tone="critical">
97 {productsFetchError?.message || shopFetchError?.message}
98 </Banner>
99 </Layout.Section>
100 )}
101 <Layout.Section>
102 <PrePurchaseForm shop={shopData} products={productOptions} />
103 </Layout.Section>
104 <Layout.Section>
105 <FooterHelp>
106 <p>
107 Powered by{" "}
108 <Link url="https://gadget.dev" external>
109 gadget.dev
110 </Link>
111 </p>
112 </FooterHelp>
113 </Layout.Section>
114 </Layout>
115 )}
116 </Page>
117 );
118};
119
120export default ShopPage;

A lot 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, useFindMany, and useAction React hooks to read and write data from your 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:

  • data contains product data returned from this request
  • fetching is a boolean that can be used to display a loading state while data is being retrieved
  • 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 accessControl/filters/shopify/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 shopifyShop model is the only required field, a select query is added to the hook so only the ID field is returned.

useActionFrom to write metafield data 

The useActionForm hook is used in the PrePurchaseForm component to manage the selected form state, and call the shopifyShop.savePrePurchaseProduct action:

frontend/ShopPage.jsx
jsx
1// the useActionForm Gadget React hook is used to call the savePrePurchaseProduct action on the shopifyShop model
2const PrePurchaseForm = ({ products, shop }) => {
3 // useActionForm used to handle form state and submission
4 const { submit, control, formState, error, setValue, watch } = useActionForm(api.shopifyShop.savePrePurchaseProduct, {
5 findBy: shop.id,
6 select: {
7 id: true,
8 prePurchaseProduct: true,
9 },
10 // send productId as a custom param
11 send: ["id", "productId"],
12 });
13
14 // use watch to listen for updates to the form state
15 const updateProductId = watch("shopifyShop.prePurchaseProduct");
16 // save as productId value in form state to send custom param
17 useEffect(() => {
18 setValue("productId", updateProductId);
19 }, [updateProductId]);
20
21 return (
22 <Form onSubmit={submit}>
23 <FormLayout>
24 {formState?.isSubmitSuccessful && <Banner title="Pre-purchase product saved!" tone="success" />}
25 {error && (
26 <Banner title="Error saving selection" tone="critical">
27 {error.message}
28 </Banner>
29 )}
30 {formState?.isLoading ? (
31 <SkeletonDisplayText size="large" />
32 ) : (
33 <Controller
34 name="shopifyShop.prePurchaseProduct"
35 control={control}
36 required
37 render={({ field }) => {
38 const { ref, ...fieldProps } = field;
39 return (
40 <Select
41 label="Product for pre-purchase offer"
42 placeholder="-No product selected-"
43 options={products}
44 disabled={formState.isSubmitting}
45 {...fieldProps}
46 />
47 );
48 }}
49 />
50 )}
51
52 <Button submit disabled={formState.isSubmitting} variant="primary">
53 Save
54 </Button>
55 </FormLayout>
56 </Form>
57 );
58};

Because we are sending a custom productId param with our action (and don't want to update prePurchaseProduct directly), we need to use the watch and setValue functions to update the productId param with the selected product ID. We also only send the id and productId fields to the action, so the prePurchaseProduct field is not updated.

More docs on useActionForm and the Controller component can be found in the @gadgetinc/react reference docs.

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

Step 4: 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/app@latest
yarn create @shopify/app
  • Select the Start by adding your first extension option
  • Once the app is generated, cd into the app's root
  • Generate a checkout UI extension:
terminal
npm run shopify app generate extension -- --template checkout_ui --name=pre-purchase-ext
yarn shopify app generate extension --template 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.extension.toml and the extension source: src/Checkout.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.extension.toml file. You need to define your metafield as input and allow access to the Storefront API.

  • Paste the following into shopify.extension.toml:
extensions/pre-purchase-ext/shopify.extension.toml
toml
1# Learn more about configuring your checkout UI extension:
2# https://shopify.dev/api/checkout-extensions/checkout/configuration
3
4# The version of APIs your extension will receive. Learn more:
5# https://shopify.dev/docs/api/usage/versioning
6api_version = "2023-07"
7
8[[extensions]]
9type = "ui_extension"
10name = "pre-purchase-ext"
11handle = "pre-purchase-ext"
12
13# Controls where in Shopify your extension will be injected,
14# and the file that contains your extension's source code. Learn more:
15# https://shopify.dev/docs/api/checkout-ui-extensions/unstable/targets-overview
16
17[[extensions.targeting]]
18module = "./src/Checkout.jsx"
19target = "purchase.checkout.block.render"
20
21[extensions.capabilities]
22# Gives your extension access to directly query Shopify's storefront API.
23# https://shopify.dev/docs/api/checkout-ui-extensions/unstable/configuration#api-access
24api_access = true
25
26# Loads metafields on checkout resources, including the cart,
27# products, customers, and more. Learn more:
28# https://shopify.dev/docs/api/checkout-ui-extensions/unstable/configuration#metafields
29
30[[extensions.metafields]]
31namespace = "gadget-tutorial"
32key = "pre-purchase-product"

The [[extensions.metafields]] definition allows you to pull in the metafield as input to your extension. The api_access [extensions.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/Checkout.jsx code file is provided, with additional details provided below the snippet.

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

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/ui-extensions-react/checkout package:

extensions/pre-purchase-ext/src/Checkout.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/Checkout.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/Checkout.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/Checkout.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
Extension not rendering?

Make sure the product you have selected to test the pre-purchase offer on is not already in the cart or the extension will not render!

Another possible issue: the product must have inventory available. If the product is out of stock, the extension will not render. Adjust inventory in your Shopify admin or select a different product to be offered.

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 publish your extension to Shopify

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 make requests to your Gadget backend from an extension?

You can install your Gadget client into Shopify extensions to make requests against your Gadget app's API. For more information, check out our documentation.

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.