Introduction to Shopify checkout UI extensions - how to create complementary product upsell component

Tomasz PosiadalaTomasz Posiadala
11 min read

Introduction

Today I would like to show you how to create a simple checkout UI extension that will allow your customers to purchase extra complementary products in the new Shopify checkout. This post intends to get you started with Shopify checkout UI extensions and to eventually add more on top of the component we are about to build together.

But first, we need to ask ourselves a question, what are checkout UI extensions? Let's see what Shopify documentation has to say:

Checkout UI extensions let app developers build custom functionality that merchants can install at defined points in the checkout flow, including product information, shipping, payment, order summary, and Shop Pay.\

Pretty simple and straight-to-the-point explanation if you ask me!

With that basic knowledge let's dive straight into building it!

Prerequisites

But before we begin make sure we have all we need:

Setup

First, we have to set up our app by running this command:

npm init @shopify/app@latest

We will need to give our app a name and choose the "Start by adding your first extensions" option.

This will generate a clean Shopify app extension template. The next step will be to generate an extension template, and run this command from the root of the project:

npm run generate extension

We want to create it as a 'new app' and give it a name

Finally, it's time to pick a type of our App, choose Checkout UI and TypeScript React template

This should create a new extensions folder for us with our clean template.

Setting up a dev server

To effectively work on our new extensions we need to start the dev server, to do that run npm run dev. You will be asked to choose the store on which you would like to view your project (select the one with checkout extensibility that you created). Once it connects press p in your terminal, which will open the developer console.

Now let's click on our connected extensions and done! We are all set to start work!

Jokes aside this wasn't so bad! Now we can start working on our extension.

Important files

There are two files that we are going to work with:

  1. /extensions/your-extension-name/shopify.extension.toml
# Learn more about configuring your checkout UI extension:
# https://shopify.dev/api/checkout-extensions/checkout/configuration

# The version of APIs your extension will receive. Learn more:
# https://shopify.dev/docs/api/usage/versioning
api_version = "2023-10"

[[extensions]]
type = "ui_extension"
name = "complementary-product-upsell"
handle = "complementary-product-upsell"

# Controls where in Shopify your extension will be injected,
# and the file that contains your extension’s source code. Learn more:
# https://shopify.dev/docs/api/checkout-ui-extensions/unstable/extension-targets-overview

[[extensions.targeting]]
module = "./src/Checkout.tsx"
target = "purchase.checkout.block.render"

[extensions.capabilities]
# Gives your extension access to directly query Shopify’s storefront API.
# https://shopify.dev/docs/api/checkout-ui-extensions/unstable/configuration#api-access
api_access = true

# Gives your extension access to make external network calls, using the
# JavaScript `fetch()` API. Learn more:
# https://shopify.dev/docs/api/checkout-ui-extensions/unstable/configuration#network-access
# network_access = true

# Loads metafields on checkout resources, including the cart,
# products, customers, and more. Learn more:
# https://shopify.dev/docs/api/checkout-ui-extensions/unstable/configuration#metafields

# [[extensions.metafields]]
# namespace = "my_namespace"
# key = "my_key"
# [[extensions.metafields]]
# namespace = "my_namespace"
# key = "my_other_key"

# Defines settings that will be collected from merchants installing
# your extension. Learn more:
# https://shopify.dev/docs/api/checkout-ui-extensions/unstable/configuration#settings-definition

# [extensions.settings]
# [[extensions.settings.fields]]
# key = "banner_title"
# type = "single_line_text_field"
# name = "Banner title"
# description = "Enter a title for the banner"
  1. /extensions/your-extension-name/src/Checkout.tsx

     import {
       Banner,
       useApi,
       useTranslate,
       reactExtension,
     } from '@shopify/ui-extensions-react/checkout';
    
     export default reactExtension(
       'purchase.checkout.block.render',
       () => <Extension />,
     );
    
     function Extension() {
       const translate = useTranslate();
       const { extension } = useApi();
    
       return (
         <Banner title="complementary-product-upsell">
           {translate('welcome', {target: extension.target})}
         </Banner>
       );
     }
    

The first one we will use to set different types of configurations related to our new extension(more on this later), and the second one will hold all the code related to our app extension.

Setting up extension target

To understand what checkout UI targets are please read this

