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.
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:
- A Shopify Partners account
- A development store
- At least one product in your store that has a product description
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 gadget.new and select the Shopify app template.
Because we are adding an embedded frontend, we are going to build an app using the Partners connection.
Now we will set up a custom Shopify application in the Partners dashboard.
- Go to the Shopify Partner dashboard
- Click on the link to the Apps page
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 the Create App button
- Click the Create app manually button and enter a name for your Shopify app
- Click on Settings in the side nav bar
- Click on Plugins in the modal that opens
- Select Shopify from the list of plugins and connections
- Copy the Client ID and Client secret from your newly created Shopify app and paste the values into the Gadget 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.
- Select the scopes and models listed below and click Confirm to connect to the custom Shopify app
- Enable the read and write scopes for the Shopify Products API, and select the underlying Product model
Now we want to connect our Gadget app to our custom app in the Partner dashboard.
- In your Shopify app in the Partner dashboard, click on Configuration 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
Now we need to install our Shopify app on a store.
- Go back to the Shopify Partner dashboard
- Click on Apps to go to the Apps page again
- Click on your custom app
- Click on Select store
- Click on the store we want to use to develop our app
- You may be prompted about Store transfer being disabled. This is okay, click Install anyway
- Click Install app to install your Gadget app on your Shopify store
If you are getting a permissions denied error when installing your app, try logging in to the Shopify store Admin!
You will be redirected to an embedded admin app that has been generated for you. The code for this app template can be found in web/routes/index.tsx
.
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
api/models
folder in the nav to add a model, and call itallowedTag
- Click + in the FIELDS section of the
api/models/allowedTag/schema
page to add a field, and name itkeyword
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
api/models/allowedTag/actions/create.ts
to open theallowedTag
model'screate
action - 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.
- 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.
- Navigate to
api/models/allowedTag/data
to go to this model's data page
We can see our added allowedTag
record!
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.
Next, as add some code to the create
action for the shopifyProduct
model to tag products based on the keywords we have stored in the allowedTag
model.
- Navigate to
api/models/shopifyProduct/actions/create.ts
- Paste the following code into the file:
1import {2 applyParams,3 preventCrossShopDataAccess,4 save,5 ActionOptions,6} from "gadget-server";78export const run: ActionRun = async ({ params, record }) => {9 applyParams(params, record);10 await preventCrossShopDataAccess(params, record);11 await save(record);12};1314export const onSuccess: ActionOnSuccess = async ({15 record,16 logger,17 api,18 connections,19}) => {20 if (record.body && record.changed("body")) {21 // get a unique list of words used in the record's description22 let newTags = [...new Set(record.body.match(/\w+(?:'\w+)*/g))];2324 // filter down to only those words which are allowed25 const allowedTags = (await api.allowedTag.findMany()).map((tag) => tag.keyword);26 // merge with any existing tags and use Set to remove duplicates27 const finalTags = [28 ...new Set(29 newTags30 .filter((tag) => allowedTags.includes(tag))31 .concat(record.tags as string[])32 ),33 ];3435 logger.info(36 { newTags, allowedTags, finalTags },37 `applying final tags to product ${record.id}`38 );3940 // write tags back to Shopify41 const shopify = connections.shopify.current;42 if (shopify) {43 await shopify.graphql(44 `mutation ($input: ProductInput!) {45 productUpdate(input: $input) {46 product {47 tags48 }49 userErrors {50 message51 }52 }53 }`,54 {55 input: {56 id: `gid://shopify/Product/${record.id}`,57 tags: finalTags,58 },59 }60 );61 }62 }63};6465export const options: ActionOptions = {66 actionType: "create",67};
1import {2 applyParams,3 preventCrossShopDataAccess,4 save,5 ActionOptions,6} from "gadget-server";78export const run: ActionRun = async ({ params, record }) => {9 applyParams(params, record);10 await preventCrossShopDataAccess(params, record);11 await save(record);12};1314export const onSuccess: ActionOnSuccess = async ({15 record,16 logger,17 api,18 connections,19}) => {20 if (record.body && record.changed("body")) {21 // get a unique list of words used in the record's description22 let newTags = [...new Set(record.body.match(/\w+(?:'\w+)*/g))];2324 // filter down to only those words which are allowed25 const allowedTags = (await api.allowedTag.findMany()).map((tag) => tag.keyword);26 // merge with any existing tags and use Set to remove duplicates27 const finalTags = [28 ...new Set(29 newTags30 .filter((tag) => allowedTags.includes(tag))31 .concat(record.tags as string[])32 ),33 ];3435 logger.info(36 { newTags, allowedTags, finalTags },37 `applying final tags to product ${record.id}`38 );3940 // write tags back to Shopify41 const shopify = connections.shopify.current;42 if (shopify) {43 await shopify.graphql(44 `mutation ($input: ProductInput!) {45 productUpdate(input: $input) {46 product {47 tags48 }49 userErrors {50 message51 }52 }53 }`,54 {55 input: {56 id: `gid://shopify/Product/${record.id}`,57 tags: finalTags,58 },59 }60 );61 }62 }63};6465export const options: ActionOptions = {66 actionType: "create",67};
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
If we want to run this same code on the update
action, we can create a shared utility function to avoid duplicating code.
- Create a new
api/models/shopifyProduct/utils.ts
file:
1import { api, logger, connections } from "gadget-server";23export const applyTags = async (record: {4 body: string;5 tags: string[];6 id: string;7}) => {8 // get a unique list of words used in the record's description9 let newTags = [...new Set(record.body.match(/\w+(?:'\w+)*/g))];1011 // filter down to only those words which are allowed12 const allowedTags = (await api.allowedTag.findMany()).map((tag) => tag.keyword);1314 // merge with any existing tags and use Set to remove duplicates15 const finalTags = [16 ...new Set(17 newTags.filter((tag) => allowedTags.includes(tag)).concat(record.tags)18 ),19 ];2021 logger.info(22 { newTags, allowedTags, finalTags },23 `applying final tags to product ${record.id}`24 );2526 // write tags back to Shopify27 const shopify = connections.shopify.current;28 if (shopify) {29 await shopify.graphql(30 `mutation ($input: ProductInput!) {31 productUpdate(input: $input) {32 product {33 tags34 }35 userErrors {36 message37 }38 }39 }`,40 {41 input: {42 id: `gid://shopify/Product/${record.id}`,43 tags: finalTags,44 },45 }46 );47 }48};
1import { api, logger, connections } from "gadget-server";23export const applyTags = async (record: {4 body: string;5 tags: string[];6 id: string;7}) => {8 // get a unique list of words used in the record's description9 let newTags = [...new Set(record.body.match(/\w+(?:'\w+)*/g))];1011 // filter down to only those words which are allowed12 const allowedTags = (await api.allowedTag.findMany()).map((tag) => tag.keyword);1314 // merge with any existing tags and use Set to remove duplicates15 const finalTags = [16 ...new Set(17 newTags.filter((tag) => allowedTags.includes(tag)).concat(record.tags)18 ),19 ];2021 logger.info(22 { newTags, allowedTags, finalTags },23 `applying final tags to product ${record.id}`24 );2526 // write tags back to Shopify27 const shopify = connections.shopify.current;28 if (shopify) {29 await shopify.graphql(30 `mutation ($input: ProductInput!) {31 productUpdate(input: $input) {32 product {33 tags34 }35 userErrors {36 message37 }38 }39 }`,40 {41 input: {42 id: `gid://shopify/Product/${record.id}`,43 tags: finalTags,44 },45 }46 );47 }48};
- Import the
applyTags
function intoapi/models/shopifyProduct/actions/create.ts
and callapplyTags
from theonSuccess
function:
1import {2 applyParams,3 preventCrossShopDataAccess,4 save,5 ActionOptions,6} from "gadget-server";7import { applyTags } from "../../utils";89export const run: ActionRun = async ({ params, record }) => {10 applyParams(params, record);11 await preventCrossShopDataAccess(params, record);12 await save(record);13};1415export const onSuccess: ActionOnSuccess = async ({16 record,17 logger,18 api,19 connections,20}) => {21 if (record.body) {22 await applyTags({23 body: record.body,24 tags: record.tags as string[],25 id: record.id,26 });27 }28};2930export const options: ActionOptions = {31 actionType: "create",32};
1import {2 applyParams,3 preventCrossShopDataAccess,4 save,5 ActionOptions,6} from "gadget-server";7import { applyTags } from "../../utils";89export const run: ActionRun = async ({ params, record }) => {10 applyParams(params, record);11 await preventCrossShopDataAccess(params, record);12 await save(record);13};1415export const onSuccess: ActionOnSuccess = async ({16 record,17 logger,18 api,19 connections,20}) => {21 if (record.body) {22 await applyTags({23 body: record.body,24 tags: record.tags as string[],25 id: record.id,26 });27 }28};2930export const options: ActionOptions = {31 actionType: "create",32};
- Do the same in
api/models/shopifyProduct/actions/update.ts
, importapplyTags
and call the function inonSuccess
:
1import { applyParams, save, ActionOptions } from "gadget-server";2import { preventCrossShopDataAccess } from "gadget-server/shopify";3import { applyTags } from "../../utils";45export const run: ActionRun = async ({ params, record }) => {6 applyParams(params, record);7 await preventCrossShopDataAccess(params, record);8 await save(record);9};1011export const onSuccess: ActionOnSuccess = async ({12 record,13 logger,14 api,15 connections,16}) => {17 if (record.body && record.changed("body")) {18 await applyTags({19 body: record.body,20 tags: record.tags as string[],21 id: record.id,22 });23 }24};2526export const options: ActionOptions = { actionType: "update" };
1import { applyParams, save, ActionOptions } from "gadget-server";2import { preventCrossShopDataAccess } from "gadget-server/shopify";3import { applyTags } from "../../utils";45export const run: ActionRun = async ({ params, record }) => {6 applyParams(params, record);7 await preventCrossShopDataAccess(params, record);8 await save(record);9};1011export const onSuccess: ActionOnSuccess = async ({12 record,13 logger,14 api,15 connections,16}) => {17 if (record.body && record.changed("body")) {18 await applyTags({19 body: record.body,20 tags: record.tags as string[],21 id: record.id,22 });23 }24};2526export const options: ActionOptions = { actionType: "update" };
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.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.
- Navigate to the
accessControl/permissions
page - Grant the
shopify-app-users
role access to theallowedTag/
model'sread
,create
, anddelete
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.ts
- a default React app in
web/main.tsx
- router setup in
web/App.tsx
- two pages at
web/routes/index.tsx
andweb/routes/about.tsx
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@gadgetinc/react/auto
: provides autocomponents, pre-built forms and tables connected to your APIs
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
web/routes/index.tsx
1import { Layout, Page, Card, Text } from "@shopify/polaris";2import { AutoForm, AutoTable } from "@gadgetinc/react/auto/polaris";3import { api } from "../api";45export default function () {6 // use autocomponents to automatically create a form and table to manage allowedTag records7 return (8 <Page title="Keyword manager">9 <Layout>10 <Layout.Section>11 <Card>12 {/** AutoForm automatically calls allowedTag.create on form submission */}13 <AutoForm action={api.allowedTag.create} title="Add keywords" />14 </Card>15 </Layout.Section>16 <Layout.Section>17 <Card>18 <Text as="h2" variant="headingLg">19 Manage keywords20 </Text>21 {/** AutoTable allows you to delete allowedTag records (in bulk!) */}22 <AutoTable model={api.allowedTag} columns={["keyword"]} />23 </Card>24 </Layout.Section>25 </Layout>26 </Page>27 );28}
1import { Layout, Page, Card, Text } from "@shopify/polaris";2import { AutoForm, AutoTable } from "@gadgetinc/react/auto/polaris";3import { api } from "../api";45export default function () {6 // use autocomponents to automatically create a form and table to manage allowedTag records7 return (8 <Page title="Keyword manager">9 <Layout>10 <Layout.Section>11 <Card>12 {/** AutoForm automatically calls allowedTag.create on form submission */}13 <AutoForm action={api.allowedTag.create} title="Add keywords" />14 </Card>15 </Layout.Section>16 <Layout.Section>17 <Card>18 <Text as="h2" variant="headingLg">19 Manage keywords20 </Text>21 {/** AutoTable allows you to delete allowedTag records (in bulk!) */}22 <AutoTable model={api.allowedTag} columns={["keyword"]} />23 </Card>24 </Layout.Section>25 </Layout>26 </Page>27 );28}
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.
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.
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.
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: