
Using Sanity With Shopify for E-commerce
To use Sanity with Shopify, install the Sanity Connect app in your Shopify admin and link it to a Sanity project and dataset. It syncs your products, variants and, optionally, collections into Sanity as documents, with Shopify's data stored in a read-only store object on each one. Editors then add the content Shopify can't hold well, such as rich descriptions, lookbooks, guides and landing pages, in Sanity Studio. Your storefront, built with Shopify Hydrogen or Next.js, fetches prices, inventory and cart data from Shopify's Storefront API, fetches content from Sanity with GROQ, and joins the two by product handle or ID.
You keep Shopify's checkout, payments and inventory, and gain a content model shaped around your brand. The hard part is deciding which system owns which data. This guide covers that split, installing Sanity Connect, the documents and fields it creates, setting up the Studio, enriching products with editorial content, building Hydrogen and Next.js storefronts that query both systems, syncing content back to Shopify, and keeping everything fresh.
Where Content and Commerce Data Should Live
The most common mistake in a Sanity and Shopify build is duplicating data, or reading data from the system that doesn't own it. Use this split:
| Data | Owner | Read it from |
|---|---|---|
| Price, compare-at price | Shopify | Storefront API |
| Inventory and availability | Shopify | Storefront API |
| Variants, options, SKUs | Shopify | Storefront API (Sanity has a synced copy) |
| Cart, checkout, customers, orders | Shopify | Storefront and Customer Account APIs |
| Product title and handle | Shopify | Either, the synced copy is fine for listings |
| Rich descriptions, size guides, care instructions | Sanity | GROQ |
| Lifestyle images, videos, lookbooks | Sanity | GROQ |
| Landing pages, editorial articles, navigation | Sanity | GROQ |
| SEO overrides, promotional banners | Sanity | GROQ |
The rule is simple: anything that affects what a customer pays or whether they can buy comes from Shopify at request time. Sanity's synced store fields are a convenient copy for references, previews and listing cards, but they're not the source of truth for checkout. Everything editorial lives in Sanity, where it can reference products by their synced documents.
Install Sanity Connect
Sanity Connect is Sanity's official Shopify app:
- Install Sanity Connect from the Shopify App Store.
- Log in to Sanity from the app and choose a project and dataset.
- Choose a sync mode, then let the initial sync run.
Test against a non-production dataset first, so you can check the documents before they land in the dataset your site uses. Each product, variant and collection becomes one document and counts towards your plan's document limit, which matters for large catalogues. The datasets and environments guide explains how to set up a staging dataset.
Sync Modes
| Mode | What happens | Use it when |
|---|---|---|
| Automatic | Every product save in Shopify updates Sanity within seconds | Most stores |
| Manual | Nothing syncs until you trigger it in the app settings | Tight control over changes |
| Custom sync handler | Shopify updates are sent to your own endpoint | You want a different document shape |
Collection sync is a separate option. It copies a collection's own properties, such as its title, handle, image and rules, but not which products belong to it. A daily reconciliation job also catches changes or deletions that a webhook missed, and the app's Logs tab shows recent runs.
What Sanity Connect Creates
Synced documents use predictable IDs and types:
| Shopify resource | Sanity _type | _id format |
|---|---|---|
| Product | product | shopifyProduct-<id> |
| Variant | productVariant | shopifyProductVariant-<id> |
| Collection | collection | shopifyCollection-<id> |
All Shopify data lives under store. A product document looks roughly like this, trimmed:
{
"_id": "shopifyProduct-7696133062907",
"_type": "product",
"store": {
"id": 7696133062907,
"gid": "gid://shopify/Product/7696133062907",
"title": "Linen Overshirt",
"slug": { "_type": "slug", "current": "linen-overshirt" },
"status": "active",
"isDeleted": false,
"previewImageUrl": "https://cdn.shopify.com/...",
"productType": "Shirts",
"vendor": "Northwind",
"priceRange": { "minVariantPrice": 89, "maxVariantPrice": 95 },
"options": [{ "_key": "Size", "_type": "option", "name": "Size", "values": ["S", "M", "L"] }],
"variants": [{ "_key": "...", "_type": "reference", "_ref": "shopifyProductVariant-4321", "_weak": true }]
}
}
Useful details:
store.slug.currentis the Shopify handle, which is what your storefront routes use.store.gidis the Shopify global ID, which you can pass straight to the Storefront API.store.variantsholds weak references to the variant documents, which carryprice,compareAtPrice,sku,option1tooption3andinventory.isAvailableunder their ownstore.- Status: active products are published in Sanity, draft products are created as unpublished, and archiving a product unpublishes its document. Deleted products are flagged with
store.isDeleted. - Metafields: if you enable import, product and collection metafields appear in a read-only
store.metafieldsarray, overwritten on every sync.
Never edit store fields in Sanity. Shopify is the source of truth, and the next sync overwrites them.
Set Up the Studio
The quickest start is Sanity's Shopify Studio template, which includes schemas for the synced types, read-only store fields, product previews and editorial modules:
npm create sanity@latest -- --template shopify
To add Shopify types to an existing Studio, define the documents yourself. Keep store read-only and put your own fields alongside it:
// schemaTypes/product.ts
import { defineField, defineType } from 'sanity'
export const productType = defineType({
name: 'product',
title: 'Product',
type: 'document',
fields: [
defineField({
name: 'store',
type: 'object',
readOnly: true,
fields: [
defineField({ name: 'title', type: 'string' }),
defineField({ name: 'slug', type: 'slug' }),
defineField({ name: 'gid', type: 'string' }),
defineField({ name: 'status', type: 'string' }),
defineField({ name: 'isDeleted', type: 'boolean' }),
defineField({ name: 'previewImageUrl', type: 'url' }),
],
}),
defineField({ name: 'body', title: 'Description', type: 'array', of: [{ type: 'block' }] }),
defineField({ name: 'gallery', type: 'array', of: [{ type: 'image', options: { hotspot: true } }] }),
defineField({ name: 'sizeGuide', type: 'reference', to: [{ type: 'sizeGuide' }] }),
defineField({ name: 'seo', type: 'seo' }),
],
preview: {
select: { title: 'store.title', imageUrl: 'store.previewImageUrl', status: 'store.status' },
prepare: ({ title, status }) => ({ title, subtitle: status }),
},
})
You only need to declare the store fields you want editors to see; undeclared fields stay in the document regardless. Because Shopify creates and deletes these documents, stop editors from doing either in the Studio:
// sanity.config.ts
const SHOPIFY_TYPES = ['product', 'productVariant', 'collection']
export default defineConfig({
// ...
document: {
newDocumentOptions: (prev) =>
prev.filter((item) => !SHOPIFY_TYPES.includes(item.templateId)),
actions: (prev, { schemaType }) =>
SHOPIFY_TYPES.includes(schemaType)
? prev.filter(({ action }) => !['duplicate', 'delete', 'unpublish'].includes(action ?? ''))
: prev,
},
})
The document actions guide explains this API in more detail. With the Structure Builder, you can also group products, variants and collections under a single "Shop" item, separate from editorial content.
Enrich Products With Editorial Content
Once products exist in Sanity, any document can reference them. This is where the integration pays off: a guide, lookbook or landing page can link to real products, and the reference stays valid when a price changes.
// schemaTypes/lookbook.ts
defineField({
name: 'looks',
type: 'array',
of: [
defineArrayMember({
type: 'object',
name: 'look',
fields: [
defineField({ name: 'image', type: 'image', options: { hotspot: true } }),
defineField({
name: 'products',
type: 'array',
of: [{ type: 'reference', to: [{ type: 'product' }], options: { filter: '!store.isDeleted && store.status == "active"' } }],
}),
],
}),
],
})
The filter option keeps archived and deleted products out of the reference picker. In GROQ, resolve the references and take only what the page needs:
*[_type == "lookbook" && slug.current == $slug][0]{
title,
looks[]{
_key,
image,
"products": products[@->store.isDeleted != true]->{
"handle": store.slug.current,
"gid": store.gid,
"title": store.title,
"image": store.previewImageUrl
}
}
}
The same pattern works for inline product cards inside Portable Text, using a custom block type that references a product. See Portable Text custom blocks for rendering those, and Sanity references for modelling relationships.
Build a Hydrogen Storefront
Hydrogen is Shopify's React Router-based framework, and hydrogen-sanity is Sanity's toolkit for it. It adds a Sanity client to Hydrogen's request context, caches queries with Hydrogen's cache and supports Visual Editing:
npm install hydrogen-sanity @sanity/client
Check its @shopify/hydrogen peer range before upgrading Hydrogen, because hydrogen-sanity declares support for specific Hydrogen releases.
Register the Vite plugin, then create the Sanity context in app/lib/context.ts and pass it to createHydrogenContext:
// app/lib/context.ts (excerpt)
import { createSanityContext, type SanityContext } from 'hydrogen-sanity'
const sanity = await createSanityContext({
request,
cache,
waitUntil,
client: {
projectId: env.SANITY_PROJECT_ID,
dataset: env.SANITY_DATASET || 'production',
apiVersion: '2025-02-19',
useCdn: process.env.NODE_ENV === 'production',
},
})
A product route then queries both systems in parallel. Shopify supplies the commercial data, Sanity the editorial content, both keyed by the handle:
// app/routes/products.$handle.tsx
import { defineQuery } from 'groq'
import type { Route } from './+types/products.$handle'
const SHOPIFY_PRODUCT_QUERY = `#graphql
query Product($handle: String!) {
product(handle: $handle) {
id
title
description
selectedOrFirstAvailableVariant {
id
availableForSale
price { amount currencyCode }
}
}
}
`
const SANITY_PRODUCT_QUERY = defineQuery(`
*[_type == "product" && store.slug.current == $handle][0]{
body,
gallery,
sizeGuide->{ title, rows }
}
`)
export async function loader({ params, context }: Route.LoaderArgs) {
const handle = params.handle!
const [{ product }, content] = await Promise.all([
context.storefront.query(SHOPIFY_PRODUCT_QUERY, { variables: { handle } }),
context.sanity.fetch(SANITY_PRODUCT_QUERY, { handle }),
])
if (!product) throw new Response('Not found', { status: 404 })
return { product, content }
}
The component renders the price and add-to-cart button from product, and the description, gallery and size guide from content. If the Sanity query returns null, for example for a brand-new product nobody has enriched yet, fall back to Shopify's own description. hydrogen-sanity also offers query with a <Query> component for Visual Editing, and useImageUrl for Sanity images. Since Hydrogen is built on React Router, the loader patterns in Sanity with Remix and React Router apply here too.
Build a Next.js Storefront
In Next.js, call the Storefront API with fetch and Sanity with next-sanity. A small helper keeps the Shopify side tidy:
// src/lib/shopify.ts
const endpoint = `https://${process.env.SHOPIFY_STORE_DOMAIN}/api/2026-10/graphql.json`
export async function shopifyFetch<T>(query: string, variables: Record<string, unknown>, tags: string[] = []) {
const res = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Storefront-Access-Token': process.env.SHOPIFY_STOREFRONT_TOKEN!,
},
body: JSON.stringify({ query, variables }),
next: { tags, revalidate: 60 },
})
const json = await res.json()
if (json.errors) throw new Error(json.errors[0].message)
return json.data as T
}
// src/app/products/[handle]/page.tsx
import { notFound } from 'next/navigation'
import { PortableText } from 'next-sanity'
import { sanityFetch } from '@/sanity/lib/live'
import { shopifyFetch } from '@/lib/shopify'
import { SANITY_PRODUCT_QUERY, SHOPIFY_PRODUCT_QUERY } from './queries'
export default async function ProductPage({ params }: { params: Promise<{ handle: string }> }) {
const { handle } = await params
const [{ product }, { data: content }] = await Promise.all([
shopifyFetch<{ product: ShopifyProduct | null }>(SHOPIFY_PRODUCT_QUERY, { handle }, [`product:${handle}`]),
sanityFetch({ query: SANITY_PRODUCT_QUERY, params: { handle } }),
])
if (!product) notFound()
return (
<main>
<h1>{product.title}</h1>
<Price variant={product.selectedOrFirstAvailableVariant} />
<AddToCart variantId={product.selectedOrFirstAvailableVariant?.id} />
{content?.body ? <PortableText value={content.body} /> : <p>{product.description}</p>}
</main>
)
}
Keep the Storefront API revalidation short, or use Shopify webhooks to revalidate the product tag, because prices and stock change outside Sanity. The Sanity side refreshes through defineLive, covered in the next-sanity guide. Cart actions should always call Shopify directly, never use cached data.
For product listing pages, a single GROQ query against synced products is often enough, using store.title, store.previewImageUrl and store.priceRange for the cards. Fetch live prices only on the product page and in the cart.
Sync Content Back to Shopify
Sanity Connect can also sync in the other direction. When enabled, custom fields on products, variants and collections are written to Shopify as metafields, and custom document types become metaobjects. That's useful if part of your store still runs on a Liquid theme. Configure it in the app's Metafields tab.
Keep Everything Fresh
Because Sanity Connect writes product changes into your dataset, Shopify edits trigger the same Sanity events as editorial changes. A single revalidation path handles both: <SanityLive /> in Next.js, or a webhook that purges caches or rebuilds a static site, as described in how to use Sanity webhooks. Prices and stock read from the Storefront API need their own short cache or Shopify webhook, as above.
FAQ: Sanity and Shopify
No. Shopify still runs products, inventory, pricing, checkout, payments and orders. Sanity adds a flexible content layer for everything around the products, such as rich descriptions, editorial pages and campaigns, and your storefront combines the two.
No. Synced Shopify data lives in read-only store fields, and every sync overwrites them. Prices, inventory and variants are managed in the Shopify admin and flow into Sanity automatically.
Only where a small delay is acceptable, such as listing cards. On product pages, in the cart and at checkout, read prices and availability from the Storefront API so customers always see current values.
No. Hydrogen with hydrogen-sanity is a good fit if you want Shopify's hosting on Oxygen and its built-in commerce components, but Next.js, Astro, Nuxt or any framework that can call two APIs works just as well.
Archiving unpublishes the Sanity document, and deletion flags it with store.isDeleted. Filter on both in GROQ and in reference pickers, so editorial content never links to products customers can't buy.
Yes. Each product, variant and collection becomes a document. For large catalogues with many variants, check your plan's limits before the initial sync, or use a custom sync handler to store variants as objects on the product instead.
Conclusion
Sanity and Shopify work best when each does what it's good at. Sanity Connect keeps products, variants and collections in sync as read-only documents, editors enrich them and build pages around them in the Studio, and your storefront fetches commerce data from the Storefront API and content from GROQ, joined by handle or GID.
Decide the data split up front: anything that affects what a customer pays comes from Shopify at request time, and everything editorial comes from Sanity. Start with the Shopify Studio template, lock down the synced types, filter out archived and deleted products, and pick Hydrogen or Next.js based on where you want to host.


