Building Shopify app extensions
Prerequisites
Before building Shopify extensions, you need to:
Working with extensions
You can build your Shopify app extensions inside your Gadget app using the Shopify CLI and ggt
, Gadget's CLI.
- In your local terminal, run the
ggt dev
command replacing<YOUR APP DOMAIN>
to pull down your app to your local machine:
terminalggt dev ./<YOUR APP DOMAIN> --app=<YOUR APP DOMAIN> --env=development
You can also click the cloud icon next to your environment selector in the Gadget editor to get your app's ggt dev
command. See the
ggt guide for more info on working locally.
cd
into your project, and open it in an editor- Create an empty
shopify.app.toml
file at the root level of your project - Add the following
workspaces
andtrustedDependencies
to yourpackage.json
:
package.jsonjson{"workspaces": ["extensions/*"],"trustedDependencies": ["@shopify/plugin-cloudflare"]}
Once you add the workspaces
definition to your package.json
, you will need to use the -W
flag to add new packages to your core Gadget app:
terminalyarn add -W <package>
This is required by Yarn workspaces to ensure that all packages are installed in the correct location.
- Add a
.ignore
file to the root of your project - Add the following to both
.ignore
(and.gitignore
if you are using source control):
add to .ignore and .gitignoreextensions/*/distextensions/*/node_modulesextensions/*/generated
- Use the Shopify CLI to generate your checkout UI extension:
terminalshopify app generate extension
The following steps are for admin, checkout, or customer account extensions. For theme app extensions, see the theme app extensions section.
- Select the same Partner app and development store you used to connect to Shopify when prompted by Shopify's CLI
- Select an extension type and a language for your extension
This command will create an extensions
folder at your project root, and your extension will be generated by the Shopify CLI.
- Start your extension development server by running:
terminalshopify app dev
If you are porting over an existing extension-only app and you are copying over your root-level app configuration shopify.app.toml
, you
need to make sure use_legacy_install_flow = true
is set in the [access_scopes]
section so Gadget can manage scope registration.
Using Shopify metafields as input
You can use Shopify metafields to store and retrieve custom data. This has the added benefit of being stored on Shopify's infrastructure, so you don't need to manage stored values in your Gadget database.
You do have the option to store metafield data in your Gadget database if it is required for your app. If you need access to metafield data in Gadget, you can add metadata fields to your Shopify data models.
Metafields are the only way to use custom data as input in some extensions, for example, most Shopify Functions.
Make a network request to your Gadget API
In some extensions, you can also send a request to your app's API to run custom backend code and return the data you need. This is useful if you need to run custom logic to generate the data you need.
Before you write any network requests, you'll need to set network_access = true
in your extension's shopify.extension.toml
file. Some extensions, such as Admin extensions, already allow you to make requests to your app backend, and don't require this setting.
Other extension types may not allow for network access. Check Shopify's documentation for the extension type you're working with to see if network access is allowed.
You can install your Gadget app API client into your extensions to make requests to your app's API.
- Make sure you have access to Gadget's npm registry:
terminalnpm config set @gadget-client:registry https://registry.gadget.dev/npm
- Install your app's unique API client:
terminalyarn add @gadget-client/YOUR-GADGET-APP-DOMAIN
- Set up a new API client instance in your extension, for example:
extensions/your-extension-name/src/api.jsJavaScriptimport { Client } from "@gadget-client/<YOUR-GADGET-APP-DOMAIN>";export const api = new Client();
Using @gadgetinc
React hooks
The @gadgetinc/react
hooks, such as useFindMany
, useAction
, and useFetch
, can be used to interact with your app's API.
- Install the
@gadgetinc/react
package:
terminalyarn add @gadgetinc/react
- Set up the
Provider
in your extension by wrapping the exported extension component or app with theProvider
component and passing in your API client instance:
extensions/your-extension-name/src/Extension.jsxJavaScript1import { Provider } from "@gadgetinc/react";2import { api } from "./api";34export default reactExtension(TARGET, () => (5 <Provider api={api}>6 <App />7 </Provider>8));
Now you can use the @gadgetinc/react
hooks to interact with your app's API.
Admin extensions
By default, Shopify's Admin extensions will add an Authentication
header to requests made by the extension.
Your Gadget app will automatically handle these incoming requests, and grant them the shopify-app-users
role. This means you can use your api
client like you would in an embedded admin frontend, with or without the @gadgetinc/react
hooks.
Here's an example of a simple Admin extension making an authenticated request to a custom updateDescription
action on the shopifyProduct
model:
extensions/your-extension-name/src/ActionExtension.jsxJavaScript1import { useCallback, useState } from "react";2import {3 reactExtension,4 useApi,5 AdminAction,6 BlockStack,7 Button,8 Text,9 NumberField,10} from "@shopify/ui-extensions-react/admin";11// import app API client12import { api } from "./api";13import { Provider, useAction } from "@gadgetinc/react";1415// The target used here must match the target used in the extension's toml file (./shopify.extension.toml)16const TARGET = "admin.product-details.action.render";1718// set up the Provider component so React hooks can be used19export default reactExtension(TARGET, () => (20 <Provider api={api}>21 <App />22 </Provider>23));2425function App() {26 // The useApi hook provides access to several useful APIs like i18n, close, and data.27 const {28 extension: { target },29 i18n,30 close,31 data,32 } = useApi(TARGET);3334 const [wordCount, setWordCount] = useState("100");3536 // custom action in Gadget that updates the product description37 // using OpenAI to generate a description based on the word count and product images38 const [_, updateDescription] = useAction(api.shopifyProduct.updateDescription);3940 const update = useCallback(async () => {41 // get current product id from data42 // remove the shopifyProduct gid prefix from the id43 const productId = data.selected[0].id.split("/").pop();44 // fire request to update the product description in Gadget45 await updateDescription({46 id: productId,47 wordCount,48 });49 });5051 // The AdminAction component provides an API for setting the title and actions of the Action extension wrapper.52 return (53 <AdminAction54 primaryAction={55 <Button56 onPress={() => {57 update();58 close();59 }}60 >61 {i18n.translate("updateDescription")}62 </Button>63 }64 secondaryAction={65 <Button66 onPress={() => {67 close();68 }}69 >70 {i18n.translate("close")}71 </Button>72 }73 loading={fetching}74 >75 <BlockStack gap="large">76 <Text fontWeight="bold">{i18n.translate("welcome", { target })}</Text>77 <NumberField78 label="Select a word count"79 value={wordCount}80 onChange={setWordCount}81 />82 </BlockStack>83 </AdminAction>84 );85}
Checkout extensions
Checkout extensions are making network requests from an unauthenticated context, the Shopify checkout. This means that requests made to your app's API will be granted the unauthenticated
role. Make sure any data passed into the checkout extensions is safe to be seen by any buyer!
Custom apps
For custom apps where you do not need multi-tenancy per shop, you can make requests using the API client:
extensions/your-extension-name/src/Checkout.jsxJavaScript1import { Banner, reactExtension } from "@shopify/ui-extensions-react/checkout";2import { Provider, useGlobalAction } from "@gadgetinc/react";3// import your app API client4import { api } from "../api";56// set up the Provider component so React hooks can be used7export default reactExtension("purchase.checkout.block.render", () => (8 <Provider api={api}>9 <Extension />10 </Provider>11));1213function Extension() {14 // use hooks to call your API15 // in this case, a global action16 const [{ data, error, fetching }, refresh] = useGlobalAction(17 api.myCustomGlobalAction18 );1920 if (fetching) {21 return <Banner>Loading...</Banner>;22 }2324 if (error) {25 return <Banner>Error loading. Please try again.</Banner>;26 }2728 return <Banner>{data.value}</Banner>;29}
Public apps
You can still enforce shop multi-tenancy by passing the Shopify session token with your request.
Sending the session token
When you send Shopify's session token to Gadget, you need to use the ShopifySessionToken
prefix in the Authorization
header. This is ensures that your Gadget actions have the correct shop context.
Gadget provides a @gadgetinc/shopify-extensions
package you can install into your extension that makes it easy to add the session token as a header to all requests made using your Gadget app's API client.
1import {2 reactExtension,3 useApi,4} from "@shopify/ui-extensions-react/customer-account";5import { Provider, useGadget } from "@gadgetinc/shopify-extensions/react";6import { useFindMany } from "@gadgetinc/react";7import { Client } from "@gadget-client/example-app";89// initialize a new Client for your Gadget API10const api = new Client();1112// the Provider is set up in the reactExtension() initialization function13export default reactExtension("your.extension.target", () => <GadgetUIExtension />);1415// component to set up the Provider with the sessionToken from Shopify16function GadgetUIExtension() {17 const { sessionToken } = useApi();1819 return (20 <Provider api={api} sessionToken={sessionToken}>21 <MyExtension />22 </Provider>23 );24}2526function MyExtension() {27 // get the 'api' client and a 'ready' boolean from the useGadget hook28 const { api, ready } = useGadget();2930 const [{ data, fetching, error }] = useFindMany(api.customModel, {31 // use 'ready' to pause hooks until the API client is ready to make authenticated requests32 pause: !ready,33 });3435 // the rest of your extension component...36}
If you aren't using your app's API client, this example shows how to send the session token in a fetch
request when reading model data using a findOne
query:
extensions/your-extension-name/src/Checkout.jsxJavaScript1import {2 Banner,3 reactExtension,4 useApi,5} from "@shopify/ui-extensions-react/checkout";6import { useState, useEffect } from "react";78export default reactExtension("purchase.checkout.block.render", () => <Extension />);910function Extension() {11 // get the session token from the useApi hook12 const { sessionToken } = useApi();13 const [productData, setProductData] = useState(null);1415 useEffect(() => {16 // Specify the GraphQL endpoint17 const url = "https://my-extension-app--development.gadget.dev/api/graphql";1819 // Create a GraphQL query20 const query = `21 query GetOneShopifyProduct($id: GadgetID!) {22 shopifyProduct(id: $id) {23 title24 }25 }26 `;2728 // get the session token29 async function getToken() {30 const token = await sessionToken.get();31 return token;32 }3334 // use fetch to make a POST request to the GraphQL endpoint35 getToken().then((token) => {36 fetch(url, {37 method: "POST",38 headers: {39 "Content-Type": "application/json",40 Accept: "application/json",41 // pass the session token using the Authorization header42 Authorization: `ShopifySessionToken ${token}`,43 },44 body: JSON.stringify({ query: query }),45 })46 .then((response) => response.json())47 .then((jsonData) => {48 // handle the returned data49 setProductData(jsonData.data.product);50 })51 .catch((error) => console.error("Error:", error));52 });53 }, [sessionToken]);5455 return <Banner>{productData.title}</Banner>;56}
Post-purchase extensions
Post-purchase extensions are a type of checkout extension that requires a JSON Web Token (JWT) to be signed and passed to the extension. This signing can be done in your app backend by passing the JWT from the extension to Gadget as an Authorization: Bearer
header.
For example, in your post-purchase extension, you can make a request to get offers and determine if you should render the extension:
extensions/your-extension-name/src/index.jsxJavaScript1/**2 * Extend Shopify Checkout with a custom Post Purchase user experience.3 * This template provides two extension points:4 *5 * 1. ShouldRender - Called first, during the checkout process, when the6 * payment page loads.7 * 2. Render - If requested by `ShouldRender`, will be rendered after checkout8 * completes9 */10// other imports such as React state hooks and extension components are omitted for brevity11import React from "react";12import { extend, render } from "@shopify/post-purchase-ui-extensions-react";13// your app API client14import { api } from "./api";1516/**17 * Entry point for the `ShouldRender` Extension Point.18 *19 * Returns a value indicating whether or not to render a PostPurchase step, and20 * optionally allows data to be stored on the client for use in the `Render`21 * extension point.22 */23extend("Checkout::PostPurchase::ShouldRender", async ({ inputData, storage }) => {24 // get the variant ids of the products in the initial purchase25 const productVariantIds = inputData.initialPurchase.lineItems.map(26 (lineItem) => lineItem.product.variant.id27 );2829 // make request against POST-offer route in Gadget30 const response = await api.fetch("/offer", {31 method: "POST",32 headers: {33 "Content-Type": "application/json",34 Authorization: `Bearer ${inputData.token}`,35 },36 body: JSON.stringify({37 referenceId: inputData.initialPurchase.referenceId,38 productVariantIds,39 }),40 });4142 // get response body from route43 const jsonResp = await response.json();44 // save offers to extension storage45 await storage.update({ offers: jsonResp.offers });4647 // For local development, always show the post-purchase page48 return { render: true };49});5051render("Checkout::PostPurchase::Render", () => <App />);5253export function App() {54 // the rest of the post-purchase extension component55 // determine what is actually rendered in this component56}
And your Gadget POST-offer
HTTP route could look like:
api/routes/POST-offer.jsJavaScript1import { RouteContext } from "gadget-server";2import jwt from "jsonwebtoken";3import { getOffers } from "../utils/offerUtils";45/**6 * Route handler for POST offer7 *8 * @param { RouteContext } route context - see: https://docs.gadget.dev/guides/http-routes/route-configuration#route-context9 *10 */11export default async function route({ request, reply, api, logger, connections }) {12 let token = request.headers?.authorization;13 if (token?.startsWith("Bearer ")) {14 token = token.slice(7);15 } else {16 // if no bearer token is present, return 401 error17 await reply.code(401).send();18 }1920 // use SHOPIFY_API_SECRET (from Partners app) as an environment variable to decode the token21 const decodedToken = jwt.verify(token, process.env["SHOPIFY_CLIENT_SECRET"]);2223 // get the referenceId from the decoded token24 const decodedReferenceId = decodedToken.input_data.initialPurchase.referenceId;2526 const { referenceId, productVariantIds } = request.body;2728 if (decodedReferenceId !== referenceId) {29 // return error if incoming jwt is not valid30 await reply.code(401).send();31 }3233 // fetch custom offers34 const offers = await getOffers({ api, logger, connections, productVariantIds });3536 // reply with the offers37 await reply.headers({ "Content-type": "application/json" }).send({ offers });38}
You will also need a POST-sign-changeset
HTTP route in your Gadget app to apply the order changes if a buyer accepts the offer:
JavaScript1// api/routes/POST-sign-changeset.js2import { RouteContext } from "gadget-server";3import { v4 as uuidv4 } from "uuid";4import jwt from "jsonwebtoken";56/**7 * Route handler for POST sign-changeset8 *9 * @param { RouteContext } route context - see: https://docs.gadget.dev/guides/http-routes/route-configuration#route-context10 *11 */12export default async function route({ request, reply, api, logger, connections }) {13 // get token from headers14 let token = request.headers?.authorization;15 if (token?.startsWith("Bearer ")) {16 token = token.slice(7);17 } else {18 // if no bearer token is present, return 401 error19 await reply.code(401).send();20 }2122 // use SHOPIFY_API_SECRET (from Partners app) as an environment variable to decode the token23 const decodedToken = jwt.verify(token, process.env["SHOPIFY_CLIENT_SECRET"]);24 const decodedReferenceId = decodedToken.input_data.initialPurchase.referenceId;2526 const { referenceId, changes } = request.body;27 // compare passed in referenceId with decoded referenceId28 if (decodedReferenceId !== referenceId) {29 // return error if incoming jwt is not valid30 await reply.code(401).send();31 }3233 // create the payload for updating the order34 const payload = {35 iss: process.env["SHOPIFY_CLIENT_KEY"],36 jti: uuidv4(),37 iat: Date.now(),38 sub: referenceId,39 changes,40 };4142 // sign the token and return back to the extension43 const responseToken = jwt.sign(payload, process.env["SHOPIFY_CLIENT_SECRET"]);44 await reply.send({ token: responseToken });45}
Note that post-purchase extensions require the Shopify Partner app API key and secret to be stored as environment variables in your Gadget app.
Customer account UI extensions
Gadget's support for customer account UI extensions is in beta. See the guide for more information.
Theme app extensions
Theme app extensions are different from other types of extensions because they are built using Liquid and JavaScript. They are not Node projects, so there is no package.json
where a Gadget API client can be installed.
Instead, you need to:
- Include your app's direct script tag to use the API client in a theme app extension
.liquid
block:
extensions/your-extension-name/blocks/my-extension.liquidliquid1<script src="https://YOUR-GADGET-DOMAIN.gadget.app/api/client/web.min.js" defer="defer"></script>23<div>My theme extension content goes here!</div>45{% schema %}6{7 "name": "My extension",8 "target": "body",9 "settings": []10}11{% endschema %}
When you add your script tag, make sure the domain has the correct environment tag!
For example, if you are working in the development environment, your script tag src should look like https://YOUR-GADGET-DOMAIN--development.gadget.app/api/client/web.min.js
- Create a JS file in
extensions/your-extension-name/assets
, and initialize the API client:
extensions/your-extension-name/assets/my-extension.jsJavaScript1document.addEventListener("DOMContentLoaded", function () {2 // initialize an API client object3 const myExtensionAPI = new Gadget();45 const myButton = document.getElementById("my-button");6 myButton.addEventListener("click", async () => {7 // make a request to your app's API8 const response = await myExtensionAPI.myDataModel.findOne("1");9 console.log(response);10 });11});
Testing extensions
To test Shopify extensions, you can run the following command in your project root:
terminalshopify app dev
Then follow the links provided by the Shopify CLI to preview your extension in the Shopify admin, checkout, customer account, or storefront pages.
Instructions for testing may vary based on extension type. Make sure to check out Shopify's documentation for your specific extension type.
Deploying extensions
Extensions can be deployed by running:
terminalshopify app deploy
This publishes your extension to Shopify's infrastructure, and the extension's functionality will be included as part of the connected Partner app.
Instructions may vary based on the extension type. Read Shopify's documentation for more information on deploying different extensions.