At the moment our default extension is set to render at purchase.checkout.block.render target:

Let's change it, in our shopify.extension.toml file lets edit [[extension targeting]] piece of code to look like this:

[[extensions.targeting]]
module = "./src/Checkout.tsx"
target = "purchase.checkout.cart-line-list.render-after"

We will also need to update it inside our Checkout.tsx file:

export default reactExtension(
  'purchase.checkout.cart-line-list.render-after',
  () => <Extension />,
);

This will tell Shopify in which place of the checkout it should render our extension in our case it will be right under the cart items.

Implementing the UI of our extension

Let's start from the visual part and implement how we would like our extension to look like. Let's update our Checkout.tsx file again:

import {
  useApi,
  useTranslate,
  reactExtension,
  BlockStack,
  Checkbox,
  InlineLayout,
  Image,
  Text,
  Pressable,
  Heading,
  BlockSpacer,
  Divider,
} from '@shopify/ui-extensions-react/checkout';

export default reactExtension(
    'purchase.checkout.cart-line-list.render-after',
    () => <Extension />,
  );


function Extension() {
  const { extension } = useApi();

  return (
    <>
      <Divider />
      <BlockSpacer spacing={"base"}/>
      <Heading level={2}>You May also Like</Heading>
      <BlockSpacer spacing={"base"}/>

        <InlineLayout
          blockAlignment={"center"}
          spacing={["base", "base"]}
          columns={["auto", 80, "fill"]}
          padding={"base"}
        >
          <Checkbox checked={false} />
          <Image
            source={"[your-product-img-src]"}
            accessibilityDescription={"Alt Text"}
            border={"base"}
            borderRadius={"base"}
            borderWidth={"base"}
          />
          <BlockStack spacing="none">
            <Text>
              Product Title
            </Text>
            <Text size={'small'}>
              - Variant Data
            </Text>
            <BlockSpacer spacing={"tight"}/>
            <Text>
              20$
            </Text>
          </BlockStack>
        </InlineLayout>
    </>
  );
}

This is how our upsell product is going to look like, for more details on how each of those Shopify checkout UI build-in components work read this.

Getting the right data

The next step will be getting the correct data so we can populate our UI with the correct product. In our extension, we will use the last added product to the cart and display its complementary product set in the Search & Discovery app.

Let's update our Checkout.tsx file again:

import {
  useApi,
  useTranslate,
  reactExtension,
  BlockStack,
  Checkbox,
  InlineLayout,
  Image,
  Text,
  Pressable,
  Heading,
  BlockSpacer,
  Divider,
  useCartLines,
} from "@shopify/ui-extensions-react/checkout";
import { useEffect, useState } from "react";

export default reactExtension("purchase.checkout.cart-line-list.render-after", () => <Extension />);

interface ComplemenraryProductsIds {
  metafield: {
    value: string;
  };
}

interface ComplemenraryProductsIdsArray {
  products: string[];
}

interface ComplemenraryProductData {
  title: string;
  featuredImage: {
    altText: string;
    id: string;
    originalSrc: string;
  };
  variants: {
    edges: {
      node: {
        id: string;
        title: string;
        priceV2: {
          amount: string;
          currencyCode: string;
        };
        image: {
          originalSrc: string;
          altText: string;
        };
      };
    }[];
  };
}

