Build a full-stack single-click search keyword app for BigCommerce 

Expected time: 25 minutes

This tutorial shows you how to build a full-stack, single-click app for BigCommerce that helps merchants manage product search keywords.

The app allows merchants to enter keywords, automatically checks if those keywords exist in product descriptions, and adds them as search_keywords on products.

A screenshot of the completed application, embedded in a BigCommerce control panel. The top of the page is a form with a single input for entering keywords, the bottom of the page is a table with existing keywords.

By the end of this tutorial, you will have:

  • Set up a BigCommerce connection
  • Subscribed to BigCommerce webhooks
  • Stored BigCommerce data in a Gadget database
  • Built a complete serverless Node.js backend
  • Used the BigDesign library to build a React frontend
  • (Optional) Sync historical product data from BigCommerce

Prerequisites 

Before starting, you will need:

Step 1: Create a new Gadget app and connect to BigCommerce 

Start by creating a new Gadget app and connecting to BigCommerce.

  1. Follow the BigCommerce quickstart guide to set up a BigCommerce connection and install your app on a sandbox store. Select the Products Modify OAuth scope when setting up the connection.

You now have a full-stack, single-click BigCommerce app. OAuth and frontend sessions are handled, and you can subscribe to BigCommerce webhooks.

Step 2: Create data models for products and search keywords 

You need to store both product data and search keywords entered by merchants in your Gadget database. You can create data models in Gadget to store this data.

  1. Right-click on the api/models/bigcommerce directory and select Add model.
  2. Name the model product and add the following fields and validations:
Field nameField typeValidations
bigcommerceIdnumberUniqueness, Required
namestringRequired
descriptionstring
searchKeywordsstring
storebelongs toRequired
typeenum (with options: digital & physical)Required
weightnumberRequired
pricenumberRequired
  1. For the store field, select the bigcommerce/store model as the parent model, so that bigcommerce/store has many bigcommerce/product.
A screenshot of the store relationship field on the bigcommerce/product model. The inverse of the relationship is a has many, so that bigcommerce/store has many bigcommerce/product records.
  1. For the type field, add the following options: physical and digital.
A screenshot of the enum options for the bigcommerce/product model's type field.
bigcommerceId uniqueness validation

For multi-tenant apps, you may have multiple stores whose resources have the same bigcommerceId. To avoid conflicts, you can scope the Uniqueness validation on bigcommerceId by the store relationship. This ensures that bigcommerceId is unique per store.

  1. Click the + button next to api/models to create another new model outside the bigcommerce namespace.
  2. Name the model searchKeyword with the following field:
Field nameField typeValidations
valuestringUniqueness, Required

Creating the searchKeyword model also generates a CRUD API automatically.

Why store product data?

Product data needs to be stored to avoid webhook looping on store/product/updated webhooks. If no data is stored, change detection cannot be used to see if a product must be updated.

Step 3: Subscribe to store/product webhooks 

Now you can subscribe to webhooks and use them to run some code that will check for stored search keywords in product descriptions. If a keyword is found, the keyword will be written back to the product in BigCommerce.

  1. Click the + button next to api/actions and enter bigcommerce/handleProductWebhooks.js. This creates a bigcommerce namespace folder and the new action.
  2. Click the + button in the action's Triggers card and select BigCommerce.
  3. Select the store/product/created, store/product/updated, and store/product/deleted webhook scopes.
  4. (Optional) Remove the Generated API endpoint trigger from the action.

Now this action will run anytime a product is created, updated, or deleted in BigCommerce!

When a product webhook is fired, you want to call the bigcommerce/product model's actions to create, update, or delete records in your Gadget app database.

Notice that the upsert meta API is used to handle store/product/updated webhooks. This is because the product may not yet exist in your database if you have not synced historical data.

  1. Paste the following code in api/actions/bigcommerce/handleProductWebhooks.js:
api/actions/bigcommerce/handleProductWebhooks.js
JavaScript
import { ActionOptions } from "gadget-server"; export const run: ActionRun = async ({ params, api, connections, trigger }) => { if (trigger.type !== "bigcommerce_webhook") { throw new Error("This action can only be triggered by BigCommerce webhooks"); } // handle deletes early, don't need to query BigCommerce, just need to remove data from Gadget if (trigger.scope === "store/product/deleted") { // see if product exists in database const productRecordToDelete = await api.bigcommerce.product.maybeFindFirst({ filter: { bigcommerceId: { equals: params.id } }, select: { id: true }, }); if (productRecordToDelete) { // if it exists, delete it await api.bigcommerce.product.delete(productRecordToDelete.id); } return; } // get the BigCommerce API client for the current store const bigcommerce = connections.bigcommerce.current!; // fetch the product data const product = await bigcommerce.v3.get("/catalog/products/{product_id}", { path: { product_id: params.id, }, }); if (product) { // split out fields we are storing in database const productToSave = { bigcommerceId: product.id, name: product.name, description: product.description, searchKeywords: product.search_keywords, store: { // get the id of the store record in Gadget to create the relationship _link: connections.bigcommerce.currentStoreId, }, type: product.type, price: product.price, weight: product.weight, }; // a switch statement to handle different product webhook topics // use the trigger parameter to access the webhook scope switch (trigger.scope) { case "store/product/created": // add product to database await api.bigcommerce.product.create(productToSave); break; case "store/product/updated": // upsert the product into the database, using the bigcommerceId as the key identifier await api.bigcommerce.product.upsert({ ...productToSave, on: ["bigcommerceId", "store"], }); break; } } else { throw new Error(`Product ${params.id} not found in BigCommerce store!`); } }; export const options: ActionOptions = { triggers: { api: false, bigcommerce: { webhooks: [ "store/product/created", "store/product/updated", "store/product/deleted", ], }, }, };
import { ActionOptions } from "gadget-server"; export const run: ActionRun = async ({ params, api, connections, trigger }) => { if (trigger.type !== "bigcommerce_webhook") { throw new Error("This action can only be triggered by BigCommerce webhooks"); } // handle deletes early, don't need to query BigCommerce, just need to remove data from Gadget if (trigger.scope === "store/product/deleted") { // see if product exists in database const productRecordToDelete = await api.bigcommerce.product.maybeFindFirst({ filter: { bigcommerceId: { equals: params.id } }, select: { id: true }, }); if (productRecordToDelete) { // if it exists, delete it await api.bigcommerce.product.delete(productRecordToDelete.id); } return; } // get the BigCommerce API client for the current store const bigcommerce = connections.bigcommerce.current!; // fetch the product data const product = await bigcommerce.v3.get("/catalog/products/{product_id}", { path: { product_id: params.id, }, }); if (product) { // split out fields we are storing in database const productToSave = { bigcommerceId: product.id, name: product.name, description: product.description, searchKeywords: product.search_keywords, store: { // get the id of the store record in Gadget to create the relationship _link: connections.bigcommerce.currentStoreId, }, type: product.type, price: product.price, weight: product.weight, }; // a switch statement to handle different product webhook topics // use the trigger parameter to access the webhook scope switch (trigger.scope) { case "store/product/created": // add product to database await api.bigcommerce.product.create(productToSave); break; case "store/product/updated": // upsert the product into the database, using the bigcommerceId as the key identifier await api.bigcommerce.product.upsert({ ...productToSave, on: ["bigcommerceId", "store"], }); break; } } else { throw new Error(`Product ${params.id} not found in BigCommerce store!`); } }; export const options: ActionOptions = { triggers: { api: false, bigcommerce: { webhooks: [ "store/product/created", "store/product/updated", "store/product/deleted", ], }, }, };

This will handle the product webhook topics and call the required bigcommerce/product action.

Action parameters

Gadget actions have a high-context parameter that provides you with everything you need to interact with the rest of your app.

In this example, we use:

  • params to get the product ID from the webhook payload
  • api to call your app's API and interact with your database
  • connections to get the BigCommerce API client
  • trigger to read the webhook topic
  • logger to write to Gadget's built-in Logs tool

Step 4: Write search keywords back to BigCommerce 

When a product is created or updated in BigCommerce, you want to check the product description for search keywords. If a keyword is found, it will be written back to the product in BigCommerce.

You want to run this code when you create a new product or update an existing product. You can create a utility function to handle this logic and call it from your create and update actions.

  1. Create a utils.js file in the api/models/bigcommerce/product directory.
  2. Add the following code to utils.js:
api/models/bigcommerce/product/utils.js
JavaScript
import { api, logger, connections } from "gadget-server"; export const getKeywords = async (record: { description: string; searchKeywords: string | null; storeId: string; bigcommerceId: number; type: "physical" | "digital"; price: number; weight: number; name: string; }) => { // get array of unique words in product description const descriptionWords = [...new Set(record.description.match(/\w+(?:'\w+)*/g))]; // get array of entered search keywords const savedKeywords = (await api.searchKeyword.findMany()).map( (searchKeyword) => searchKeyword.value ); // get the final list of keywords to be used for the product let searchKeywords = [ ...new Set(descriptionWords.filter((tag) => savedKeywords.includes(tag))), ]; // concatenate with existing searchKeywords, using a Set to remove duplicates if (record.searchKeywords) { searchKeywords = Array.from( new Set(record.searchKeywords.split(",").concat(searchKeywords)) ); } logger.info( { descriptionWords, savedKeywords, searchKeywords }, "keyword and description info" ); // get the storeHash for the current product const store = await api.bigcommerce.store.findById(record.storeId, { select: { storeHash: true }, }); // use the storeHash to get a BigCommerce API client for the current store const bigcommerce = await connections.bigcommerce.forStoreHash(store.storeHash); // update the search_keywords! await bigcommerce.v3.put("/catalog/products/{product_id}", { path: { product_id: record.bigcommerceId, }, body: { search_keywords: searchKeywords.toString(), type: record.type, price: record.price, weight: record.weight, name: record.name, }, }); };
import { api, logger, connections } from "gadget-server"; export const getKeywords = async (record: { description: string; searchKeywords: string | null; storeId: string; bigcommerceId: number; type: "physical" | "digital"; price: number; weight: number; name: string; }) => { // get array of unique words in product description const descriptionWords = [...new Set(record.description.match(/\w+(?:'\w+)*/g))]; // get array of entered search keywords const savedKeywords = (await api.searchKeyword.findMany()).map( (searchKeyword) => searchKeyword.value ); // get the final list of keywords to be used for the product let searchKeywords = [ ...new Set(descriptionWords.filter((tag) => savedKeywords.includes(tag))), ]; // concatenate with existing searchKeywords, using a Set to remove duplicates if (record.searchKeywords) { searchKeywords = Array.from( new Set(record.searchKeywords.split(",").concat(searchKeywords)) ); } logger.info( { descriptionWords, savedKeywords, searchKeywords }, "keyword and description info" ); // get the storeHash for the current product const store = await api.bigcommerce.store.findById(record.storeId, { select: { storeHash: true }, }); // use the storeHash to get a BigCommerce API client for the current store const bigcommerce = await connections.bigcommerce.forStoreHash(store.storeHash); // update the search_keywords! await bigcommerce.v3.put("/catalog/products/{product_id}", { path: { product_id: record.bigcommerceId, }, body: { search_keywords: searchKeywords.toString(), type: record.type, price: record.price, weight: record.weight, name: record.name, }, }); };

Now you can use this utility function in your create and update actions.

  1. Update api/models/bigcommerce/product/actions/create.js:
api/models/bigcommerce/product/actions/create.js
JavaScript
import { applyParams, save, ActionOptions } from "gadget-server"; import { getKeywords } from "../utils"; export const run: ActionRun = async ({ params, record }) => { applyParams(params, record); await save(record); }; export const onSuccess: ActionOnSuccess = async ({ record }) => { if (record.storeId && record.description && record.description !== "") { await getKeywords({ ...record, storeId: record.storeId, description: record.description, }); } }; export const options: ActionOptions = { actionType: "create", };
import { applyParams, save, ActionOptions } from "gadget-server"; import { getKeywords } from "../utils"; export const run: ActionRun = async ({ params, record }) => { applyParams(params, record); await save(record); }; export const onSuccess: ActionOnSuccess = async ({ record }) => { if (record.storeId && record.description && record.description !== "") { await getKeywords({ ...record, storeId: record.storeId, description: record.description, }); } }; export const options: ActionOptions = { actionType: "create", };
  1. Update api/models/bigcommerce/product/actions/update.js:
api/models/bigcommerce/product/actions/update.js
JavaScript
import { ActionOptions, applyParams, save } from "gadget-server"; import { getKeywords } from "../utils"; export const run: ActionRun = async ({ params, record }) => { applyParams(params, record); await save(record); }; export const onSuccess: ActionOnSuccess = async ({ record }) => { if ( record.storeId && record.description && record.description !== "" && record.changed("description") ) { await getKeywords({ ...record, storeId: record.storeId, description: record.description, }); } }; export const options: ActionOptions = { actionType: "update", };
import { ActionOptions, applyParams, save } from "gadget-server"; import { getKeywords } from "../utils"; export const run: ActionRun = async ({ params, record }) => { applyParams(params, record); await save(record); }; export const onSuccess: ActionOnSuccess = async ({ record }) => { if ( record.storeId && record.description && record.description !== "" && record.changed("description") ) { await getKeywords({ ...record, storeId: record.storeId, description: record.description, }); } }; export const options: ActionOptions = { actionType: "update", };

You have subscribed to webhooks and are writing data back to BigCommerce. Now you can build a frontend to allow merchants to enter search keywords.

Model action parameters

Model actions also include a record parameter for the record being created or updated, which is required to prevent webhook loops. Read more about the record API and preventing webhook loops.

Step 5: Grant merchants API access to search keywords 

All Gadget apps have authorization built in. A role-based access control system allows you to restrict API and data access to merchants and unauthenticated shoppers. Merchants will not have access to data stored in your custom models by default. You need to grant them access to your actions to be able to call them from your frontend.

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

The bigcommerce-app-users role is automatically assigned to merchants who install your app. Read more about roles.

Step 6: Build a React frontend with the BigDesign library 

You have finished building your database and backend. Now you just need a frontend that allows merchants to enter search keywords.

  1. Replace the code in web/routes/index.jsx:
web/routes/index.jsx
React
import { Box, Panel, Text, StatefulTable, Form, FormGroup, Input, Button, ProgressCircle, Message } from "@bigcommerce/big-design"; import { DeleteIcon } from "@bigcommerce/big-design-icons"; import { useFindMany, useActionForm, useBulkAction } from "@gadgetinc/react"; import { useState } from "react"; import { api } from "../api"; export default function () { const [selectedKeywordIds, setSelectedKeywordIds] = useState<string[]>([]); // a useFindMany hook to fetch search keyword data const [{ data, fetching, error }] = useFindMany(api.searchKeyword); // the useAction hook is used for deleting existing keywords const [{ error: deleteKeywordsError }, deleteKeywords] = useBulkAction(api.searchKeyword.bulkDelete); // useActionForm used to manage form state and submission for creating new keywords const { submit, register, reset, error: createError, formState } = useActionForm(api.searchKeyword.create); return ( <> <Panel> {createError && <ErrorMessage title="Error adding keyword" error={createError} />} <Form onSubmit={async (event) => { event.preventDefault(); await submit(); reset(); }} > <FormGroup> <Input description="Enter a new product search keyword" label="Add search keywords" required {...register("value")} autoComplete="off" /> </FormGroup> <Box marginTop="xxLarge"> <Button type="submit" disabled={formState.isSubmitting}> Add keyword </Button> </Box> </Form> </Panel> <Panel description="Current search keywords"> {fetching && <ProgressCircle size="large" />} {error && <ErrorMessage title="Error reading keywords" error={error} />} {deleteKeywordsError && <ErrorMessage title="Error removing keyword" error={deleteKeywordsError} />} {data && ( <StatefulTable stickyHeader pagination itemName="search keywords" selectable onSelectionChange={(value) => setSelectedKeywordIds(value.map((v) => v.id))} columns={[ { header: "Keyword", hash: "value", render: ({ value }) => value, }, ]} items={data} actions={ <Button actionType="destructive" variant="secondary" iconLeft={<DeleteIcon />} disabled={selectedKeywordIds.length === 0} onClick={async () => { await deleteKeywords({ ids: selectedKeywordIds, }); setSelectedKeywordIds([]); }} > Delete </Button> } emptyComponent={<Text marginTop="medium">No search keywords - start by adding one above!</Text>} /> )} </Panel> </> ); } const ErrorMessage = ({ title, error }: { title: string; error: Error }) => { return ( <Message type="error" header={title} messages={[ { text: error.toString(), }, ]} marginBottom="medium" /> ); };
import { Box, Panel, Text, StatefulTable, Form, FormGroup, Input, Button, ProgressCircle, Message } from "@bigcommerce/big-design"; import { DeleteIcon } from "@bigcommerce/big-design-icons"; import { useFindMany, useActionForm, useBulkAction } from "@gadgetinc/react"; import { useState } from "react"; import { api } from "../api"; export default function () { const [selectedKeywordIds, setSelectedKeywordIds] = useState<string[]>([]); // a useFindMany hook to fetch search keyword data const [{ data, fetching, error }] = useFindMany(api.searchKeyword); // the useAction hook is used for deleting existing keywords const [{ error: deleteKeywordsError }, deleteKeywords] = useBulkAction(api.searchKeyword.bulkDelete); // useActionForm used to manage form state and submission for creating new keywords const { submit, register, reset, error: createError, formState } = useActionForm(api.searchKeyword.create); return ( <> <Panel> {createError && <ErrorMessage title="Error adding keyword" error={createError} />} <Form onSubmit={async (event) => { event.preventDefault(); await submit(); reset(); }} > <FormGroup> <Input description="Enter a new product search keyword" label="Add search keywords" required {...register("value")} autoComplete="off" /> </FormGroup> <Box marginTop="xxLarge"> <Button type="submit" disabled={formState.isSubmitting}> Add keyword </Button> </Box> </Form> </Panel> <Panel description="Current search keywords"> {fetching && <ProgressCircle size="large" />} {error && <ErrorMessage title="Error reading keywords" error={error} />} {deleteKeywordsError && <ErrorMessage title="Error removing keyword" error={deleteKeywordsError} />} {data && ( <StatefulTable stickyHeader pagination itemName="search keywords" selectable onSelectionChange={(value) => setSelectedKeywordIds(value.map((v) => v.id))} columns={[ { header: "Keyword", hash: "value", render: ({ value }) => value, }, ]} items={data} actions={ <Button actionType="destructive" variant="secondary" iconLeft={<DeleteIcon />} disabled={selectedKeywordIds.length === 0} onClick={async () => { await deleteKeywords({ ids: selectedKeywordIds, }); setSelectedKeywordIds([]); }} > Delete </Button> } emptyComponent={<Text marginTop="medium">No search keywords - start by adding one above!</Text>} /> )} </Panel> </> ); } const ErrorMessage = ({ title, error }: { title: string; error: Error }) => { return ( <Message type="error" header={title} messages={[ { text: error.toString(), }, ]} marginBottom="medium" /> ); };
@gadgetinc/react hooks

The @gadgetinc/react library provides hooks for fetching data (useFindMany), calling actions (useAction), and managing form state (useActionForm).

Test it out 

Now you can test your app

  1. Open your app in the sandbox store control panel and enter some search keywords.
  2. Create a new product in BigCommerce with one of the stored keywords in the description.
  3. Refresh the product page to see the keywords written back to the product.

Congrats! You have just built a full-stack, single-click app for BigCommerce in Gadget!

Step 7 (Optional): Sync historical product data from BigCommerce 

Your app works great for new or updated products, but what about the products that were already in the store before you installed the app? You can sync historical product data from BigCommerce to your Gadget database to ensure that all products have search keywords.

The bigcommerce/store model has an install.js action that runs when the app is installed. We can use this action to fetch all products from BigCommerce and enqueue a background action to create them in Gadget.

  1. Add the following code to api/models/bigcommerce/store/install.js:
api/models/bigcommerce/store/install.js
JavaScript
import { ActionOptions, applyParams, save } from "gadget-server"; export const run: ActionRun = async ({ params, record }) => { applyParams(params, record); await save(record); }; export const onSuccess: ActionOnSuccess = async ({ api, connections }) => { // set the batch size to 50, process 50 products at a time const BATCH_SIZE = 50; const bigcommerce = connections.bigcommerce.current!; // use the API client to fetch all products, and return const products = await bigcommerce.v3.list(`/catalog/products`); // get the current store from the database const store = await api.bigcommerce.store.findByStoreHash( connections.bigcommerce.currentStoreHash!, { // only read the id field select: { id: true }, } ); const productPayload = []; // use a for await loop to iterate over the AsyncIterables, add to an array for await (const product of products) { productPayload.push({ bigcommerceId: product.id, name: product.name, description: product.description, searchKeywords: product.search_keywords, store: { _link: store.id, }, // use the upsert meta action to avoid creating duplicates on: ["bigcommerceId", "store"], }); // enqueue 80 actions at a time if (productPayload.length >= BATCH_SIZE) { const section = productPayload.splice(0, BATCH_SIZE); // bulk enqueue create action await api.enqueue(api.bigcommerce.product.bulkUpsert, section, { queue: { name: "product-sync" }, }); } } // enqueue any remaining products await api.enqueue(api.bigcommerce.product.bulkUpsert, productPayload, { queue: { name: "product-sync" }, }); }; export const options: ActionOptions = { actionType: "create", // 15 minute timeout for the sync timeoutMS: 900000, };
import { ActionOptions, applyParams, save } from "gadget-server"; export const run: ActionRun = async ({ params, record }) => { applyParams(params, record); await save(record); }; export const onSuccess: ActionOnSuccess = async ({ api, connections }) => { // set the batch size to 50, process 50 products at a time const BATCH_SIZE = 50; const bigcommerce = connections.bigcommerce.current!; // use the API client to fetch all products, and return const products = await bigcommerce.v3.list(`/catalog/products`); // get the current store from the database const store = await api.bigcommerce.store.findByStoreHash( connections.bigcommerce.currentStoreHash!, { // only read the id field select: { id: true }, } ); const productPayload = []; // use a for await loop to iterate over the AsyncIterables, add to an array for await (const product of products) { productPayload.push({ bigcommerceId: product.id, name: product.name, description: product.description, searchKeywords: product.search_keywords, store: { _link: store.id, }, // use the upsert meta action to avoid creating duplicates on: ["bigcommerceId", "store"], }); // enqueue 80 actions at a time if (productPayload.length >= BATCH_SIZE) { const section = productPayload.splice(0, BATCH_SIZE); // bulk enqueue create action await api.enqueue(api.bigcommerce.product.bulkUpsert, section, { queue: { name: "product-sync" }, }); } } // enqueue any remaining products await api.enqueue(api.bigcommerce.product.bulkUpsert, productPayload, { queue: { name: "product-sync" }, }); }; export const options: ActionOptions = { actionType: "create", // 15 minute timeout for the sync timeoutMS: 900000, };

Now when you install your app on a sandbox store, all products will be synced to your Gadget database, and search keywords will be added to the products in BigCommerce!

To test this code out:

  1. Uninstall your app from your sandbox store.
  2. In the Gadget command palette, run Bulk reset model data and uncheck the searchKeyword model so that your searchKeyword records are not deleted!
  3. Reinstall your app on your sandbox store.

Next steps 

Was this page helpful?