Build a pre-purchase Shopify checkout UI extension 

Expected time: 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. A snowboard is being offered in the checkout. The product name and variant price are visible, along with an Add button available for buyers to add the item to their cart
Requirements

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

Step 1: Create a Gadget app and connect to Shopify 

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

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

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

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

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

Click on Apps link in Shopify Partners Dashboard
  1. Click the Create App button
Click on Create app button
  1. Click the Create app manually button and enter a name for your Shopify app
Shopify's app creation landing page in the Partners Dashboard
  1. Click on Settings in the side nav bar
  2. Click on Plugins in the modal that opens
  3. Select Shopify from the list of plugins and connections
The Gadget homescreen, with the Connections link highlighted
  1. Copy the Client ID and Client secret from your newly created Shopify app and paste the values into the Gadget Connections page
Screenshot of the Partners card selected on the Connections page
  1. Click Connect on the Gadget Connections page to move to scope and model selection

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

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

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

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

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

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

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

Click Install app to authorize our Gadget app with our store

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

App installation was a success

Set up is complete! We are now ready to sync Shopify data into our Gadget app database.

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

  1. Go back to our Gadget Connections page and click on the Shop Installs button for the added app in the Shopify Apps section
  2. A screenshot of the Connections page with the Shop Installs button highlighted
  3. 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:

  1. Click on the api/models/shopifyProduct/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.

  1. Click on api/models/shopifyShop/schema
  2. Click + in the FIELDS section of the schema page and give the field the identifier prePurchaseProduct
  3. Check the Store data from Shopify Metafield option
  4. Enter the metafield namespace: gadget-tutorial
  5. Click the Register Namespace button
  6. Enter your metafield key pre-purchase-product
  7. 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 

  1. Click + in the api/models/shopifyShop/actions folder to create a new action and name the new file savePrePurchaseProduct.js
  2. Paste the following code snippet into api/models/shopifyShop/actions/savePrePurchaseProduct.js to update the onSuccess function of the action:
api/models/shopifyShop/actions/savePrePurchaseProduct.js
JavaScript
1import {
2 applyParams,
3 preventCrossShopDataAccess,
4 ActionOptions,
5 SavePrePurchaseProductShopifyShopActionContext,
6} from "gadget-server";
7
8/**
9 * @param { SavePrePurchaseProductShopifyShopActionContext } context
10 */
11export async function run({ params, record, logger, connections }) {
12 applyParams(params, record);
13 await preventCrossShopDataAccess(params, record);
14
15 // get the product id passed in as a custom param
16 const { productId } = params;
17
18 // save the selected pre-purchase product in a SHOP-owned metafield
19 // https://www.npmjs.com/package/shopify-api-node#metafields
20 const response = await connections.shopify.current?.metafield.create({
21 key: "pre-purchase-product",
22 namespace: "gadget-tutorial",
23 owner_id: record.id,
24 type: "product_reference",
25 value: productId,
26 });
27
28 // print to the Gadget Logs
29 logger.info({ response }, "add metafields response");
30}
31
32// define a productId custom param for this action
33export const params = {
34 productId: { type: "string" },
35};
36
37/** @type { ActionOptions } */
38export const options = {
39 actionType: "custom",
40 triggers: { api: true },
41};

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 modify the access control settings for your new action:

  1. Check the shopify-app-users box in the ACCESS CONTROL section to the right of the editor

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.

  1. Copy and paste this snippet into web/routes/index.jsx:
web/routes/index.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
64export default function () {
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}

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 web/api.js and handles auth and session token management for you. This client is imported into index.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:

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!

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:

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 

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. Thankfully, you can build your extensions in your Gadget project.

  1. Open the Gadget command palette with P or Ctrl P
  2. Type > to enable terminal mode
  3. Run the following command:
Run in the Gadget command palette
yarn add -D @shopify/cli

Once the install is finished, modify your package.json file:

  1. Add a workspaces field with the value ["extensions/*"], and a trustedDependencies field with the value ["@shopify/plugin-cloudflare"] to the root of your package.json file:
package.json
json
{
"workspaces": ["extensions/*"],
"trustedDependencies": ["@shopify/plugin-cloudflare"]
}
  1. Create an empty shopify.app.toml file at the root level of your project
  2. Add additional scripts to your package.json file. You can paste this over the existing scripts:
package.json
json
1{
2 "scripts": {
3 "vite:build": "NODE_ENV=production vite build",
4 "shopify": "shopify",
5 "build": "shopify app build",
6 "dev": "shopify app dev",
7 "info": "shopify app info",
8 "generate": "shopify app generate",
9 "deploy": "shopify app deploy"
10 }
11}

Your package.json should look something like this:

package.json
json
1{
2 "name": "<your-app-package>",
3 "version": "0.1.0",
4 "description": "Internal package for Gadget app <your-app-package> (Development environment)",
5 "license": "UNLICENSED",
6 "private": true,
7 "scripts": {
8 "vite:build": "NODE_ENV=production vite build",
9 "shopify": "shopify",
10 "build": "shopify app build",
11 "dev": "shopify app dev",
12 "info": "shopify app info",
13 "generate": "shopify app generate",
14 "deploy": "shopify app deploy"
15 },
16 "dependencies": {
17 "@gadget-client/<your-app-package>": "link:.gadget/client",
18 "@gadgetinc/react": "^0.15.4",
19 "@gadgetinc/react-shopify-app-bridge": "^0.14.2",
20 "@shopify/app-bridge-react": "4.0.0",
21 "@shopify/polaris": "^12.0.0",
22 "@shopify/polaris-icons": "^8.1.0",
23 "gadget-server": "link:.gadget/server",
24 "react": "^18.2.0",
25 "react-dom": "^18.2.0",
26 "react-router-dom": "6.15.0",
27 "shopify-api-node": "^3.12.6"
28 },
29 "devDependencies": {
30 "@shopify/app": "^3.57.1",
31 "@shopify/cli": "^3.57.1",
32 "@types/node": "^20.8.4",
33 "@types/react": "^18.0.28",
34 "@types/react-dom": "^18.0.11",
35 "@vitejs/plugin-react-swc": "3.2.0",
36 "typescript": "^5.4.5",
37 "vite": "^4.4.9"
38 },
39 "workspaces": ["extensions/*"],
40 "trustedDependencies": ["@shopify/plugin-cloudflare"]
41}

Now pull down your Gadget project to your local machine to start building your extension. You can use ggt, Gadget's CLI to work on your Gadget app locally.

  1. In your local terminal, run the following command replacing <YOUR APP DOMAIN>:
terminal
npx ggt@latest dev ~/gadget/<YOUR APP DOMAIN> --app=<YOUR APP DOMAIN> --env=development
ggt

You can also click the cloud icon next to your environment selector in the Gadget editor to get your ggt command. See the ggt guide for more info on working locally.

ggt dev will run in the background. It pushes any code changes you make locally to your hosted Gadget environment, and pulls down any changes you make in the Gadget editor to your local machine. Make sure you leave it running while you work on your extension in your local editor.

  1. cd into your project, and open it in an editor
  2. Add a .ignore file to the root of your project and add the following:
.ignore
extensions/*/dist
  1. Use yarn to generate your checkout UI extension:
terminal
yarn generate extension --template checkout_ui --name=pre-purchase-ext
  1. Select the same Partner app and development store you used to connect to Shopify when prompted by Shopify's CLI
  2. Select JavaScript React when prompted for the extension language

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

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.

  1. Paste the following into extensions/pre-purchase-ext/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 = "2024-01"
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 of small modifications. The entire src/Checkout.jsx code file is provided, with additional details provided below the snippet.

  1. Paste the following into your extension's extensions/pre-purchase-ext/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 through a step-by-step build of the extension in their tutorial. The differences will be highlighted here. 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]);

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.

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.

Test your extension 

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

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

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

A screenshot of the extension rendered in a Shopify checkout
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.

  1. Stop the dev command you ran in the previous step and run yarn deploy to publish your extension to Shopify
terminal
yarn deploy
  1. When prompted, select Yes, always to include the shopify.app.toml configuration on deploy
  2. Select Yes when prompted to release a new version of the extension

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

  1. Go to your development store
  2. Click Settings in the store admin
  3. Click on the Checkout option
  4. Click the Customize button for the current theme
  5. Click Add app block in the bottom of the Information panel of the checkout editor
  6. Select your extension, and drag and drop it in the checkout editor to place it
  7. Click Save in the top right corner of the checkout editor

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.