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:
| Field | Owner | Why |
|---|---|---|
| Title | Medusa | Appears on orders and invoices |
| Handle / slug | Medusa | Drives commerce URLs |
| SKU, price, stock | Medusa | Transactional truth |
| Variants and options | Medusa | Purchase logic |
| Short description | Medusa | Cart and order lines |
| Long-form description | CMS | Editorial |
| Lifestyle imagery | CMS | Art-directed |
| Product imagery | Medusa | Tied to variants |
| Spec tables | CMS | Structured editorial |
| Care instructions, FAQs | CMS | Editorial |
| SEO title and description | CMS | Marketing owns it |
| Category landing copy | CMS | Editorial |
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:
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:
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.
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.
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.
Medusa Storefront Performance: Where the Milliseconds Actually Go
Over-fetching, waterfalls and unoptimised images account for most of a slow headless storefront. How to find them and what to do about each.
Caching Medusa: What to Cache, Where, and What Never To
Four cache layers, one rule about carts, and the invalidation strategy that stops a price change taking twelve hours to appear.
Multi-Language Medusa Storefronts: Routing, Content and hreflang
Medusa handles currency and tax per region, not translation. How to layer language onto that — URL structure, product content, and the hreflang most stores get wrong.



