Building Shopify app extensions 

Working with extensions 

You can build your Shopify app extensions inside your Gadget app using the Shopify CLI and ggt, Gadget's CLI.

Note: These instructions are for admin, checkout, or customer account extensions. For theme app extensions, see the Theme app extensions section.

To set up an extension in your Gadget app, you need to do the following:

  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:

  1. Create an empty shopify.app.toml file at the root level of your project
  2. Add a workspaces field with the value ["extensions/*"], and a trustedDependencies field with the value ["@shopify/plugin-cloudflare"] to the root level of your package.json file:
package.json
json
{
"workspaces": ["extensions/*"],
"trustedDependencies": ["@shopify/plugin-cloudflare"]
}

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 and keeps your local and hosted Gadget environments up to date with one another. Changes made locally will be pushed to your remote Gadget environment, and changes made to the remote environment will be pulled 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 shopify app generate extension
Add Shopify scripts

You can add Shopify's CLI app scripts commands to your package.json file if desired:

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}

Then instead of running yarn shopify app generate extension, you can run:

terminal
yarn generate extension
  1. Select the same Partner app and development store you used to connect to Shopify when prompted by Shopify's CLI
  2. Select the extension type you want to build
  3. 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.

  1. Start your extension development by running:
terminal
yarn shopify app dev
Bringing an existing extension into Gadget?

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.

  1. Make sure you have access to Gadget's npm registry:
terminal
npm config set @gadget-client:registry https://registry.gadget.dev/npm
  1. Install your app's unique API client:
terminal
yarn add @gadget-client/YOUR-GADGET-APP-DOMAIN
  1. Set up a new API client instance in your extension, for example:
extensions/your-extension-name/src/api.js
JavaScript
import { Client } from "@gadget-client/<YOUR-GADGET-APP-DOMAIN>";
export const api = new Client();

Using @gadgetinc React hooks 

The []@gadgetinc/react hooks](/reference/react), such as useFindMany, useAction, and useFetch, can be used to interact with your app's API.

  1. Install the @gadgetinc/react package:
terminal
yarn add @gadgetinc/react
  1. Set up the Provider in your extension by wrapping the exported extension component or app with the Provider component and passing in your API client instance:
extensions/your-extension-name/src/Extension.jsx
JavaScript
1import { Provider } from "@gadgetinc/react";
2import { api } from "./api";
3
4export 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.jsx
JavaScript
1import { 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 client
12import { api } from "./api";
13import { Provider, useAction } from "@gadgetinc/react";
14
15// 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";
17
18// set up the Provider component so React hooks can be used
19export default reactExtension(TARGET, () => (
20 <Provider api={api}>
21 <App />
22 </Provider>
23));
24
25function 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);
33
34 const [wordCount, setWordCount] = useState("100");
35
36 // custom action in Gadget that updates the product description
37 // using OpenAI to generate a description based on the word count and product images
38 const [_, updateDescription] = useAction(api.shopifyProduct.updateDescription);
39
40 const update = useCallback(async () => {
41 // get current product id from data
42 // remove the shopifyProduct gid prefix from the id
43 const productId = data.selected[0].id.split("/").pop();
44 // fire request to update the product description in Gadget
45 await updateDescription({
46 id: productId,
47 wordCount,
48 });
49 });
50
51 // The AdminAction component provides an API for setting the title and actions of the Action extension wrapper.
52 return (
53 <AdminAction
54 primaryAction={
55 <Button
56 onPress={() => {
57 update();
58 close();
59 }}
60 >
61 {i18n.translate("updateDescription")}
62 </Button>
63 }
64 secondaryAction={
65 <Button
66 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 <NumberField
78 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.jsx
JavaScript
1import { Banner, reactExtension } from "@shopify/ui-extensions-react/checkout";
2import { Provider, useGlobalAction } from "@gadgetinc/react";
3// import your app API client
4import { api } from "../api";
5
6// set up the Provider component so React hooks can be used
7export default reactExtension("purchase.checkout.block.render", () => (
8 <Provider api={api}>
9 <Extension />
10 </Provider>
11));
12
13function Extension() {
14 // use hooks to call your API
15 // in this case, a global action
16 const [{ data, error, fetching }, refresh] = useGlobalAction(
17 api.myCustomGlobalAction
18 );
19
20 if (fetching) {
21 return <Banner>Loading...</Banner>;
22 }
23
24 if (error) {
25 return <Banner>Error loading. Please try again.</Banner>;
26 }
27
28 return <Banner>{data.value}</Banner>;
29}

Public apps 

You can still enforce shop multi-tenancy by passing the Shopify session token with your request.

Note that in this case, the Gadget API client is not required. Instead, the built-in fetch is used to send a request to an app's GraphQL endpoint.

extensions/your-extension-name/src/Checkout.jsx
JavaScript
1import {
2 Banner,
3 reactExtension,
4 useApi,
5} from "@shopify/ui-extensions-react/checkout";
6import { useState, useEffect } from "react";
7
8export default reactExtension("purchase.checkout.block.render", () => <Extension />);
9
10function Extension() {
11 // get the session token from the useApi hook
12 const { sessionToken } = useApi();
13 const [productData, setProductData] = useState(null);
14
15 useEffect(() => {
16 // Specify the GraphQL endpoint
17 const url = "https://my-extension-app--development.gadget.dev/api/graphql";
18
19 // Create a GraphQL query
20 const query = `
21 query GetOneShopifyProduct($id: GadgetID!) {
22 shopifyProduct(id: $id) {
23 title
24 }
25 }
26 `;
27
28 // get the session token
29 async function getToken() {
30 const token = await sessionToken.get();
31 return token;
32 }
33
34 // use fetch to make a POST request to the GraphQL endpoint
35 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 header
42 Authorization: `ShopifySessionToken ${token}`,
43 },
44 body: JSON.stringify({ query: query }),
45 })
46 .then((response) => response.json())
47 .then((jsonData) => {
48 // handle the returned data
49 setProductData(jsonData.data.product);
50 })
51 .catch((error) => console.error("Error:", error));
52 });
53 }, []);
54
55 return <Banner>{productData.title}</Banner>;
56}

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 required to authenticate the request, and ensure that your Gadget actions have the correct shop context.

This example shows how to send the session token in a fetch request when calling a global action:

extensions/your-extension-name/src/Checkout.jsx
JavaScript
1const url = "https://my-extension-app--development.gadget.dev/api/graphql";
2const query = `
3 mutation {
4 myCustomGlobalAction {
5 success
6 errors {
7 message
8 }
9 result
10 }
11 }
12`;
13
14fetch(url, {
15 method: "POST",
16 headers: {
17 "Content-Type": "application/json",
18 Accept: "application/json",
19 // pass the session token using the Authorization header
20 Authorization: `ShopifySessionToken ${token}`,
21 },
22 body: JSON.stringify({ query: query }),
23});

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.jsx
JavaScript
1/**
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 the
6 * payment page loads.
7 * 2. Render - If requested by `ShouldRender`, will be rendered after checkout
8 * completes
9 */
10// other imports such as React state hooks and extension components are omitted for brevity
11import React from "react";
12import { extend } from "@shopify/post-purchase-ui-extensions-react";
13// your app API client
14import { api } from "./api";
15
16/**
17 * Entry point for the `ShouldRender` Extension Point.
18 *
19 * Returns a value indicating whether or not to render a PostPurchase step, and
20 * 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 purchase
25 const productVariantIds = inputData.initialPurchase.lineItems.map(
26 (lineItem) => lineItem.product.variant.id
27 );
28
29 // make request against POST-offer route in Gadget
30 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 });
41
42 // get response body from route
43 const jsonResp = await response.json();
44 // save offers to extension storage
45 await storage.update({ offers: jsonResp.offers });
46
47 // For local development, always show the post-purchase page
48 return { render: true };
49});

And your Gadget POST-offer HTTP route could look like:

api/routes/POST-offer.js
JavaScript
1import { RouteContext } from "gadget-server";
2import jwt from "jsonwebtoken";
3import { getOffers } from "../utils/offerUtils";
4
5/**
6 * Route handler for POST offer
7 *
8 * @param { RouteContext } route context - see: https://docs.gadget.dev/guides/http-routes/route-configuration#route-context
9 *
10 */
11export default async function route({ request, reply, api, logger, connections }) {
12 let token = request.headers?.authorization;
13 if (token?.startsWith("Bearer ")) {
14 token = authToken.slice(7);
15 } else {
16 // if no bearer token is present, return 401 error
17 await reply.code(401).send();
18 }
19
20 // use SHOPIFY_API_SECRET (from Partners app) as an environment variable to decode the token
21 const decodedToken = jwt.verify(token, process.env.SHOPIFY_API_SECRET);
22
23 // get the referenceId from the decoded token
24 const decodedReferenceId = decodedToken.input_data.initialPurchase.referenceId;
25
26 const { referenceId, productVariantIds } = request.body;
27
28 if (decodedReferenceId !== referenceId) {
29 // return error if incoming jwt is not valid
30 await reply.code(401).send();
31 }
32
33 // fetch custom offers
34 const offers = await getOffers({ api, logger, connections, productVariantIds });
35
36 // reply with the offers
37 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:

JavaScript
1// api/routes/POST-sign-changeset.js
2import { RouteContext } from "gadget-server";
3import { v4 as uuidv4 } from "uuid";
4import jwt from "jsonwebtoken";
5
6/**
7 * Route handler for POST sign-changeset
8 *
9 * @param { RouteContext } route context - see: https://docs.gadget.dev/guides/http-routes/route-configuration#route-context
10 *
11 */
12export default async function route({ request, reply, api, logger, connections }) {
13 // get token from headers
14 let token = request.headers?.authorization;
15 if (token?.startsWith("Bearer ")) {
16 token = authToken.slice(7);
17 } else {
18 // if no bearer token is present, return 401 error
19 await reply.code(401).send();
20 }
21
22 // use SHOPIFY_API_SECRET (from Partners app) as an environment variable to decode the token
23 const decodedToken = jwt.verify(token, process.env.SHOPIFY_API_SECRET);
24 const decodedReferenceId = decodedToken.input_data.initialPurchase.referenceId;
25
26 const { referenceId, changes } = request.body;
27 // compare passed in referenceId with decoded referenceId
28 if (decodedReferenceId !== referenceId) {
29 // return error if incoming jwt is not valid
30 await reply.code(401).send();
31 }
32
33 // create the payload for updating the order
34 const payload = {
35 iss: process.env.SHOPIFY_API_KEY,
36 jti: uuidv4(),
37 iat: Date.now(),
38 sub: referenceId,
39 changes,
40 };
41
42 // sign the token and return back to the extension
43 const responseToken = jwt.sign(payload, process.env.SHOPIFY_API_SECRET);
44 await reply.send({ token: responseToken });
45}

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:

  1. 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.liquid
liquid
1<script src="https://YOUR-GADGET-DOMAIN.gadget.app/api/client/web.min.js" defer="defer"></script>
2
3<div>My theme extension content goes here!</div>
4
5{% schema %}
6{
7 "name": "My extension",
8 "target": "body",
9 "settings": []
10}
11{% endschema %}
Check the environment in your domain

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

  1. Create a JS file in extensions/your-extension-name/assets, and initialize the API client:
extensions/your-extension-name/assets/my-extension.js
JavaScript
1document.addEventListener("DOMContentLoaded", function () {
2 // initialize an API client object
3 const myExtensionAPI = new Gadget();
4
5 const myButton = document.getElementById("my-button");
6 myButton.addEventListener("click", async () => {
7 // make a request to your app's API
8 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:

terminal
yarn 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:

terminal
yarn 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.