Use Gadget to build an automated product tagging app for a Shopify store 

Topics covered: Shopify connections, Building models, Actions, React frontends
Time to build: ~20 minutes

A Shopify merchant needs an automated way to tag products being added to their Shopify store inventory. They source hundreds of products weekly from various dropshippers and upload the unstructured data to Shopify programmatically. Because the data is unstructured, Shopify is unable to power the merchant's storefront search. While the merchant can add tags inside the Shopify Admin, the experience of doing this on hundreds of products weekly is time-consuming.

To solve this, the merchant wants to build a custom Shopify app on Gadget that will run every new product description through an automated tagging script.

Screenshot of the completed product tagger app embedded in a Shopify store admin

In this example, we'll build a custom product tagging app that listens to the product/create and product/update webhooks from Shopify, runs product descriptions through a tagging script, and sets tags back in Shopify.


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

You can fork this Gadget project and try it out yourself.

You will still need to set up the Shopify Connection after forking. Continue reading if you want to learn how to connect Gadget to a Shopify store!

Fork on Gadget

Step 1: Create a Gadget app and connect to Shopify 

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

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

Because we are adding an embedded frontend, we are going to build an app using the Partners connection.

Connect to Shopify through the Partners dashboard 


To complete this connection, you will need a Shopify Partners account as well as a store or development store

Our first step is going to be setting up a custom Shopify application in the Partners dashboard.

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

Click on Apps link in Shopify Partners Dashboard
  • Click the Create App button
Click on Create app button
  • Click the Create app manually button and enter a name for your Shopify app
Shopify's app creation landing page in the Partners Dashboard
  • Go to the Connections page in your Gadget app
The Gadget homescreen, with the Connections link highlighted
  • Copy the Client ID and Client secret from your newly created Shopify app and paste the values into the Gadget Connections page
  • Screenshot of the Partners card selected on the Connections page
  • Click Connect on the Gadget Connections page to move to scope and model selection

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

  • Enable the read and write scopes for the Shopify Products API, and select the underlying Product model that we want to import into Gadget
Select Product API scope + model
  • Click Confirm

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

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

At this point, Gadget copies the selected Shopify models, their types, validations and associations into your Gadget backend. These models are ready to process webhooks as soon as you install the app on a Shopify store.

Product, Shop models imported from Shopify

We're going to install this app on a Shopify development store:

  • Go to the Overview tab for your app in the Shopify Partners dashboard
  • Click Select store in the Test your app section
  • Click on a development store to start the installation

Gadget handles Shopify's OAuth for us, all we need to do is click the Install app button when prompted to grant our app permission to use the selected API scopes and the connection is made.

Step 2: Add new model for tag keywords 

The next step is to create a model that will store our list of vetted keywords that we can use to power our tagging script. These keywords can be different types of products or brands. Make sure to add keywords that will be found in your products' descriptions!

  • Click + next to the DATA MODELS header in the nav to add a model, and call it allowedTag
  • Click + in the FIELDS section to add a field, and name it keyword
The allowedTag model with keyword field

Gadget instantly creates a new table and column in the underlying database, and generates a GraphQL CRUD API for this model. Test it out the API in the API Playground!

  • Click on the create action in the ACTIONS section of the allowedTag model page
  • Click the Run Action button to open up the create action in the API Playground

Using the API Playground, we can make a create call to our allowedTag model to store a new keyword. The GraphQL mutation is pre-populated already, all we need to do is update the keyword value to the keyword we want to store.

  • Add the following to the Variables section of the API Playground:
copy-paste into the Variables section of the API Playground
"allowedTag": {
"keyword": "sweater"
  • Click the Execure query button to run the mutation

We can run the same mutation again with a different keyword value to store additional keywords.

We can also check to make sure our tag keywords have been saved.

  • Go back to the main Gadget editor
  • Click on the allowedTag model
  • Click on Data in the left nav underneath allowedTag to go to the data viewer for this model
Button used to navigate to allowedTag Data page

We can see our added allowedTag records!

The allowedTag Data page with our added keyword

Step 3: Build your tagging script 

Gadget keeps your app and store in sync by generating a CRUD (Create, Read, Update, Delete) API around each of your cloned models and wiring up each of the API actions to their corresponding Shopify webhook. If the Shopify store fires a products/create webhook, Gadget will run your Create action on the Product model. By default, this action uses the incoming params from the webhook to create a record in your database. Similarly, if Shopify fires the products/update webhook, Gadget will run your Update action which updates the record with the incoming params.

What makes Actions special is that they can be completely customized. You can change what happens when the action runs by adding custom code to the run and onSuccess functions.

Anatomy of a model action

By default, model actions have two defined functions, run and onSuccess.

  • run is a required function, and applies changes to the database by default (ie. creates, updates, or deletes a record). If you need to add any custom code that updates the database, you can add it to this function. The run function is transactional by default and has a 5-second timeout.
  • onSuccess is optional, and is called after the run function completes successfully. It is a great place to add side effects and longer-running operations, such as sending an email or making a potentially long-running API call to another service.

For more information on model actions, read our documentation.

Now that we have keywords to check against, we can write our tagging script. Because we want this script to run every time a product record is created or updated, we'll add an Effect to the create and update actions on shopifyProduct:

  • Click on shopifyProduct in the left nav
  • Select the create action
  • Replace the contents of the shopifyProduct/actions/create.js code file with the following snippet:
1import {
2 applyParams,
3 preventCrossShopDataAccess,
4 save,
5 ActionOptions,
6 UpdateShopifyProductActionContext,
7} from "gadget-server";
10 * @param { CreateAllowedTagActionContext } context
11 */
12export async function run({ params, record, logger, api }) {
13 applyParams(params, record);
14 await preventCrossShopDataAccess(params, record);
15 await save(record);
19 * @param { CreateAllowedTagActionContext } context
20 */
21export async function onSuccess({ params, record, logger, api, connections }) {
22 if ( && record.body && record.changed("body")) {
23 // get a unique list of words used in the record's description
24 let newTags = [ Set(record.body.match(/\w+(?:'\w+)*/g))];
26 // filter down to only those words which are allowed
27 const allowedTags = (await api.allowedTag.findMany()).map((tag) => tag.keyword);
28 // merge with any existing tags and use Set to remove duplicates
29 const finalTags = [
30 Set(
31 newTags.filter((tag) => allowedTags.includes(tag)).concat(record.tags)
32 ),
33 ];
35 { newTags, allowedTags, finalTags },
36 `applying final tags to product ${}`
37 );
39 // write tags back to Shopify
40 const shopify = await connections.shopify.current;
41 if (shopify) {
42 await shopify.product.update(parseInt(, {
43 tags: finalTags.join(","),
44 });
45 }
46 }
49/** @type { ActionOptions } */
50export const options = {
51 actionType: "create",

That's not a lot of code!

This snippet will run on every incoming products/create webhook that is sent by Shopify, and determines if tags need to be added by cross-referencing the body of the incoming payload against the stored keyword records by making an internal API request to Gadget. Should any words match, they're sent back to Shopify as new tags for the product.

Gadget gives us a connections object as an argument to our effect function, which has an authenticated Shopify API client ready to go. We use this object to make API calls back to Shopify to update the tags and complete the process.

Sharing code between actions 

We also use Gadget's changed helper on our record to avoid entering an infinite loop. This looping can occur when a Shopify webhook triggers code that updates our Shopify store. Because we have added this change detection, we can use the same code for both the create and update actions.

  • Instead of duplicating the code in both places, we can create a new shopifyProduct/actions/utils.js file to hold our shared code:
1export async function applyTags({ record, logger, api, connections }) {
2 if ( && record.body && record.changed("body")) {
3 // get a unique list of words used in the record's description
4 let newTags = [ Set(record.body.match(/\w+(?:'\w+)*/g))];
6 // filter down to only those words which are allowed
7 const allowedTags = (await api.allowedTag.findMany()).map((tag) => tag.keyword);
8 // merge with any existing tags and use Set to remove duplicates
9 const finalTags = [
10 Set(
11 newTags.filter((tag) => allowedTags.includes(tag)).concat(record.tags)
12 ),
13 ];
15 { newTags, allowedTags, finalTags },
16 `applying final tags to product ${}`
17 );
19 // write tags back to Shopify
20 const shopify = await connections.shopify.current;
21 if (shopify) {
22 await shopify.product.update(parseInt(, {
23 tags: finalTags.join(","),
24 });
25 }
26 }
  • Import the applyTags function into shopifyProduct/create.js and shopifyProduct/update.js and call applyTags from the onSuccess function (only import the utils file and update the onSuccess function):
shopifyProduct/actions/create.js and shopifyProduct/actions/update.js
1import {
2 applyParams,
3 save,
4 ActionOptions,
5 CreateAllowedTagActionContext,
6} from "gadget-server";
7import { applyTags } from "../utils"; // <-- add this import statement
9// the `run` function
12 * @param { CreateShopifyProductActionContext } context
13 */
14export async function onSuccess({ params, record, logger, api, connections }) {
15 await applyTags({ record, logger, api, connections });
18// the `options` object

Now this code will be run every time a product is created or updated in a Shopify store.

The record.changed helper is a special field that Gadget has included to help prevent an infinite loop when updating Shopify records.

When we call shopify.product.update(...) the product in our Shopify store will be updated. This update action will fire Shopify's products/update webhook. If we are using this webhook as a trigger for running custom code that updates a product, we will be stuck in an endless loop of updating our products and running our custom code.

We can use record.changed to determine if changes have been made to the key on this record and only run our code if changes have occurred.

For more info on change tracking in Gadget, refer to the documentation.

Step 4: Add shop tenancy and permissions to allowedTag model 

Right now the allowedTag model only has a single field, keyword. If you're building a public Shopify app, you also need to associate keywords with individual stores so that all the shops that install your app don't share keyword records.

It's also important to grant your embedded app permission to call the create and delete actions on the allowedTag model. Access to custom model APIs are always disabled by default for embedded app users. If you encounter a GGT_PERMISSION_DENIED error when building an embedded app, you probably need to go into the Roles and Permissions page and grant embedded app users access to your Gadget app API.

  • Add a new field named shop to the allowedTag model
  • Make shop a belongs to relationship field and select shopifyShop as the related model
  • Select the has many option when defining the inverse of the relationship so that shopifyShop has many allowedTags
Screenshot of the Shop relation field on the allowedTag model, with allowedTag belonging to Shop

With this added Shop relationship, you will be able to track what keywords are used for individual shops. To automatically filter by the current shop when reading from your allowedTag model, you can add a Gelly snippet to enforce shop tenancy automatically.

  • Go to Settings in the nav bar and select the Roles & Permissions page
  • Enable the read, create, and delete actions for your allowedTag model on the shopify-app-users role
Screenshot of the permissions page, with the allowedTag model's CRUD actions all enabled for the shopify-app-users role
  • Click + Filter next to the read action, type tenancy into the input, and hit Enter on your keyboard to create a new Gelly file
  • Go to the file by clicking on the File icon
  • Add the following Gelly fragment
fragment Filter($session: Session) on AllowedTag {
[where shopId == $session.shopId]

This snippet selects all allowedTag records when the related shopId is equal to the shop id of the current session. The current session is managed for you when you connect to Shopify, and session records including the current session can be viewed on the session Data page in Gadget.

This handles shop tenancy for your read action, and will also be applied for update, delete, or custom model actions. For custom actions, it's advisable to leverage Gadget's preventCrossShopDataAccess helper which prevents the modification of the shop relationship on all model actions to which it is applied.

  • Go to your allowedTag/actions/create.js file and paste the following code:
1import { applyParams, save, ActionOptions, CreateAllowedTagActionContext, preventCrossShopDataAccess } from "gadget-server";
4 * @param { CreateAllowedTagActionContext } context
5 */
6export async function run({ params, record, logger, api, connections }) {
7 applyParams(params, record);
8 await preventCrossShopDataAccess(params, record);
9 await save(record);
13 * @param { CreateAllowedTagActionContext } context
14 */
15export async function onSuccess({ params, record, logger, api, connections }) {
16 // Your logic goes here
19/** @type { ActionOptions } */
20export const options = {
21 actionType: "create",

The relationship to the current shop is automatically set up by the preventCrossShopDataAccess helper in the run function. By using this helper, the shopId cannot be spoofed by malicious users.

The preventCrossShopDataAccess helper is also useful for other model actions, such as delete, to enforce data tenancy in public apps.

  • Go to your allowedTag/actions/delete.js file and paste the following code:
1import { deleteRecord, ActionOptions, DeleteAllowedTagActionContext, preventCrossShopDataAccess } from "gadget-server";
4 * @param { DeleteAllowedTagActionContext } context
5 */
6export async function run({ params, record, logger, api, connections }) {
7 // only allow deletion if the request comes from the same shop that relates to the record
8 await preventCrossShopDataAccess(params, record);
9 await deleteRecord(record);
13 * @param { DeleteAllowedTagActionContext } context
14 */
15export async function onSuccess({ params, record, logger, api }) {
16 // Your logic goes here
19/** @type { ActionOptions } */
20export const options = {
21 actionType: "delete",

For delete actions (or update, or other custom actions), using preventCrossShopDataAccess in the run function verifies that the shopId associated with the current record matches the shopId for the current session. If these values do not match, an error is triggered, resulting in the failure of the action.

Now you have a multi-tenant backend. The final step is building an embedded frontend.

Step 5: Build a Shopify admin frontend 

New Gadget apps include a frontend folder. When you set up a Shopify connection, Gadget automatically makes changes to this frontend folder by:

  • initializing your Gadget API client in frontend/api.js
  • setting up a default React app in frontend/main.jsx
  • adding a routing example in frontend/App.jsx
  • has two examples of rendered pages and navigation with frontend/ShopPage.jsx and frontend/AboutPage.jsx

Additional packages have also been added to your package.json upon connecting to Shopify, including @gadgetinc/react which allows for the use of Gadget's handy React hooks for fetching data and calling your Gadget project's API, and @shopify/polaris which allows you to use Shopify's Polaris components out of the box when building embedded Shopify apps.

Start building! 

The entire tagger frontend code snippet is below. Additional details on some of Gadget's provided tooling are below the snippet.

  • Paste the following code into frontend/ShopPage.jsx
1import { useCallback, useState } from "react";
2import { useFindMany, useAction } from "@gadgetinc/react";
3import { TitleBar } from "@shopify/app-bridge-react";
4import { Banner, Button, LegacyCard, Form, FormLayout, Layout, Page, Spinner, LegacyStack, Tag, TextField } from "@shopify/polaris";
5import { api } from "./api";
7const PageLayout = ({ children }) => {
8 return <Page title="Keyword manager">{children}</Page>;
11const ShopPage = () => {
12 // react hook to manage the keyword input
13 const [keyword, setKeyword] = useState("");
15 // useAction hook to call the create and delete actions and get the response object
16 const [createTagResponse, createTag] = useAction(api.allowedTag.create);
17 const [_, deleteTag] = useAction(api.allowedTag.delete);
19 // a useFindMany hook to fetch allowedTag data
20 const [{ data, fetching, error }] = useFindMany(api.allowedTag);
22 // callbacks that are called when the form is submitted or a tag is removed
23 const handleSubmit = useCallback(async () => {
24 // call the createTag function defined with the useAction hook with the keyword value
25 await createTag({
26 allowedTag: {
27 keyword,
28 },
29 });
30 setKeyword("");
31 }, [keyword, createTag]);
33 const removeTag = useCallback(
34 async (id) => {
35 // call the deleteTag function defined with the useAction hook with the id of the tag to delete
36 await deleteTag({ id });
37 },
38 [deleteTag]
39 );
41 // render the page, using data, fetching, and error from the useFindMany and useAction hooks to display different widgets
42 return (
43 <PageLayout>
44 <>
45 <Layout>
46 <Layout.Section>
47 <TitleBar title="Manage keywords" />
48 <Form onSubmit={handleSubmit}>
49 <FormLayout>
50 {createTagResponse.error && (
51 <Banner status="critical">
52 <pre>
53 <code>{createTagResponse.error.toString()}</code>
54 </pre>
55 </Banner>
56 )}
57 <LegacyStack alignment="center">
58 <LegacyStack.Item fill>
59 <TextField
60 value={keyword}
61 onChange={setKeyword}
62 label="Tag"
63 type="text"
64 autoComplete="tag"
65 helpText={<span>Add a keyword</span>}
66 disabled={createTagResponse.fetching}
67 />
68 </LegacyStack.Item>
69 <Button primary submit disabled={createTagResponse.fetching}>
70 Add keyword
71 </Button>
72 </LegacyStack>
73 </FormLayout>
74 </Form>
75 </Layout.Section>
76 <Layout.Section>
77 <LegacyCard title="Existing keywords" sectioned>
78 <LegacyStack>
79 {fetching && <Spinner />}
80 {data?.map((allowedTag, i) => (
81 <Tag key={i} onRemove={() => removeTag(}>
82 {allowedTag.keyword}
83 </Tag>
84 ))}
85 {data?.length === 0 && <p>No keywords added</p>}
86 </LegacyStack>
87 </LegacyCard>
88 </Layout.Section>
89 </Layout>
90 </>
91 </PageLayout>
92 );
95export default ShopPage;

If you go to your development app, you should now be able to test it out! Go back to the embedded frontend in your store admin and start adding custom tags. You'll be able to see the created tags, along with the related shop ID, in the allowedTag Data page in Gadget.

A screenshot of the completed embedded tagger app

How the frontend reads and writes data 

The above snippet has everything you need to build a frontend for your app. Let's take a closer look at how you read and write data using Gadget's React tooling.

Using the API and hooks to fetch data 

Your Gadget API client is already set up for you in frontend/api.js! You can use this API client to fetch data from our models using the product tagger application's auto-generated API. You can also make use of some Gadget-provided React hooks that help to read (and write) using your app's API.

The useFindMany hook will run api.allowedTag.findMany() and return a response that has data, error, and fetching as properties. You can use fetching to display a Polaris Spinner while the data is being fetched, and the error property to display and handle any request errors.

Here is the code snippets from your tagger frontend that use the useFindMany hook:

reading allowedTag data in frontend/ShopPage.jsx
1// import the required dependencies
2import { useFindMany } from "@gadgetinc/react";
3import { api } from "./api";
5// ...
7const ShopPage = () => {
8 // fetch allowedTag data
9 const [{ data, fetching, error }] = useFindMany(api.allowedTag);
11 // ...
13 // a Spinner is displayed while fetching is true
14 // a list of keywords is displayed if data exists
15 // a message is displayed if data is empty
16 return (
17 {/* other components */}
18 <LegacyStack>
19 {fetching && <Spinner />}
20 {data?.map((allowedTag, i) => (
21 <Tag key={i} onRemove={() => removeTag(}>
22 {allowedTag.keyword}
23 </Tag>
24 ))}
25 {data?.length === 0 && <p>No keywords added</p>}
26 </LegacyStack>
27 {/* other components */}
28 );

Using the API and hooks to write data 

Now you need to send a request to your Gadget app's backend API to create entered keywords in your app's database. The @gadgetinc/react package also has a useAction hook that will assist with making requests to any of your model actions. Similar to useFindMany, the useAction hook returns an object with data, fetching, and error properties. Additionally, useAction returns a function that needs to be called to actually run the action.

For the product tagger, you are using the useAction hook to call the api.allowedTag.create and api.allowedTag.delete actions:

calling the allowedTag create action in frontend/ShopPage.jsx
1import { useAction } from "@gadgetinc/react";
2import { api } from "./api";
4// ...
6const ShopPage = () => {
7 // ...
9 // useAction hook to call the create action and get the response object
10 const [createTagResponse, createTag] = useAction(api.allowedTag.create);
12 // ...
14 // a handleSubmit callback is created, and the createTag function is called when the form is submitted
15 const handleSubmit = useCallback(async () => {
16 await createTag({
17 allowedTag: {
18 keyword,
19 },
20 });
22 // ...
23 }, [keyword, createTag]);
25 return (
26 {/* other page components */}
27 {/* when the form is submitted, call handleSubmit to then run the createTag function */}
28 <Form onSubmit={handleSubmit}>
29 <FormLayout>
30 {/* use the createTagResponse to display errors */}
31 {createTagResponse.error && (
32 <Banner status="critical">
33 <pre>
34 <code>{createTagResponse.error.toString()}</code>
35 </pre>
36 </Banner>
37 )}
38 <LegacyStack alignment="center">
39 <LegacyStack.Item fill>
40 <TextField
41 value={keyword}
42 onChange={setKeyword}
43 label="Tag"
44 type="text"
45 autoComplete="tag"
46 helpText={<span>Add a keyword</span>}
47 disabled={createTagResponse.fetching}
48 />
49 </LegacyStack.Item>
50 {/* disable inputs when a new tag is being created */}
51 <Button primary submit disabled={createTagResponse.fetching}>
52 Add keyword
53 </Button>
54 </LegacyStack>
55 </FormLayout>
56 </Form>
57 {/* other page components */}
58 );

Congrats! You have built a full-stack and fully functional embedded product tagger application! Now you can test it out.

Step 6: Test it out 

First, add some keywords to your product tagger. You want to make sure to add words that are in your product descriptions. If using Shopify's default store data, SUPER and DUPER both appear in the product description of The Complete Snowboard.

Go back to the Connections page in Gadget and click Shop Installs and then Sync on the connected store if you set up a custom app through the Partners dashboard, or just click Sync if you used the store Admin to set up your app.

The Installs page for the connection, displaying the store name, and the Sync button

Gadget will fetch each of the records in Shopify and run them through your actions. Not only will this populate your Gadget backend with the store's inventory, but it will also run the effects we added, updating the tags for each synced product. Our tagging application will also run on products when they are added to the store, so any new products will also be tagged for us automatically.

Display the tag added on one of the demo products

Congratulations! In about 20 minutes you were able to build a custom app that updates tags in Shopify each time there is a match against the list of allowed tags.

Next steps 

Now that you can add keywords using an admin UI, you may want to try adding a global action to run through all existing products that have been synced to Gadget to apply tags!

Want to keep building in Gadget? Check out these other tutorials:

Call the Gadget API in Liquid (product recommendation quiz)
20 mins

Learn how to call a Gadget API from the Shopify storefront, using a product quiz as an example.

AI product recommender chatbot
30 mins

Use LangChain and OpenAI along with Gadget to build a chatbot that recommends products to shoppers.