function Extension() {
  const { query } = useApi();

  // States
  const [lastCartItemID, setLastItemId] = useState<string>(null);
  const [complementaryProductsIds, setComplementaryProductsIds] = useState<ComplemenraryProductsIdsArray>(null);
  const [complementaryProductData, setComplementaryProductData] = useState<ComplemenraryProductData>(null);

  const cart = useCartLines();

  // Getting last item ID
  useEffect(() => {
    if (cart.length > 0) {
      setLastItemId(cart[cart.length - 1].merchandise.product.id);
    }
  }, []);

  // Fetching complementary products IDs and setting it to state
  async function getComplementaryProductsIds(productID: string) {
    const complementaryProducts = await query<{ node: ComplemenraryProductsIds }>(`{
      node(id: "${productID}") {
        ... on Product {
          metafield(namespace:"shopify--discovery--product_recommendation", key:"complementary_products") {
            value
          }
        }
      }
    }`);

    if (!complementaryProducts.data.node.metafield) return null;
    setComplementaryProductsIds(JSON.parse(complementaryProducts.data.node.metafield.value));
  }

  // Fetching complementary product data and setting it to state
  async function getComplementaryProductData(productID: string) {
    const productData = await query<{ node: ComplemenraryProductData }>(`{
      node(id: "${productID}") {
        ... on Product {
          title
          featuredImage {
            altText
            id
            originalSrc
          }
          variants(first: 1){
            edges {
              node {
                id
                title
                priceV2 {
                  amount
                  currencyCode
                }
                image {
                  originalSrc
                  altText
                }
              }
            }
          }
        }
      }
    }`);

    if (productData) {
      setComplementaryProductData(productData.data.node);
    }
  }

  // Fetching Product Data on cart change
  useEffect(() => {
    if (!lastCartItemID) return;
    getComplementaryProductsIds(lastCartItemID);
  }, [lastCartItemID]);

  useEffect(() => {
    if (!complementaryProductsIds) return;
    getComplementaryProductData(complementaryProductsIds[0]);
  }, [complementaryProductsIds]);

  console.log(complementaryProductData);

  return (
    <>
      <Divider />
      <BlockSpacer spacing={"base"} />
      <Heading level={2}>You May also Like</Heading>
      <BlockSpacer spacing={"base"} />

      <InlineLayout
        blockAlignment={"center"}
        spacing={["base", "base"]}
        columns={["auto", 80, "fill"]}
        padding={"base"}
      >
        <Checkbox checked={false} />
        <Image
          source={
            "https://cdn.shopify.com/s/files/1/0759/5530/6798/products/Main_0a40b01b-5021-48c1-80d1-aa8ab4876d3d.jpg?v=1683210858"
          }
          accessibilityDescription={"Alt Text"}
          border={"base"}
          borderRadius={"base"}
          borderWidth={"base"}
        />
        <BlockStack spacing="none">
          <Text>Product Title</Text>
          <Text size={"small"}>- Variant Data</Text>
          <BlockSpacer spacing={"tight"} />
          <Text>20$</Text>
        </BlockStack>
      </InlineLayout>
    </>
  );
}

A lot has changed! Let's break it down step by step.

First, we will use the useCartLines hook to get the last item added to the cart and write to our newly created useState hook

Next, we define two functions: getComplementaryProductsIds and getComplementaryProductData. The first one will fetch ID's of our last cart item complementary products using shopify--discovery--product_recommendation
metafield se by the S&D app. The second one will fetch product details of the first complementary product set in that meta field.

Now if we console.log(complementaryProductData) we should be presented with our complementary product. If not make sure the S&D app is set up correctly.

Excellent! Now let's display our data in the previously set up UI. Let's replace our return statement with below code:

  if (!complementaryProductData) return null;

  return (
    <>
      <Divider />
      <BlockSpacer spacing={"base"} />
      <Heading level={2}>You May also Like</Heading>
      <BlockSpacer spacing={"base"} />

      <InlineLayout
        blockAlignment={"center"}
        spacing={["base", "base"]}
        columns={["auto", 80, "fill"]}
        padding={"base"}
      >
        <Checkbox checked={false} />
        <Image
          source={
            complementaryProductData.featuredImage
              ? complementaryProductData.variants.edges[0].node.image.originalSrc
              : complementaryProductData.featuredImage.originalSrc
          }
          accessibilityDescription={"Alt Text"}
          border={"base"}
          borderRadius={"base"}
          borderWidth={"base"}
        />
        <BlockStack spacing="none">
          <Text>{complementaryProductData?.title}</Text>
          {complementaryProductData.variants.edges[0].node.title && (
            <Text size={"small"}>- {complementaryProductData.variants.edges[0].node.title}</Text>
          )}
          <BlockSpacer spacing={"tight"} />
          <Text>
            {complementaryProductData.variants.edges[0].node.priceV2.amount}
            {complementaryProductData.variants.edges[0].node.priceV2.currencyCode}
          </Text>
        </BlockStack>
      </InlineLayout>
    </>
  );
}

Smashing job so far! At this point, we should successfully see our product displayed in the checkout.

At this point you probably noticed the checkbox in our component, we will now attempt to add our complementary product to the cart when checked and remove it when unchecked.

