If you haven't set up a Shopify connection before, follow the Shopify quickstart guide to set up the Shopify connection.
For this tutorial, you need the write_products Shopify API scope and the product model.
Confirm that your Shopify connection has the write_products scope and the product model selected in the connection settings:
Step 2: Add a data model to store keywords
The next step is to create a model that will store your list of vetted keywords that you can use to power your tagging script.
Click on Files in the left nav to go to the file explorer.
Click + next to the api/models folder in the nav to add a model, and call it allowedTag.
Click + in the FIELDS section of the api/models/allowedTag/schema page to add a field, and name it keyword.
It doesn't make sense to create a record without a keyword, so add the Required validation to the field.
Test your allowedTag.create action
Gadget instantly creates a new table and column in the underlying database and generates a GraphQL CRUD API and API client for this model. Test your allowedTag.create action in the API Playground:
Click on api/models/allowedTag/actions/create.js to open the allowedTag model's create 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.
Enter a keyword to store in your database and run the action. Examples of keywords might include different product categories or brands. Make sure that the entered keyword is present in the product description of one of your store's products!
Navigate to api/models/allowedTag/data to go to this model's data page.
Your backend needs to determine the tags to write to a product when a product is created or updated, then write those tags back to Shopify.
Update your product create and update actions
The create, update, and delete actions for Shopify models are triggered by webhooks or a Shopify data sync. Code in the run function of these actions creates, updates, or deletes the record in the database, and validates that the request is coming from the current shop.
Paste the following in api/models/shopifyProduct/actions/create.js to call an applyTags function after a product is created:
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 }) => {
applyParams(params, record);
await preventCrossShopDataAccess(params, record);
await save(record);
};
export const onSuccess: ActionOnSuccess = async ({ record }) => {
// 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 }) => {
applyParams(params, record);
await preventCrossShopDataAccess(params, record);
await save(record);
};
export const onSuccess: ActionOnSuccess = async ({ record }) => {
// 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: {},
};
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.
Updating the tags on a product will fire Shopify's products/update webhook. If you are using this webhook as a trigger for running custom code that updates a product, you will be stuck in an endless loop of updating products and running the action.
You can use record.changed to determine if changes have been made to the key on this record and only run code if changes have occurred.
For more info on change tracking in Gadget, refer to the documentation.
Copy the onSuccess function from api/models/shopifyProduct/actions/create.js into api/models/shopifyProduct/actions/update.js so that tags are applied when a product is updated as well. Don't forget to import the applyTags function at the top of the file!
api/models/shopifyProduct/actions/update.js
JavaScript
import { applyTags } from "../utils";
// ... other imports
// ... run function
export const onSuccess: ActionOnSuccess = async ({ record }) => {
if (record.changed("body")) {
await applyTags({
id: record.id,
body: record.body,
tags: record.tags as string[],
});
}
};
// ... options
import { applyTags } from "../utils";
// ... other imports
// ... run function
export const onSuccess: ActionOnSuccess = async ({ record }) => {
if (record.changed("body")) {
await applyTags({
id: record.id,
body: record.body,
tags: record.tags as string[],
});
}
};
// ... options
Add a shared utility function
Now you need to implement the applyTags function containing the logic for determining which tags to apply, then writing those tags back to Shopify. A util file is used so you can share this code between the create and update actions.
Create a utils.js file at api/models/shopifyProduct/utils.js.
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.
*/
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 wordsInProductDescription = new Set(body.match(/\w+(?:'\w+)*/g));
// filter down to only those words which are allowed
const savedKeywords = (
await api.allowedTag.findMany({
// 250 is the max page size in Gadget AND max number of tags in Shopify
first: 250,
})
).map((tag) => tag.keyword);
// define tags as a set for quick reads
const tagsSet = new Set(tags);
// get list of non-unique keywords to apply as tags
// make sure they aren't already tags on the product
const keywordsToApply = savedKeywords.filter(
(keyword) => wordsInProductDescription.has(keyword) && !tagsSet.has(keyword)
);
// use the built-in logger for backend debugging
logger.info(
{
wordsInProductDescription: [...wordsInProductDescription],
savedKeywords,
keywordsToApply,
},
"words from product description, stored keywords, and the overlap to be applied"
);
if (keywordsToApply.length > 0) {
// merge with existing tags
const finalTags = Array.from(new Set([...keywordsToApply, ...tags]));
// log the tags you are applying
logger.info({ finalTags }, `applying finalTags to product ${id}`);
// enqueue a background action to write the tags to Shopify
await api.enqueue(
// the action to run in the background
api.writeToShopify,
// the parameters to pass to the action
{
tags: finalTags,
productId: id,
shopId: connections.shopify.currentShopId!.toString(),
},
// the options for the background action (concurrency control in this case)
{
queue: {
name: "shopify-product-update",
maxConcurrency: 4,
},
}
);
}
}
};
import { logger, api, connections } from "gadget-server";
/**
* Applies tags to a Shopify product using the Shopify API.
*/
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 wordsInProductDescription = new Set(body.match(/\w+(?:'\w+)*/g));
// filter down to only those words which are allowed
const savedKeywords = (
await api.allowedTag.findMany({
// 250 is the max page size in Gadget AND max number of tags in Shopify
first: 250,
})
).map((tag) => tag.keyword);
// define tags as a set for quick reads
const tagsSet = new Set(tags);
// get list of non-unique keywords to apply as tags
// make sure they aren't already tags on the product
const keywordsToApply = savedKeywords.filter(
(keyword) => wordsInProductDescription.has(keyword) && !tagsSet.has(keyword)
);
// use the built-in logger for backend debugging
logger.info(
{
wordsInProductDescription: [...wordsInProductDescription],
savedKeywords,
keywordsToApply,
},
"words from product description, stored keywords, and the overlap to be applied"
);
if (keywordsToApply.length > 0) {
// merge with existing tags
const finalTags = Array.from(new Set([...keywordsToApply, ...tags]));
// log the tags you are applying
logger.info({ finalTags }, `applying finalTags to product ${id}`);
// enqueue a background action to write the tags to Shopify
await api.enqueue(
// the action to run in the background
api.writeToShopify,
// the parameters to pass to the action
{
tags: finalTags,
productId: id,
shopId: connections.shopify.currentShopId!.toString(),
},
// the options for the background action (concurrency control in this case)
{
queue: {
name: "shopify-product-update",
maxConcurrency: 4,
},
}
);
}
}
};
The applyTags function determines which tags to apply and then uses api.enqueue to run a writeToShopify action (that you will implement next) as a background action to update the product in Shopify.
Use a background action to update Shopify
To finish off your backend, you need to implement the writeToShopify action that will be called in the background to update the product in Shopify.
This action will use the authenticated Shopify API client that Gadget provides to write the tags back to Shopify, using their GraphQL Admin API.
Click + next to the api/actions folder to add a new global action: writeToShopify.js.
Paste the following code into api/actions/writeToShopify.js:
api/actions/writeToShopify.js
JavaScript
export const run: ActionRun = async ({ params, logger, api, connections }) => {
if (!params.shopId || !params.productId) {
throw new Error("shopId and productId are required");
}
// get an auth'd Shopify client for the shop
const shopify = await connections.shopify.forShopId(params.shopId);
// use the Shopify GraphQL Admin API to update the product's tags
return await shopify.graphql(
`mutation ($id: ID!, $tags: [String!]) {
productUpdate(product: {id: $id, tags: $tags}) {
product {
id
}
userErrors {
message
}
}
}`,
{
id: `gid://shopify/Product/${params.productId}`,
tags: params.tags,
}
);
};
// define the custom params for this action
export const params = {
shopId: {
type: "string",
},
productId: {
type: "string",
},
tags: {
type: "array",
items: {
type: "string",
},
},
};
export const run: ActionRun = async ({ params, logger, api, connections }) => {
if (!params.shopId || !params.productId) {
throw new Error("shopId and productId are required");
}
// get an auth'd Shopify client for the shop
const shopify = await connections.shopify.forShopId(params.shopId);
// use the Shopify GraphQL Admin API to update the product's tags
return await shopify.graphql(
`mutation ($id: ID!, $tags: [String!]) {
productUpdate(product: {id: $id, tags: $tags}) {
product {
id
}
userErrors {
message
}
}
}`,
{
id: `gid://shopify/Product/${params.productId}`,
tags: params.tags,
}
);
};
// define the custom params for this action
export const params = {
shopId: {
type: "string",
},
productId: {
type: "string",
},
tags: {
type: "array",
items: {
type: "string",
},
},
};
Gadget gives us a connections object as an argument to the action, which has an authenticated Shopify API client ready to go. This action's params are defined at the bottom of the file.
Why use a background action?
The writeToShopify action is called as a background action using api.enqueue to handle Shopify's rate
limits. Background actions have built-in retry logic and have concurrency controls to help manage rate limits when writing to external
services.
Your backend is done. The product actions will use the applyTags function to determine the tags to apply, then call the writeToShopify action in the background to update the product in Shopify.
Step 4: Access control
Gadget has built-in access control permissions to handle authorization for your app's API. This allows you to restrict access based on the user's role.
By default, Shopify merchants will not have access to your custom model APIs, for example, the allowedTag actions. You can grant permissions to the shopify-app-users role to allow merchants to successfully call these APIs.
Navigate to the accessControl/permissions page.
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
Your app frontend is in the web folder, and includes:
an API client for your Gadget app (web/api.js).
reusable React components (web/components).
file-based routing (web/routes, where the index route is web/routes/_app._index.jsx).
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/_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.
Click on the preview button () in the Gadget editor to go back to your app in the development store admin and preview your frontend.
You are done building, time to test it out!
Step 6: Test your app
Add some keywords to your product tagger. You want to make sure to use words that are in your product descriptions.
Navigate to Installs using the left-nav in the Gadget editor, then click Sync on the connected store.
Gadget will fetch each of the existing products in Shopify and run them through your shopifyProduct/create and shopifyProduct/update actions, applying tags as needed. Because those actions are also triggered by webhooks, adding or updating a product will also run your tagging logic.
Congratulations! You have built a full stack, custom app that automatically updates tags in Shopify.
Next steps
Have questions about the tutorial? Ask the Gadget team in our developer Discord.
Want to build apps that use Shopify extensions? Try the checkout UI extension tutorial: