Sanity Connect for Shopify
Setting up and configuring Sanity Connect for Shopify
You can install Sanity Connect on the Shopify app store to set up synchronization of your product inventory to your content lake. This will let you add custom metadata to product information, as well as reference and use product information in your other content.
How to install Sanity Connect in your Shopify store and connect it to a project:
- Find Sanity Connect on the Shopify app store and push the Add App button
- If you have multiple Shopify accounts, you need to choose the one that contains the store you want to add the app to
- After choosing the store, Shopify will show you the permission Sanity Connect needs to work and its data policies. You can push the Install app button to continue.
- The app will ask you to connect to your Sanity account. If you don't have one, you can choose to Create new account.
- When you're logged in, you will need to connect your shop with a project on Sanity. You can choose between existing projects or create a new one (for free).
- Select organization to list out projects under it, and then the project and dataset you want to sync to.
- You are now ready to configure the app.
Gotcha
Once you chose Start synchronizing now, the app will add product documents to your content lake. It can be wise to test it against a non-production dataset if you haven't tried it before.
You can configure how and when Sanity Connect should synchronize products to your content lake. You can change these options at any time.
This will synchronize all products and variants as documents to your content lake. You can check the reference below to preview the data model for these documents.
This option will let you enter an endpoint that receives updates from Shopify and returns the documents you want to sync to your content lake. Typically that will be a serverless function handler where you can reshape the data and do other business logic before it's added to your content lake. You can find the shape of the payload your handler will receive below in the reference section, as well as the expected response.
Custom sync will send this payload on every update from Shopify as a POST request. You can write your custom business logic in it and update your content lake accordingly. Your handler also needs to return a response that contains information about the documents in the content lake that has been created, updated, or deleted by the custom handler.
Gotcha
The request has a 10s timeout and your handler needs to reply before that.
Gotcha
This operation will in batched when manually syncing, especially when dealing with larger catalogs.
Below is an example of a barebones custom function that will:
- Create/update/delete products (including drafts) in the Content Lake on Shopify product operations
- Only deal with products (variants are included as objects within products)
- Manual sync will create and update products on your dataset, but will not delete products that have since been removed.
import client from "@sanity/client";
// Document type for all incoming synced Shopify products
const SHOPIFY_PRODUCT_DOCUMENT_TYPE = "shopify.product";
// Prefix added to all Sanity product document ids
const SHOPIFY_PRODUCT_DOCUMENT_ID_PREFIX = "product-";
// Enter your Sanity studio details here.
// You will also need to provide an API token with write access in order for this
// handler to be able to create documents on your behalf.
// Read more on auth, tokens and securing them: https://www.sanity.io/docs/http-auth
const sanityClient = client({
apiVersion: "2021-10-21",
dataset: process.env.SANITY_DATASET,
projectId: process.env.SANITY_PROJECT_ID,
token: process.env.SANITY_ADMIN_AUTH_TOKEN,
useCdn: false,
});
/**
* Sanity Connect sends POST requests and expects both:
* - a 200 status code
* - a response header with `content-type: application/json`
*
* Remember that this may be run in batches when manually syncing.
*/
export default async function handler(req, res) {
// Next.js will automatically parse `req.body` with requests of `content-type: application/json`,
// so manually parsing with `JSON.parse` is unnecessary.
const { body, method } = req;
// Ignore non-POST requests
if (method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
try {
const transaction = sanityClient.transaction();
switch (body.action) {
case "create":
case "update":
case "sync":
await createOrUpdateProducts(transaction, body.products);
break;
case "delete":
const documentIds = body.productIds.map((id) =>
getDocumentProductId(id)
);
await deleteProducts(transaction, documentIds);
break;
}
await transaction.commit();
} catch (err) {
console.error("Transaction failed: ", err.message);
}
res.status(200).json({ message: "OK" });
}
/**
* Creates (or updates if already existing) Sanity documents of type `shopify.product`.
* Patches existing drafts too, if present.
*
* All products will be created with a deterministic _id in the format `product-${SHOPIFY_ID}`
*/
async function createOrUpdateProducts(transaction, products) {
// Extract draft document IDs from current update
const draftDocumentIds = products.map((product) => {
const productId = extractIdFromGid(product.id);
return `drafts.${getDocumentProductId(productId)}`;
});
// Determine if drafts exist for any updated products
const existingDrafts = await sanityClient.fetch(`*[_id in $ids]._id`, {
ids: draftDocumentIds,
});
products.forEach((product) => {
// Build Sanity product document
const document = buildProductDocument(product);
const draftId = `drafts.${document._id}`;
// Create (or update) existing published document
transaction
.createIfNotExists(document)
.patch(document._id, (patch) => patch.set(document));
// Check if this product has a corresponding draft and if so, update that too.
if (existingDrafts.includes(draftId)) {
transaction.patch(draftId, (patch) =>
patch.set({
...document,
_id: draftId,
})
);
}
});
}
/**
* Delete corresponding Sanity documents of type `shopify.product`.
* Published and draft documents will be deleted.
*/
async function deleteProducts(transaction, documentIds) {
documentIds.forEach((id) => {
transaction.delete(id).delete(`drafts.${id}`);
});
}
/**
* Build Sanity document from product payload
*/
function buildProductDocument(product) {
const {
featuredImage,
id,
options,
productType,
priceRange,
status,
title,
variants,
} = product;
const productId = extractIdFromGid(id);
return {
_id: getDocumentProductId(productId),
_type: SHOPIFY_PRODUCT_DOCUMENT_TYPE,
image: featuredImage?.src,
options: options?.map((option, index) => ({
_key: String(index),
name: option.name,
position: option.position,
values: option.values,
})),
priceRange,
productType,
status,
title,
variants: variants?.map((variant, index) => {
const variantId = extractIdFromGid(variant.id);
return {
_key: String(index),
compareAtPrice: Number(variant.compareAtPrice || 0),
id: variantId,
inStock: !!variant.inventoryManagement
? variant.inventoryPolicy === "continue" ||
variant.inventoryQuantity > 0
: true,
inventoryManagement: variant.inventoryManagement,
inventoryPolicy: variant.inventoryPolicy,
inventoryQuantity: variant.inventoryQuantity || 0,
option1: variant?.selectedOptions?.[0]?.value,
option2: variant?.selectedOptions?.[1]?.value,
option3: variant?.selectedOptions?.[2]?.value,
price: Number(variant.price || 0),
sku: variant.sku,
title: variant.title,
};
}),
};
}
/**
* Extract ID from Shopify GID string (all values after the last slash)
* e.g. gid://shopify/Product/12345 => 12345
*/
function extractIdFromGid(gid) {
return gid?.match(/[^\/]+$/i)[0];
}
/**
* Map Shopify product ID number to a corresponding Sanity document ID string
* e.g. 12345 => product-12345
*/
function getDocumentProductId(productId) {
return `${SHOPIFY_PRODUCT_DOCUMENT_ID_PREFIX}${productId}`;
}
Sync data automatically: Automatically sync whenever you save products. Note: The sync will update the Shopify information for both published and drafts documents. An update is typically available in your content lake after a couple of seconds.
Sync manually: There will no automatic sync, and you'll have to go into the Sanity Connect settings to trigger a synchronization manually.
Gotcha
Sanity Connect will do an initial synchronization once you choose one of these options.
You can install a production-ready reference studio that's set up with a great editor experience by running this command in your local shell. Replace PROJECT_ID
and DATASET_NAME
with those from the project your Shopify store is connected to:
npx @sanity/cli@shopify init --template shopify --project PROJECT_ID --dataset DATASET_NAME
You'll find comprehensive documentation for this studio in its README.md
.
If you already have set up a studio and want to surface the product and variant documents, then you can go to the reference studio's source code and copy the schema files from there.
Hydrogen is Shopify's new React-based framework that comes with a lot of building blocks that help you build custom storefronts with cart handling, checkout, and more. It's currently in developer preview so that you can start experimenting and get a feeling for how it works.
The best place to start exploring what you can do with Hydrogen and Sanity is by checking out the demo starter. Its README.md contains an introduction and documentation of how it works. Inside of this starter, you'll also find an implementation of useSanityQuery
that lets you combine product information from Shopify with content from Sanity to make sure that the frontend always reflects the latest state of your inventory and store.
useSanityQuery
relies on some assumptions about the shape of your data and id conventions to work. These are baked into how the Sanity Connect automatic sync works, but if you have opted for a custom sync option, make sure you familiarize yourself with these assumptions if you use them to leverage the hook in your Hydrogen frontend.
You will find all data from Shopify under the store
property. Typically, you want to set these fields as readOnly
or hidden
in your Sanity Studio schemas.
This is an example of a product document. Note the array of references to variant documents.
{
"_createdAt": "2021-11-03T16:29:25Z",
"_id": "shopifyProduct-6639500034135",
"_rev": "o2tnlhWyosT1SsguwHFbhC",
"_type": "product",
"_updatedAt": "2021-11-07T02:22:27Z",
"body": [],
"images": [
{
"_key": "1472e0cfc7dd",
"_type": "image",
"asset": {
"_ref": "image-84e890423efd39ed23c5ec4518a54ed4db8c9b7f-3840x2160-jpg",
"_type": "reference"
}
}
],
"sections": [],
"store": {
"createdAt": "2021-11-03T16:29:19Z",
"id": 6639500034135,
"isDeleted": false,
"options": [
{
"_key": "Material",
"_type": "option",
"name": "Material",
"values": [
"Glass",
"Fire",
"Cream"
]
}
],
"previewImageUrl": "https://cdn.shopify.com/s/files/1/0550/0456/1495/products/vase_01_00000.jpg?v=1635957059",
"priceRange": {
"maxVariantPrice": 10000,
"minVariantPrice": 10000
},
"productType": "",
"slug": {
"_type": "slug",
"current": "vase"
},
"status": "active",
"tags": "",
"title": "Example Vase A (v11)",
"updatedAt": "2021-11-05T15:41:59Z",
"variants": [
{
"_key": "7bc42489-a319-46ca-b667-e50a46bfcd25",
"_ref": "shopifyProductVariant-39466495705175",
"_type": "reference",
"_weak": true
},
{
"_key": "e41f4cc4-341c-4269-b2a6-81449e0a950b",
"_ref": "shopifyProductVariant-39466495737943",
"_type": "reference",
"_weak": true
},
{
"_key": "db63fd33-6bab-4ec6-b16d-479d554d5ab8",
"_ref": "shopifyProductVariant-39466495803479",
"_type": "reference",
"_weak": true
}
]
}
}
This is an example of a variant document.
{
"_createdAt": "2021-11-03T16:30:42Z",
"_id": "shopifyProductVariant-39466495705175",
"_rev": "6S6DS0jiziiPJrIgugMBKV",
"_type": "productVariant",
"_updatedAt": "2021-11-05T20:42:33Z",
"store": {
"compareAtPrice": 0,
"createdAt": "2021-11-03T16:30:34Z",
"id": 39466495705175,
"isDeleted": false,
"option1": "Glass",
"option2": "",
"option3": "",
"previewImageUrl": "https://cdn.shopify.com/s/files/1/0550/0456/1495/products/vase_01_00001_a672060a-9184-44fe-9a4e-d81dd630387f.jpg?v=1635957098",
"price": 100,
"productId": 6639500034135,
"sku": "",
"status": "active",
"title": "Glass",
"updatedAt": "2021-11-03T16:38:15Z"
}
}
If you use the custom webhook sync, your handler will receive the shape described Product
below. You can still use JavaScript or any other programming language in your custom handler even though we describe the payload using TypeScript syntax.
export type Product = {
id: `gid://shopify/ProductVariant/${string}`
title: string
description: string
descriptionHtml: string
featuredImage?: ProductImage
handle: string
images: ProductImage[]
options: ProductOption[]
priceRange: ProductPriceRange
productType: string
tags: string[]
variants: ProductVariant[]
vendor: string
status: 'active' | 'archived' | 'draft' | 'unknown'
publishedAt: string
createdAt: string
updatedAt: string
}
export type ProductImage = {
id: `gid://shopify/ProductImage/${string}`
altText?: string
height?: number
width?: number
src: string
}
export type ProductOption = {
id: `gid://shopify/ProductOption/${string}`
name: string
position: number
values: string[]
}
export type ProductPriceRange = {
minVariantPrice?: number
maxVariantPrice?: number
}
export type ProductVariant = {
id: `gid://shopify/ProductVariant/${string}`
title: string
compareAtPrice?: number
barcode?: string
inventoryPolicy: string
inventoryQuantity: number
inventoryManagement: string
position: number
requiresShipping: boolean
sku: string
taxable: boolean
weight: number
weightUnit: string
price: string
createdAt: string
updatedAt: string
image?: ProductImage
product: {
id: `gid://shopify/Product/${string}`
status: 'ACTIVE' | 'ARCHIVED' | 'DRAFT'
options: {
id: 'gid://shopify/ProductOption/{string}'
name: string
values: string[]
}[]
}
}
// When products are created or updated
export type payloadCreateUpdate = {
action: 'create' | 'update'
products: Product[]
}
// When products are manually synced
export type payloadSync = {
action: 'sync'
products: Product[]
}
// When products are deleted
export type payloadDelete = {
action: 'delete'
productIds: number[]
}
export type requestPayload = payloadCreateUpdate | payloadDelete | payloadSync