3 MIN READ

Integrating Medusa with Sanity: Who Owns Which Field

The hard part of a commerce-plus-CMS setup is not the sync. It is deciding which system owns each field — and then never letting both own one.

BY ANANYA IYERUPDATED
Illustration for “Integrating Medusa with Sanity: Who Owns Which Field” — CMS

Medusa is a commerce engine, not a content system. Its product description field is a string, and a merchandising team that wants layouts, lookbooks and structured spec tables will outgrow it in a week.

Pairing it with a CMS is the standard answer. The failure mode is equally standard: two systems that both think they own the product title.

Ownership

The whole design fits in one table, and the discipline is refusing to add a row to both columns:

FieldOwnerWhy
TitleMedusaAppears on orders and invoices
Handle / slugMedusaDrives commerce URLs
SKU, price, stockMedusaTransactional truth
Variants and optionsMedusaPurchase logic
Short descriptionMedusaCart and order lines
Long-form descriptionCMSEditorial
Lifestyle imageryCMSArt-directed
Product imageryMedusaTied to variants
Spec tablesCMSStructured editorial
Care instructions, FAQsCMSEditorial
SEO title and descriptionCMSMarketing owns it
Category landing copyCMSEditorial

The temptation is to let merchandisers edit titles in the CMS because the editing experience is nicer. Resist it. The title is on the invoice, and two sources for one field means a reconciliation bug rather than a nicer workflow.

Linking

A Sanity document referencing the Medusa product by id:

sanity/schema/productContent.tsts
import { defineField, defineType } from "sanity"

export const productContent = defineType({
  name: "productContent",
  title: "Product content",
  type: "document",
  fields: [
    defineField({
      name: "medusaProductId",
      title: "Medusa product ID",
      type: "string",
      description: "prod_… — the stable identifier. Not the handle.",
      validation: (r) => r.required(),
    }),
    defineField({ name: "title", type: "string", readOnly: true, description: "Synced label, for editors only" }),
    defineField({ name: "story", title: "Long-form description", type: "array", of: [{ type: "block" }] }),
    defineField({ name: "lifestyleImages", type: "array", of: [{ type: "image" }] }),
    defineField({ name: "specifications", type: "array", of: [{ type: "specRow" }] }),
    defineField({ name: "seo", type: "seo" }),
  ],
})

By id, not handle, because handles change and a rename would silently orphan the content. Keep a read-only title copy so editors can find documents by name — clearly marked as a label, not a source.

To make the reference selectable rather than typed, add a custom input that lists Medusa products via the Admin API from a server-side proxy.

Merging in the storefront

The storefront is where the two halves come together. Fetch in parallel and merge in the data layer:

lib/data/product.tsts
import { sdk } from "@/lib/medusa"
import { sanityClient } from "@/lib/sanity"

export async function getProductPage(handle: string, regionId: string) {
  const { products } = await sdk.store.product.list({
    handle,
    region_id: regionId,
    fields: "*variants.calculated_price,+variants.inventory_quantity,*images,*categories",
  })

  const product = products[0]
  if (!product) return null

  // Content is enrichment: the page must render without it.
  const content = await sanityClient.fetch(
    `*[_type == "productContent" && medusaProductId == $id][0]{
      story, lifestyleImages, specifications, seo
    }`,
    { id: product.id },
  )

  return { product, content: content ?? null }
}

Two rules. Never block on the CMS — if content is missing or Sanity is slow, the page still sells the product. And never copy commerce data into the CMS or content into Medusa; a sync is a cache, and caches go stale in ways nobody notices until a customer reads an old price in a spec table.

Fetching both in parallel matters; sequential awaits here are the classic waterfall.

Cache invalidation from both sides

Either system can change what a page shows, so both need to invalidate it.

From Medusa, a subscriber on product.updated calls your revalidate endpoint. From Sanity, a webhook on document publish does the same.

app/api/revalidate/route.tsts
import { revalidateTag } from "next/cache"