We will set another state variable and utilize the Pressable component to update it each time our product card is pressed. We will also pass our isSelected state to Checkbox component checked attribute to make sure our state is reflected in our UI.

import {
  ...
  Pressable,
  ...
} from "@shopify/ui-extensions-react/checkout";

export default reactExtension("purchase.checkout.cart-line-list.render-after", () => <Extension />);

...

function Extension() {

  ...

  const [isSelected, setIsSelected] = useState<boolean>(false)

  ...

  if (!complementaryProductData) return null;

  return (
    <>
      <Divider />
      <BlockSpacer spacing={"base"} />
      <Heading level={2}>You May also Like</Heading>
       {/* Pressable component that updates state onPress */}
      <Pressable onPress={() => setIsSelected(!isSelected)}>
        <BlockSpacer spacing={"base"} />

        <InlineLayout
          blockAlignment={"center"}
          spacing={["base", "base"]}
          columns={["auto", 80, "fill"]}
          padding={"base"}
        >
          {/* Passing isSelected state to our checkbox */}
          <Checkbox checked={isSelected} />
          <Image
            source={
              complementaryProductData.featuredImage
                ? complementaryProductData.variants.edges[0].node.image.originalSrc
                : complementaryProductData.featuredImage.originalSrc
            }
            accessibilityDescription={"Alt Text"}
            border={"base"}
            borderRadius={"base"}
            borderWidth={"base"}
          />
          <BlockStack spacing="none">
            <Text>{complementaryProductData?.title}</Text>
            {complementaryProductData.variants.edges[0].node.title && (
              <Text size={"small"}>- {complementaryProductData.variants.edges[0].node.title}</Text>
            )}
            <BlockSpacer spacing={"tight"} />
            <Text>
              {complementaryProductData.variants.edges[0].node.priceV2.amount}
              {complementaryProductData.variants.edges[0].node.priceV2.currencyCode}
            </Text>
          </BlockStack>
        </InlineLayout>
      </Pressable>
    </>
  );
}

Great! now that we know that the user clicked our product and we track this in our state we can finally add the product to the cart. To achieve that we will have to use the useApplyCartLinesChange hook provided by Shopify.

Let's update out Checkout.tsx file one last time, it should look like this:

import {
  useApi,
  useTranslate,
  reactExtension,
  BlockStack,
  Checkbox,
  InlineLayout,
  Image,
  Text,
  Pressable,
  Heading,
  BlockSpacer,
  Divider,
  useCartLines,
  useApplyCartLinesChange,
} from "@shopify/ui-extensions-react/checkout";
import { useEffect, useState } from "react";

export default reactExtension("purchase.checkout.cart-line-list.render-after", () => <Extension />);

interface ComplemenraryProductsIds {
  metafield: {
    value: string;
  };
}

interface ComplemenraryProductsIdsArray {
  products: string[];
}

interface ComplemenraryProductData {
  title: string;
  featuredImage: {
    altText: string;
    id: string;
    originalSrc: string;
  };
  variants: {
    edges: {
      node: {
        id: string;
        title: string;
        priceV2: {
          amount: string;
          currencyCode: string;
        };
        image: {
          originalSrc: string;
          altText: string;
        };
      };
    }[];
  };
}

