Sanity to Shopify Product Sync: Use Sanity as Your PIM
· 19 min read
To make Sanity your product source of truth, send every published product to Shopify with the Admin API productSet mutation, keyed by a unique custom-ID metafield (we use skai.sanity-id), then write Shopify’s product and variant IDs back to Sanity. Wrap that in a filtered webhook, a queue, retries and a channel-publish step, and editors run the whole catalog from Sanity Studio while Shopify handles checkout, inventory and orders.
This is the pattern we run in production for headless Shopify stores, from single-brand shops to groups that run several storefronts from one Sanity dataset. Below: the architecture, the TypeScript, and the details that make it boringly reliable.
- Editor publishes in StudioA custom publish action waits for the new published revision, then reports sync status.
- Filtered webhook firesA GROQ type filter excludes sync-owned mapping documents.
- Queue a job per storeReturn 202 fast and dedupe retries on Sanity's idempotency-key header.
- productSet upsertShopify matches the product by its skai.sanity-id custom ID: update or create.
- Write Shopify IDs backProduct and variant GIDs are saved in a sync-owned Sanity mapping document.
- Publish to sales channelsActive products go live on every channel with publishablePublish.
Sanity first or Shopify first?
Sanity’s official app, Sanity Connect, is built for the Shopify-first direction: products live in Shopify admin and sync into Sanity, where editors enrich them. Its outbound option sends Sanity content to Shopify as metafields and metaobjects, not as native product fields (checked Oct 22, 2026: https://www.sanity.io/docs/developer-guides/sanity-connect-for-shopify).
Sanity first flips that. Title, handle, description, options, variants, prices, images and status all originate in Sanity, and Shopify mirrors them. Shopify built productSet for exactly this job: syncing complete product state from an external source of truth (checked Oct 22, 2026: https://shopify.dev/docs/apps/build/product-merchandising/products-and-collections/sync-data).
Shopify first + Sanity Connect
- Products created in Shopify admin
- Sanity enriches with editorial content
- Shopify fields read-only in Sanity
Sanity first + productSet
- Products created in Sanity Studio
- Shopify mirrors native product fields
- One editor workflow for the whole catalog
Where do your editors create and price products?
- In Shopify adminShopify first
- In Sanity StudioSanity first + productSet
Choose Sanity first when:
- Product data is richer than Shopify’s model: specs, materials, designers, rules that drive price or availability.
- One product record feeds several Shopify stores, markets or channels. (See how to run multiple Shopify stores.)
- Editors already live in Sanity Studio and you want one place to create, review and publish a product.
- Prices or attributes are computed, for example from a formula, a supplier feed or a market rate.
Shopify stays the system of record for orders, customers, inventory levels and checkout. Sanity owns the product definition. For where this fits in a wider stack, see how we use Sanity on Shopify projects and our Shopify CMS guide.
The data contract: one ID that ties both systems together
Every Sanity product gets a Shopify metafield holding its Sanity document _id. We use namespace skai, key sanity-id, type id. Shopify enforces unique values for id metafields, which makes it a true custom ID that productSet can upsert against (checked Oct 22, 2026: https://shopify.dev/docs/apps/build/metafields/working-with-custom-ids).
Create the definition once per store:
const CREATE_SANITY_ID_DEFINITION = /* GraphQL */ `
mutation CreateSanityIdDefinition {
metafieldDefinitionCreate(
definition: {
name: "Sanity ID"
namespace: "skai"
key: "sanity-id"
type: "id"
ownerType: PRODUCT
}
) {
createdDefinition {
id
}
userErrors {
field
message
}
}
}
`;
Sanity stores the reverse mapping in a separate, sync-owned document. Product and variant content has no Shopify GID fields:
type ShopifyMapping = {
_id: string; // shopifyMap.<storeKey>.<sanityProductId>
_type: 'shopifyMapping';
storeKey: string;
sanityProductId: string;
shopifyProductGid?: string;
variants: {
_key: string;
sanityVariantId: string;
shopifyVariantGid: string;
shopifyProductGid: string; // owner, not just an ID to forward
}[];
};
const mappingId = (storeKey: string, productId: string) => `shopifyMap.${storeKey}.${productId}`;
Why IDs live in a separate mapping document: publishing an old draft replaces editor content, including any IDs patched into its published counterpart since the draft began. A mapping document is never drafted or published by editors; only the worker can write it (enforce that with Sanity permissions, not just readOnly UI fields). Stale draft publication therefore cannot erase confirmed IDs.
The snippets use a trusted, configured storeKey even for one store. Validate keys and published document IDs for the mapping ID format. Types, authenticated API adapters and queue helpers are application-specific illustrations, not a complete sync service.
Step 1: Query exactly what Shopify needs
One GROQ query defines the sync contract. If a field is not in this query, it never reaches Shopify.
// sanity/get-product-to-sync.ts
import { createClient } from '@sanity/client';
export const sanity = createClient({
projectId: process.env.SANITY_PROJECT_ID!,
dataset: process.env.SANITY_DATASET!,
apiVersion: '2025-10-01',
token: process.env.SANITY_WRITE_TOKEN, // server-side only
useCdn: false, // always read fresh data for sync
perspective: 'published'
});
export const PRODUCT_TO_SYNC = /* groq */ `
*[_type == "product" && _id == $id][0]{
_id,
title,
"handle": slug.current,
status,
descriptionHtml,
"images": gallery[].asset->{ "url": url, altText },
options[]{ name, values },
"variants": *[_type == "productVariant" && product._ref == ^._id]{
_id, sku, barcode, price, compareAtPrice, trackStock, allowBackorder,
optionValues[]{ optionName, name }
}
}
`;
export async function getProductToSync(id: string) {
return sanity.fetch<SanityProduct | null>(PRODUCT_TO_SYNC, { id });
}
export async function getMapping(storeKey: string, id: string) {
return sanity.getDocument<ShopifyMapping>(mappingId(storeKey, id));
}
Each separate productVariant document has a product reference to its parent product (product._ref is the published product ID). This explicit relationship also lets the webhook find both parents when a variant moves.
Step 2: Upsert with productSet and a custom ID
Since Admin API 2025-04, productSet accepts an identifier. Pass the custom ID and Shopify updates the matching product or creates it if none exists. The upsert needs no create-versus-update branch, but we still look up the target product to recover missing variant mappings safely (checked Oct 22, 2026: https://shopify.dev/changelog/productset-and-customerset-mutations-now-support-upserts).
// shopify/product-set.ts
const PRODUCT_SET = /* GraphQL */ `
mutation ProductSet($input: ProductSetInput!, $identifier: ProductSetIdentifiers) {
productSet(input: $input, identifier: $identifier, synchronous: true) {
product {
id
status
variants(first: 250) {
nodes {
id
sku
}
}
}
userErrors {
field
message
code
}
}
}
`;
export function toProductSetInput(p: SanityProduct, mapping: ShopifyMapping): ProductSetInput {
return {
title: p.title,
handle: p.handle,
status: p.status, // 'ACTIVE' | 'DRAFT' | 'ARCHIVED'
descriptionHtml: p.descriptionHtml,
metafields: [{ namespace: 'skai', key: 'sanity-id', type: 'id', value: p._id }],
files: p.images.map((img) => ({
originalSource: img.url,
alt: img.altText ?? undefined,
contentType: 'IMAGE'
})),
productOptions: p.options.map((o) => ({
name: o.name,
values: [...new Set(o.values)].map((name) => ({ name }))
})),
variants: p.variants.map((v) => {
const saved = mapping.variants.find((entry) => entry.sanityVariantId === v._id);
const gid =
saved?.shopifyProductGid === mapping.shopifyProductGid
? saved?.shopifyVariantGid
: undefined;
return {
...(gid ? { id: gid } : {}),
inventoryItem: { sku: v.sku, tracked: v.trackStock },
barcode: v.barcode,
price: v.price,
compareAtPrice: v.compareAtPrice ?? null,
inventoryPolicy: v.allowBackorder ? 'CONTINUE' : 'DENY',
optionValues: v.optionValues
};
})
};
}
export async function upsertProduct(p: SanityProduct, mapping: ShopifyMapping) {
const { productSet } = await shopifyAdmin(PRODUCT_SET, {
input: toProductSetInput(p, mapping),
identifier: {
customId: { namespace: 'skai', key: 'sanity-id', value: p._id }
}
});
if (productSet.userErrors.length > 0) {
// Validation errors are permanent. Do not retry them.
throw new PermanentSyncError(productSet.userErrors);
}
return productSet.product;
}
Treat list fields as full state. productSet replaces list fields such as variants, options and metafields with exactly what you send, and leaves omitted scalar fields alone (checked Oct 22, 2026: https://shopify.dev/docs/api/admin-graphql/latest/mutations/productSet). Send the complete set your sync owns, preserving existing entries that must survive. Do not assume that omitting other apps’ metafields preserves them; check the API’s deletion scope and use metafieldsSet for independent updates that must not replace a metafield list.
Make media deterministic. Persist and reuse Shopify file IDs for unchanged assets. Deterministic filenames alone do not guarantee replacement; configure the selected upload API’s duplicate-resolution behavior explicitly. Validate images against Shopify’s media limits before calling Shopify, and fail the whole sync if one is too large. The payload above illustrates new image uploads; add stored file IDs before using it for repeated media syncs.
Step 3: Write Shopify’s IDs back to Sanity
Storefront queries, carts and analytics can read Shopify GIDs from the mapping. Match variants by SKU and replace only this store/product’s mapping. This example assumes non-empty SKUs unique within each product and at most 250 variants; paginate Shopify responses for larger products and reject missing or ambiguous matches.
// sanity/write-back-ids.ts
export async function writeBackShopifyIds(
storeKey: string,
p: SanityProduct,
shopifyProduct: ShopifyProduct
) {
const gidBySku = new Map(shopifyProduct.variants.nodes.map((v) => [v.sku, v.id]));
const variants = p.variants.map((variant) => {
const gid = gidBySku.get(variant.sku);
if (!gid) throw new Error(`Missing Shopify variant for ${variant.sku}`);
return {
_key: variant._id,
sanityVariantId: variant._id,
shopifyVariantGid: gid,
shopifyProductGid: shopifyProduct.id
};
});
await sanity.createOrReplace(
{
_id: mappingId(storeKey, p._id),
_type: 'shopifyMapping',
storeKey,
sanityProductId: p._id,
shopifyProductGid: shopifyProduct.id,
variants
},
{ visibility: 'sync' }
);
}
Wait for synchronous visibility before releasing the worker lock. No product or variant document is patched. On a move from A to B, send an id only when its saved owner equals B’s Shopify product GID; never forward A’s variant GID. First sync B (creating or recovering its own variant), save B’s destination mapping, remove the moved entry from A’s mapping, then sync A with its complete remaining variants to remove the source variant. Persist this ordered move as one durable job, with both product locks held in a stable order; ordinary webhooks and Studio jobs must use the same locks. Resume an interrupted move at its saved stage, and do not remove from A if B fails. Re-read current parentage under those locks; a newer move needs a new plan. This applies independently per store.
Step 4: Publish to sales channels
status: ACTIVE does not put a product on your sales channels. Publish it explicitly with publishablePublish (requires the write_publications scope).
// shopify/publish.ts
const PUBLICATIONS = /* GraphQL */ `
query Publications($after: String) {
publications(first: 25, after: $after) {
nodes {
id
name
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;
const PUBLISH = /* GraphQL */ `
mutation Publish($id: ID!, $input: [PublicationInput!]!) {
publishablePublish(id: $id, input: $input) {
userErrors {
field
message
}
}
}
`;
export async function publishToChannels(productGid: string) {
const input: { publicationId: string }[] = [];
let after: string | null = null;
for (;;) {
const { publications } = await shopifyAdmin(PUBLICATIONS, { after });
input.push(...publications.nodes.map((p) => ({ publicationId: p.id })));
if (!publications.pageInfo.hasNextPage) break;
if (!publications.pageInfo.endCursor) throw new Error('Missing publication cursor');
after = publications.pageInfo.endCursor;
}
if (input.length === 0) return;
for (let offset = 0; offset < input.length; offset += 250) {
const { publishablePublish } = await shopifyAdmin(PUBLISH, {
id: productGid,
input: input.slice(offset, offset + 250)
});
if (publishablePublish.userErrors.length > 0) {
throw new PermanentSyncError(publishablePublish.userErrors);
}
}
}
Filter publications by name if some channels should not receive every product. Cache the list for the life of the worker.
Step 5: Retry the right failures
Shopify throttles, networks blink, deploys restart. Retry transient failures with exponential backoff and jitter, and fail fast on validation errors.
// sync/with-retry.ts
class ReconciliationRequired extends Error {}
export async function withRetry<T>(fn: () => Promise<T>, maxAttempts = 5): Promise<T> {
for (let attempt = 1; ; attempt++) {
try {
return await fn();
} catch (error) {
const retryable =
!(error instanceof PermanentSyncError) && !(error instanceof ReconciliationRequired);
if (!retryable || attempt >= maxAttempts) throw error;
const delay = Math.min(30_000, 500 * 2 ** attempt) * (0.5 + Math.random() / 2);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
// sync/sync-product.ts
export async function syncProduct(storeKey: string, id: string) {
const jobKey = `${storeKey}:${id}`;
// Durable per-product journal, checked inside the serialized worker lock.
if (await reconciliationJournal.isPending(jobKey)) {
throw new ReconciliationRequired('Resolve the previous uncertain write first');
}
const product = await getProductToSync(id);
if (!product) return { skipped: 'not-published' };
const mapping = await reconcileTargetMapping(storeKey, product, await getMapping(storeKey, id));
const hasNewVariants = product.variants.some(
(v) =>
!mapping.variants.some(
(entry) =>
entry.sanityVariantId === v._id && entry.shopifyProductGid === mapping.shopifyProductGid
)
);
// Persist BEFORE sending: a process crash can also lose the first response.
if (hasNewVariants) await reconciliationJournal.markPending(jobKey);
const shopifyProduct = await withRetry(async () => {
try {
return await upsertProduct(product, mapping);
} catch (error) {
if (hasNewVariants) {
throw new ReconciliationRequired('Check Shopify and recover variant IDs before resending', {
cause: error
});
}
throw error;
}
});
await withRetry(() => writeBackShopifyIds(storeKey, product, shopifyProduct));
if (hasNewVariants) await reconciliationJournal.clear(jobKey);
if (product.status === 'ACTIVE') {
await withRetry(() => publishToChannels(shopifyProduct.id));
}
return { productGid: shopifyProduct.id };
}
The custom ID prevents duplicate products, not replacement variant IDs. If a request containing new variants times out after Shopify accepted it, blindly replaying the snapshot can replace those variants. This example conservatively stops all failed writes containing missing variant IDs, even if the failure might be retryable. The durable reconciliationJournal helpers record a pending marker before the request and clear it only after ID write-back succeeds; implement them in the queue’s durable store, under the same per-product lock. A crash, failed write-back or duplicate webhook must encounter that marker and stop rather than resend. Quarantine ReconciliationRequired jobs instead of automatically retrying them.
reconcileTargetMapping is a required adapter, not a built-in API: before treating any missing or wrong-owner variant ID as new, locate the target Shopify product by its product custom ID using your pinned API version, read all its variants, and recover missing mappings by non-empty unique SKU (or a separately configured variant skai.sanity-id metafield). Scope every lookup to that product, never the whole store. Reuse a match, persist the recovered mapping with synchronous visibility, and omit id only after a conclusive no-match. An inconclusive read, duplicate SKU or ownership conflict pauses the job. This also repairs mappings lost before adopting the separate document.
To resume an uncertain write, run that same reconciliation under the lock, ensure Shopify has finished the earlier request, and only then clear the pending marker and enqueue a fresh snapshot. If Shopify conclusively never accepted it, creation can resume. Otherwise keep it quarantined. The small durable intent marker is still needed for a crash during an in-flight request; it is not a substitute for authoritative mappings and is never stored on editor content. Scope the journal and locks to (storeKey, productId).
Step 6: Stop the webhook feedback loop
Writing IDs onto product content creates a feedback loop. Separate mapping documents remove that cause: the webhook accepts only editor-owned product and variant types, never shopifyMapping.
Product webhook event
Sync-owned mapping document?A GROQ type filter keeps worker writes outside the editor webhook.
yesDrop at the webhook
No editor content changed?A server-side diff ignores Sanity system fields only.
yesAcknowledge, skip
No to bothQueue the sync
Layer 1: the GROQ webhook filter. Include only product content, excluding drafts and release versions:
_type in ["product", "productVariant"] &&
!(_id in path("drafts.**")) && !(_id in path("versions.**"))
Mapping writes cannot match this filter, so no GID exclusion list or delta::changedOnly projection is needed. Project before and after snapshots so the worker can identify deletions and moves:
{
"before": before(),
"after": after(),
"beforeParent": before().product._ref,
"afterParent": after().product._ref
}
Layer 2: the server-side diff. Verify the signature and type, strip Sanity system fields, then compare editor content. There are no sync-written fields on these documents.
// app/api/sanity/product-webhook/route.ts
import { isValidSignature, SIGNATURE_HEADER_NAME } from '@sanity/webhook';
import { isDeepStrictEqual } from 'node:util';
function strip(doc: Record<string, unknown> | null) {
if (!doc) return null;
return Object.fromEntries(Object.entries(doc).filter(([key]) => !key.startsWith('_')));
}
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get(SIGNATURE_HEADER_NAME) ?? '';
if (!(await isValidSignature(body, signature, process.env.SANITY_WEBHOOK_SECRET!))) {
return new Response('Invalid signature', { status: 401 });
}
const { before, after, beforeParent, afterParent } = JSON.parse(body);
const doc = after ?? before;
if (
!doc ||
!['product', 'productVariant'].includes(doc._type) ||
/^(drafts|versions)\./.test(doc._id)
) {
return Response.json({ skipped: 'not published product content' });
}
if (before && after && isDeepStrictEqual(strip(before), strip(after))) {
return Response.json({ skipped: 'system fields only' });
}
const eventKey = request.headers.get('idempotency-key');
if (
doc._type === 'productVariant' &&
beforeParent &&
afterParent &&
beforeParent !== afterParent
) {
// A single durable move job, not two independently ordered messages.
await queue.send(
{ move: { variantId: doc._id, from: beforeParent, to: afterParent } },
{ idempotencyKey: eventKey ?? undefined }
);
return new Response(null, { status: 202 });
}
const productIds =
doc._type === 'product' ? [doc._id] : [beforeParent, afterParent].filter(Boolean);
for (const productId of new Set(productIds)) {
await queue.send(
{ productId },
{ idempotencyKey: eventKey ? `${eventKey}:${productId}` : undefined }
);
}
return new Response(null, { status: 202 });
}
Variant creation and deletion refresh their parent; a reparent event queues one ordered move job that syncs B before A, following Step 3. The queue adapter fans each message out to configured stores, including the store in dedupe keys. It must register move dependencies before either product can run independently; reconcile current parentage against mappings on ordinary jobs too, so a later product event cannot race ahead of a delayed move event. Deleting or unpublishing a parent needs an explicit archive/delete policy: the illustrated worker skips absent products rather than deleting them from Shopify.
Queue, don’t sync inline. Return 202 as soon as the job is queued, dedupe Sanity’s retries on the idempotency-key header, and let a worker run syncProduct. Running several Shopify stores? Give each store its own queue topic so one slow or rate-limited store never blocks the others.
Step 7: Give editors instant feedback in Studio
Webhooks are the backbone. Editors still want to know their product made it. A custom publish action wraps Sanity’s own: it captures the intended content, publishes, waits for that content to appear in the published document, triggers the sync and shows a toast.
// studio/actions/publish-and-sync.ts
import { useToast } from '@sanity/ui';
import type { DocumentActionComponent } from 'sanity';
export function withShopifySync(publish: DocumentActionComponent): DocumentActionComponent {
const PublishAndSync: DocumentActionComponent = (props) => {
const original = publish(props);
const toast = useToast();
if (!original) return null;
return {
...original,
onHandle: async () => {
try {
const previousRev = props.published?._rev;
// Capture before publishing; strip() is shared with the server-side diff.
const expectedContent = strip(props.draft ?? props.published ?? null);
if (!expectedContent) throw new Error('No document content to publish');
await original.onHandle?.();
const published = await waitForPublishedContent(props.id, expectedContent, previousRev);
if (!published) throw new Error('Publish not confirmed yet');
const res = await fetch('/api/shopify/sync-product', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: props.id, expectedContent })
});
if (!res.ok) throw new Error(`Sync request failed (HTTP ${res.status})`);
const { ok, error } = await res.json();
if (ok !== true) throw new Error(error || 'Sync did not complete');
toast.push({ status: 'success', title: 'Product synced to Shopify' });
} catch (error) {
toast.push({
status: 'error',
title: 'Shopify sync not confirmed. Check publication and sync status before retrying.',
description: error instanceof Error ? error.message : 'Request failed'
});
}
}
};
};
return PublishAndSync;
}
async function waitForPublishedContent(
id: string,
expectedContent: Record<string, unknown>,
previousRev?: string,
attempts = 30
) {
await new Promise((r) => setTimeout(r, 2000));
for (let i = 0; i < attempts; i++) {
const res = await fetch('/api/sanity/published-revision', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, expectedContent, previousRev })
});
if (!res.ok) throw new Error(`Publication check failed (HTTP ${res.status})`);
const { matches } = await res.json();
if (matches === true) return true;
await new Promise((r) => setTimeout(r, 1000));
}
return false;
}
The illustrated published-revision endpoint is an authenticated Studio-only POST, not a built-in Sanity API. It accepts the published document ID, the captured expectedContent object and previousRev; reads that exact published document with useCdn: false and the published perspective; and returns { matches: true } only when the document exists, its revision differs from previousRev, and isDeepStrictEqual(strip(document), expectedContent) passes. Return { matches: false } while publication is pending and a non-2xx response for request/authentication/read failures. Share the same strip implementation on both sides: remove only top-level Sanity system fields, retaining all editor content including nested references and array keys. Mapping writes never change the product revision and cannot confirm a different draft’s publication.
The authenticated sync endpoint must recheck that content identity against its fresh snapshot inside the serialized worker lock, return a conflict if it changed, and return { ok: true } only after sync completes. An enqueue-only 202 needs job-status polling before a success toast. Retain the reconciliation gate from Step 5 for both trigger paths. The wrapper below is product-only; separate variant publishes sync through the webhook, including deletion and reparenting.
Register it for products only, in sanity.config.ts:
document: {
actions: (prev, ctx) =>
ctx.schemaType === 'product'
? prev.map((action) => (action.action === 'publish' ? withShopifySync(action) : action))
: prev;
}
Webhook and button can both fire for the same publish. The custom-ID upsert targets the same product, but it is still an update, not a guaranteed no-op. Serialize jobs for each store and product so concurrent writes and write-backs cannot race.
Production checklist
- One ID contract:
skai.sanity-id(typeid) on every Shopify product, GIDs in worker-only mapping documents with variant ownership. - One sync query: the GROQ projection is the only list of synced fields.
- Full-state payloads: send complete variants, options, files and owned metafields every time.
- Two-layer loop guard: product/variant type filter plus a server-side content diff; mapping writes never touch editor content.
- Queue and dedupe:
202fast,idempotency-keydedupe, one topic per store. - Smart retries: backoff with jitter for transient failures, none for
userErrors; quarantine uncertain writes with new variants until reconciliation recovers their IDs. - Fail closed on media: validate images before
productSet, never send a partial list. - Observability: log every sync with the Sanity ID and Shopify GID, and alert when webhook volume spikes, which is usually a filter regression.
- Reconciliation: a script that compares Sanity and Shopify by custom ID, for backfills and after any webhook outage.
Getting started
- Decide who owns what. List every product field and mark Sanity, Shopify or computed.
- Create the custom-ID definition in a development store and sync one product end to end.
- Exclude mapping documents before the write-back. Ship both guard layers together.
- Backfill with the same code path as live sync, store by store, after a dry run.
Want this running on your store without the trial and error? We build and run Sanity-first catalogs on headless Shopify, including multi-store setups from one dataset.
Written by Skai Digital: we run Sanity-first Shopify catalogs in production, including the shared platform behind all five storefronts of Verandah, an A$45M e-commerce business. Shopify Select Partner and Sanity agency partner, based in Oslo. Book a discovery call
Frequently Asked Questions
Can Sanity be the source of truth for Shopify products?
Yes. Create and edit products in Sanity Studio, then push them to Shopify with the Admin API productSet mutation, keyed by a unique custom-ID metafield that holds the Sanity document ID. Shopify keeps orders, inventory levels and checkout; Sanity owns the product definition.
Does Sanity Connect sync products from Sanity to Shopify?
Sanity Connect syncs products, variants and collections from Shopify into Sanity. Its outbound option sends Sanity content to Shopify as metafields and metaobjects, not as native product fields like title, variants or price. For a Sanity-first catalog you build a custom sync with productSet.
Why use productSet instead of productCreate and productUpdate?
productSet declares the complete product state in one call, including options and variants, and with a custom-ID identifier it creates or updates in the same request. That removes the normal product lookup step. Retry with stored variant IDs; an uncertain write containing new variants must go to reconciliation before resending.
How do you stop a Sanity webhook from triggering an endless sync loop?
Keep Shopify GIDs in sync-owned mapping documents, not product content. Filter the webhook to published product and variant types, excluding mappings, then compare editor content on the server without Sanity system fields.
Can one Sanity dataset sync to several Shopify stores?
Yes. Store per-store editorial values such as status, price or tags under configured keys on the product, and queue one job per store with its own credentials and topic. Keep IDs in separate documents: shopifyMap.oslo.<productId> and shopifyMap.bergen.<productId>, for example. No store writes GIDs onto editor-owned product or variant documents.
Pass the same validated storeKey through every job, mapping read/write, Shopify client, lock and reconciliation journal. The shopifyAdmin adapter in these examples must be bound to that store’s credentials. Reuse a variant GID only when its mapping belongs to that store and its shopifyProductGid equals the target product’s GID. Recover missing IDs by SKU only within that target Shopify product. For a move, create/recover B’s variant and save B’s mapping before removing A’s entry and syncing A, independently in each store. Mapping writes remain outside the product/variant webhook without store-specific exclusion lists.
Do I still need Shopify metafields when Sanity is the PIM?
Only for data that Shopify itself, Liquid themes or apps must read. The custom-ID metafield is the one you always need, because it links each Shopify product to its Sanity document.