export async function POST(request: Request) {
  if (request.headers.get("x-revalidate-secret") !== process.env.REVALIDATE_SECRET) {
    return new Response("Unauthorized", { status: 401 })
  }

  const { productId, handle } = await request.json()

  if (handle) revalidateTag(`product-${handle}`)
  if (productId) revalidateTag(`content-${productId}`)

  return Response.json({ revalidated: true })
}

Tag commerce and content fetches separately so a copy edit does not evict cached prices and vice versa. Storefront caching patterns.

When you do not need a CMS

Be honest about it. A CMS is worth it when non-technical people produce editorial content regularly, you need structured content beyond a description field, or you have real localisation needs.

It is not worth it when descriptions are a paragraph, one person maintains the catalog, or "we might need it later". A second system is a second deployment, a second set of credentials and a second thing to debug at 2am. Medusa's description field plus a few module fields covers more stores than people expect.

Sanity or something else

The pattern above is CMS-agnostic — Contentful, Storyblok, Payload and Strapi all fit it. Sanity's advantages for commerce are Portable Text for structured rich content, GROQ for shaping exactly the payload a page needs, and a studio you can customise enough to make the product reference feel native.

The important part is not which CMS. It is the ownership table.

Preview

Editors need to see unpublished content in context, and this is where naive integrations either leak drafts or break caching.

The workable pattern: a draft-mode route that sets a cookie, a data layer that reads the cookie and switches the CMS client to the draft perspective, and dynamic rendering only while that cookie is set.

lib/data/product.tsts
import { draftMode } from "next/headers"

export async function getProductContent(productId: string) {
  const { isEnabled } = await draftMode()

  return sanityClient
    .withConfig({
      perspective: isEnabled ? "previewDrafts" : "published",
      useCdn: !isEnabled,
      token: isEnabled ? process.env.SANITY_VIEWER_TOKEN : undefined,
    })
    .fetch(CONTENT_QUERY, { id: productId })
}

Two rules. The draft token is server-only — a viewer token in client code exposes unpublished content to anyone. And draft mode must not cache, or an editor will see stale drafts and lose faith in the preview entirely.

Keeping content and catalog in step

The failure that shows up months in: a product is deleted in Medusa and its CMS document lingers, or content is written for a product that never launches.

Two mechanisms, both cheap:

A subscriber on product.deleted that flags the corresponding CMS document as orphaned rather than deleting it. Editors may want to recover the copy.

A scheduled reconciliation that lists CMS documents whose medusaProductId no longer resolves, and products with no content document, and posts the two lists somewhere editors will see them.

Neither is glamorous. Both prevent the state where nobody trusts either system to be complete, which is the point at which people start keeping a spreadsheet.


We build these pairings often, and the ownership conversation is the one that saves the project. Ask.

Frequently asked questions

Should I use a CMS with Medusa?

Only if non-technical people regularly produce editorial content, you need structured content beyond a description field, or you have real localisation requirements. Otherwise Medusa's own fields plus a small custom module are simpler and cheaper to operate.

How do I link CMS content to Medusa products?

By the Medusa product id, stored on the CMS document. Handles change when products are renamed and would silently orphan the content, so never link by handle.

Which system should own product titles and prices?

Medusa, always. Titles appear on orders and invoices and prices are transactional truth. The CMS should own long-form copy, lifestyle imagery, spec tables and SEO metadata — and nothing that a transaction depends on.

Should I sync data between Medusa and the CMS?

No. Merge the two sources in the storefront's data layer at request time. A sync is a cache that goes stale, and stale commerce data inside content is the failure mode this architecture exists to avoid.

How do I invalidate the cache when either system changes?

Expose a secret-protected revalidate endpoint and call it from both a Medusa subscriber on product events and a CMS publish webhook. Tag commerce and content fetches separately so each invalidates only what it affects.

What happens if the CMS is down?

Nothing, if you built it correctly. Treat content as enrichment: fetch it alongside commerce data and render the page without it when it is missing. The product must remain purchasable regardless of the CMS.

[ Keep reading ]