Structured Content 2022: Join our conference to explore fresh perspectives on content and digital experiences →

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.

Installation

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.

Settings

You can configure how and when Sanity Connect should synchronize products to your content lake. You can change these options at any time.

The Sanity Connect configuration screen

Direct sync

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.

Custom sync handler

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.

Example custom function

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}`;
}

When to synchronize

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.

Reference Studio for Shopify

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.

The Shopify reference studio

Integrating with an existing studio

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.

Using Sanity Connect with Hydrogen

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.

Diagram showing how useSanityQuery works

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.

Reference

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.

Product document

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
      }
    ]
  }
}

Variant document

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"
  }
}

Custom webhook sync payload

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

Was this article helpful?