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.

Requirements

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

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

Fork on Gadget

Step 1: Create a Gadget app and connect to Shopify 

To learn how to create a Shopify connection, follow the Shopify quickstart guide. Note that you may also use the assistant to create a new Shopify connection.

For this tutorial, we will need the write_products scope and the product model.

Select Product API scope + model

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!

  1. Click + next to the api/models folder in the nav to add a model, and call it allowedTag
  2. Click + in the FIELDS section of the api/models/allowedTag/schema page 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!

  1. Click on api/models/allowedTag/actions/create.js to open the allowedTag model's create action
  2. Click the Run action button in the TRIGGERS panel on the right of the editor 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 JS action is already set up to take a keyword as a parameter.

  1. Enter a keyword to store in your database and run the action

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

  1. Navigate to api/models/allowedTag/data to go to this model's data page

We can see our added allowedTag record!

The allowedTag Data page with our added keyword

To ensure that our app is multi-tenant, we need to make sure that our allowedTag model is scoped to the current shop. This means that each shop will have its own set of allowed tags.

Just like we did for creating the keyword field, we can add a shop field with the belongs to relationship to the allowedTag model. For the parent relationship select shopifyShop. You will now see in the Relationships section an option to select and create the inverse field in shopifyShop model. Click on the shopifyShop has many allowedTags, then Gadget will automatically create the inverse relationship.

Now your Shops can only access their own allowedTag records, and you can use this model to store keywords that are specific to each shop.

Step 3: Build your tagging script 

Gadget auto-generates a CRUD (create, read, update, delete) API for each of your models. For Shopify models, these create, update, and delete actions are triggered by Shopify webhooks.

Actions are customizable, and you can add code to the run and onSuccess functions. Read more about these functions in the Gadget actions docs.

Let's add some code to the shopifyProduct model to tag products based on the keywords we have stored in the allowedTag model.

  1. Navigate to api/models/shopifyProduct
  2. Create a new file called api/models/shopifyProduct/utils.js
  3. Paste the following code into utils.js:
api/models/shopifyProduct/utils.js
JavaScript
import { logger, api, connections } from "gadget-server"; /** * Applies tags to a Shopify product using the Shopify API. * @param param {tags: string[], body: string | null, id: string} - The tags, body, and id of the product. */ export const applyTags = async ({ tags, body, id, }: { tags: string[]; body: string | null; id: string; }) => { if (id && body) { // Get a unique list of words used in the record's description let newTags = [...new Set(body.match(/\w+(?:'\w+)*/g))]; /** * Filter down to only those words which are allowed * A filter condition is used on the api.allowedTag.findMany() request that checks the shop id */ const allowedTags = ( await api.allowedTag.findMany({ filter: { shopId: { equals: String(connections.shopify.currentShop?.id), }, }, first: 250, }) ).map((tag) => tag.keyword); // Merge with any existing tags and use Set to remove duplicates const finalTags = [ ...new Set(newTags.filter((tag) => allowedTags.includes(tag)).concat(tags)), ]; logger.info( { newTags, allowedTags, finalTags }, `applying final tags to product ${id}` ); // Write tags to Shopify using the writeToShopify action await api.enqueue( api.writeToShopify, { mutation: `mutation ($id: ID!, $tags: [String!]) { productUpdate(product: {id: $id, tags: $tags}) { product { id } userErrors { message } } }`, variables: { id: `gid://shopify/Product/${id}`, tags: finalTags, }, shopId: String(connections.shopify.currentShop?.id), }, { queue: { name: "shopify-product-update", maxConcurrency: 4, }, } ); } };
import { logger, api, connections } from "gadget-server"; /** * Applies tags to a Shopify product using the Shopify API. * @param param {tags: string[], body: string | null, id: string} - The tags, body, and id of the product. */ export const applyTags = async ({ tags, body, id, }: { tags: string[]; body: string | null; id: string; }) => { if (id && body) { // Get a unique list of words used in the record's description let newTags = [...new Set(body.match(/\w+(?:'\w+)*/g))]; /** * Filter down to only those words which are allowed * A filter condition is used on the api.allowedTag.findMany() request that checks the shop id */ const allowedTags = ( await api.allowedTag.findMany({ filter: { shopId: { equals: String(connections.shopify.currentShop?.id), }, }, first: 250, }) ).map((tag) => tag.keyword); // Merge with any existing tags and use Set to remove duplicates const finalTags = [ ...new Set(newTags.filter((tag) => allowedTags.includes(tag)).concat(tags)), ]; logger.info( { newTags, allowedTags, finalTags }, `applying final tags to product ${id}` ); // Write tags to Shopify using the writeToShopify action await api.enqueue( api.writeToShopify, { mutation: `mutation ($id: ID!, $tags: [String!]) { productUpdate(product: {id: $id, tags: $tags}) { product { id } userErrors { message } } }`, variables: { id: `gid://shopify/Product/${id}`, tags: finalTags, }, shopId: String(connections.shopify.currentShop?.id), }, { queue: { name: "shopify-product-update", maxConcurrency: 4, }, } ); } };

This snippet will apply the tags from our allowedTag model to the product description, 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.

The reason we have this code in a separate file is to allow us to share it between the create and update actions for the shopifyProduct model.

Sharing code between actions 

If we want to run this same code on our create and update action, we can create a shared utility function to avoid duplicating code.

  1. In both api/models/shopifyProduct/actions/create.js and api/models/shopifyProduct/actions/update.js file:
api/models/shopifyProduct/actions/create.js
JavaScript
import { applyParams, save, ActionOptions } from "gadget-server"; import { preventCrossShopDataAccess } from "gadget-server/shopify"; import { applyTags } from "../utils"; export const run: ActionRun = async ({ params, record, logger, api, connections, }) => { applyParams(params, record); await preventCrossShopDataAccess(params, record); await save(record); }; export const onSuccess: ActionOnSuccess = async ({ params, record, logger, api, connections, }) => { // Checks if the 'body' field has changed and applies tags using the applyTags function. if (record.changed("body")) { await applyTags({ id: record.id, body: record.body, tags: record.tags as string[], }); } }; export const options: ActionOptions = { actionType: "create", triggers: {}, };
import { applyParams, save, ActionOptions } from "gadget-server"; import { preventCrossShopDataAccess } from "gadget-server/shopify"; import { applyTags } from "../utils"; export const run: ActionRun = async ({ params, record, logger, api, connections, }) => { applyParams(params, record); await preventCrossShopDataAccess(params, record); await save(record); }; export const onSuccess: ActionOnSuccess = async ({ params, record, logger, api, connections, }) => { // Checks if the 'body' field has changed and applies tags using the applyTags function. if (record.changed("body")) { await applyTags({ id: record.id, body: record.body, tags: record.tags as string[], }); } }; export const options: ActionOptions = { actionType: "create", triggers: {}, };

What we have done here is import the applyTags function into api/models/shopifyProduct/actions/create.js and api/models/shopifyProduct/actions/update.js then call applyTags from the onSuccess function.

Now this code will be run every time a product is created or updated webhook is sent by Shopify.

Gadget gives us a connections object as an argument to our effect function, which has an authenticated Shopify API client ready to go.

Avoid webhook loops

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.graphql(...) with the productUpdate mutation, 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 action.

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: Access control 

To ensure that only the right people can access your app, you can set up access control rules in Gadget. This will allow you to restrict access to certain parts of your app based on the user's role.

By default, merchants will not have access to your custom model APIs, such as allowedTag. You can grant permissions to the shopify-app-users role to allow merchants to access these APIs.

  1. Navigate to the accessControl/permissions page
  2. Grant the shopify-app-users role access to the allowedTag/ model's read, create, and delete actions

Now merchants will be able to manage allowedTag records from the embedded frontend in their Shopify store admin.

Step 5: Build a Shopify admin frontend 

Gadget apps include a web folder for your frontend. This folder contains the following:

  • your Gadget API client in web/api.js
  • reusable React components in web/components
  • a route folder containing your route structure web/routes
  • the index route at web/routes/_app._index.jsx

Additional packages have also been added to your package.json upon connecting to Shopify:

  • @shopify/polaris: Shopify's design system components
  • @gadgetinc/react: provides React hooks for fetching data and calling your API
  • @remix-run/node: a framework for building React applications, which Gadget uses to power your frontend
  • @remix-run/react: provides React components for building your frontend

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

  1. Paste the following code into web/routes/_app._index.jsx
web/routes/_app._index.jsx
React
import { AutoForm, AutoTable } from "@gadgetinc/react/auto/polaris"; import { Card, Layout, Page, Text } from "@shopify/polaris"; import { api } from "../api"; export default function Index() { return ( <Page> <Layout> <Layout.Section> <Card> {/* This form allows users to add new keywords */} <AutoForm action={api.allowedTag.create} title="Add keywords" /> </Card> </Layout.Section> <Layout.Section> <Card> <Text as="h2" variant="headingLg"> Keywords </Text> {/* This table displays the allowed keywords for the Shopify product */} <AutoTable model={api.allowedTag} columns={["keyword"]} /> </Card> </Layout.Section> </Layout> </Page> ); }
import { AutoForm, AutoTable } from "@gadgetinc/react/auto/polaris"; import { Card, Layout, Page, Text } from "@shopify/polaris"; import { api } from "../api"; export default function Index() { return ( <Page> <Layout> <Layout.Section> <Card> {/* This form allows users to add new keywords */} <AutoForm action={api.allowedTag.create} title="Add keywords" /> </Card> </Layout.Section> <Layout.Section> <Card> <Text as="h2" variant="headingLg"> Keywords </Text> {/* This table displays the allowed keywords for the Shopify product */} <AutoTable model={api.allowedTag} columns={["keyword"]} /> </Card> </Layout.Section> </Layout> </Page> ); }
Autocomponents

The @gadgetinc/react/auto library provides autocomponents for your frontend. Autocomponents are pre-built configurable forms and tables that are wired up to your model actions.

Read the autocomponent guide for more information on autocomponent customization.

You don't need to use autocomponents in your frontends. Check out the Shopify frontends guide to learn how to manually read and write data.

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 in the allowedTag Data page in Gadget.

A screenshot of the completed embedded tagger app

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 

  1. 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.
  2. 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 build apps that use Shopify extensions? Check out our pre-purchase checkout UI extension tutorial:

Was this page helpful?