function Extension() {
  const { query } = useApi();

  // States
  const [lastCartItemID, setLastItemId] = useState<string>(null);
  const [complementaryProductsIds, setComplementaryProductsIds] = useState<ComplemenraryProductsIdsArray>(null);
  const [complementaryProductData, setComplementaryProductData] = useState<ComplemenraryProductData>(null);
  const [isSelected, setIsSelected] = useState<boolean>(false);

  const cart = useCartLines();
  const applyCartLinesChange = useApplyCartLinesChange();

  // Getting last item ID
  useEffect(() => {
    if (cart.length > 0) {
      setLastItemId(cart[cart.length - 1].merchandise.product.id);
    }
  }, []);

  // Fetching complementary products IDs and setting it to state
  async function getComplementaryProductsIds(productID: string) {
    const complementaryProducts = await query<{ node: ComplemenraryProductsIds }>(`{
      node(id: "${productID}") {
        ... on Product {
          metafield(namespace:"shopify--discovery--product_recommendation", key:"complementary_products") {
            value
          }
        }
      }
    }`);

    if (!complementaryProducts.data.node.metafield) return null;
    setComplementaryProductsIds(JSON.parse(complementaryProducts.data.node.metafield.value));
  }

  // Fetching complementary product data and setting it to state
  async function getComplementaryProductData(productID: string) {
    const productData = await query<{ node: ComplemenraryProductData }>(`{
      node(id: "${productID}") {
        ... on Product {
          title
          featuredImage {
            altText
            id
            originalSrc
          }
          variants(first: 1){
            edges {
              node {
                id
                title
                priceV2 {
                  amount
                  currencyCode
                }
                image {
                  originalSrc
                  altText
                }
              }
            }
          }
        }
      }
    }`);

    if (productData) {
      setComplementaryProductData(productData.data.node);
    }
  }

  // Fetching Product Data on cart change
  useEffect(() => {
    if (!lastCartItemID) return;
    getComplementaryProductsIds(lastCartItemID);
  }, [lastCartItemID]);

  useEffect(() => {
    if (!complementaryProductsIds) return;
    getComplementaryProductData(complementaryProductsIds[0]);
  }, [complementaryProductsIds]);

  useEffect(() => {
    if (!complementaryProductData) return;

    if (isSelected) {
      applyCartLinesChange({
        type: "addCartLine",
        merchandiseId: complementaryProductData.variants.edges[0].node.id,
        quantity: 1,
      });
    } else {
      const cartLineId = cart.find(
        (cartLine) => cartLine.merchandise.id === complementaryProductData.variants.edges[0].node.id
      )?.id;

      if (cartLineId) {
        applyCartLinesChange({
          type: "removeCartLine",
          id: cartLineId,
          quantity: 1,
        });
      }
    }
  }, [isSelected]);

  console.log(complementaryProductData);

  if (!complementaryProductData) return null;

  return (
    <>
      <Divider />
      <BlockSpacer spacing={"base"} />
      <Heading level={2}>You May also Like</Heading>
      {/* Pressable component */}
      <Pressable onPress={() => setIsSelected(!isSelected)}>
        <BlockSpacer spacing={"base"} />

        <InlineLayout
          blockAlignment={"center"}
          spacing={["base", "base"]}
          columns={["auto", 80, "fill"]}
          padding={"base"}
        >
          <Checkbox checked={isSelected} />
          <Image
            source={
              complementaryProductData.featuredImage
                ? complementaryProductData.variants.edges[0].node.image.originalSrc
                : complementaryProductData.featuredImage.originalSrc
            }
            accessibilityDescription={"Alt Text"}
            border={"base"}
            borderRadius={"base"}
            borderWidth={"base"}
          />
          <BlockStack spacing="none">
            <Text>{complementaryProductData?.title}</Text>
            {complementaryProductData.variants.edges[0].node.title && (
              <Text size={"small"}>- {complementaryProductData.variants.edges[0].node.title}</Text>
            )}
            <BlockSpacer spacing={"tight"} />
            <Text>
              {complementaryProductData.variants.edges[0].node.priceV2.amount}
              {complementaryProductData.variants.edges[0].node.priceV2.currencyCode}
            </Text>
          </BlockStack>
        </InlineLayout>
      </Pressable>
    </>
  );
}

First, you should notice that we assign our newly used hook to a variable:

const applyCartLinesChange = useApplyCartLinesChange();

with that, we can create another useEffect that will run each time isSelected state changes. Then we will check if a product has been added to the cart or not and update the cart accordingly.

Next Steps:

This tutorial ends here but I would encourage you to:

  • Add useTranslate hook to translate copy on our extension.

  • Don't show our extension if the complementary product has already been added to the cart

  • Try to display more than one complementary product pulled from the S&D app

  • Utilize the useSettings hook to allow shop owners to update the title above our product

  • deploy your extensions using deploy script and install it on your store

Finally just wanted to say thank you for getting to the end :) I am still new to checkout UI extensions but I found those to be a very interesting way to interact with Shopify checkout and I hope you too after reading this guide!

0
Subscribe to my newsletter

Read articles from Tomasz Posiadala directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Tomasz Posiadala
Tomasz Posiadala

I am a developer from Poland living in Scotland and currently working for one of the biggest Shopify agencies in the country. I am passionate about React, Remix, Shopify, and Front-end development in general.