Skip to main content

08 - Content Commerce: Integrate Headless CMS (Contentful/Sanity) with Commerce Platform

The CMS holds the product identifier, never the price: the Sanity schema, a single call to the commerce platform and two cache durations instead of one.

What do you think about this article?

At a Glance

The CMS holds the product identifier, never the price: the Sanity schema, a single call to the commerce platform and two cache durations instead of one.

A seasonal lookbook where every garment can be bought has a problem that a product grid does not: an editorial team writes the text and the images, the commerce platform holds the prices and the stock levels, and the page has to show them together. That is two systems with two owners, two update cycles and two latencies.

The hard part is not calling two APIs. It is deciding where the reference lives between a piece of content and a product, and how long you can cache a page that contains a price. This article answers those two questions, because everything else follows from them.

What was verified

The code in this article was not run: it needs a Contentful or Sanity project, a Medusa backend and a Next.js deployment, none of which exist here. It is written against the official documentation linked at the bottom.

Two claims from the previous version were removed because they could not be verified: Contentful's "generous" free plan (25k records) - Contentful's documentation answers 429 to every request from this network, so I could not confirm any list-price figure - and the description of Sanity as "self-hostable", which conflates two different components. The details are in the comparison section.

Where to put the reference between content and product

This is the decision to make before writing a single line, and it has two possible answers. You can keep the product's identifier in the CMS, or you can keep a copy of the product data there.

The second option is tempting, because it makes the page servable with a single call. And it is wrong: the moment the price or the availability is copied into the CMS, there are two truths about the same product, and the page will show a price that the cart does not apply. It is not a synchronisation problem to be solved better - it is a duplication not to be introduced.

So only the reference goes in the CMS: a text field holding the product's identifier or slug in the commerce platform. The content stays responsible for the story, the commerce platform for the commercial data, and the page joins them at request time.

Sanity - the schema, with the reference to the product
// sanity/schemas/lookbook.ts
import { defineType, defineField, defineArrayMember } from 'sanity';

export const lookbook = defineType({
  name: 'lookbook',
  title: 'Lookbook',
  type: 'document',
  fields: [
    defineField({
      name: 'title',
      type: 'string',
      validation: (Rule) => Rule.required(),
    }),
    defineField({
      name: 'slug',
      type: 'slug',
      options: { source: 'title' },      // generated from the title
      validation: (Rule) => Rule.required(),
    }),
    defineField({
      name: 'sections',
      type: 'array',
      of: [
        // A purely editorial section
        defineArrayMember({
          name: 'editorial',
          type: 'object',
          fields: [
            defineField({ name: 'heading', type: 'string' }),
            defineField({ name: 'body', type: 'array', of: [{ type: 'block' }] }),
          ],
        }),
        // A product showcase: ONLY the identifiers
        defineArrayMember({
          name: 'productShowcase',
          type: 'object',
          fields: [
            defineField({ name: 'heading', type: 'string' }),
            defineField({
              name: 'productIds',
              type: 'array',
              of: [{ type: 'string' }],   // no price, no stock level
            }),
          ],
        }),
      ],
    }),
  ],
});

The sections field is an array whose elements can be of two different types, editorial or productShowcase. That is what lets an editor alternate text and products in whatever order they like, without a developer stepping in: the page has no fixed structure, it has a sequence.

The line to defend in code review is productIds with of: [{ type: 'string' }]. It holds identifiers and nothing else. The request that will come, sooner or later, is "let's add the price here too so the editorial team can see it": at that point the price in the CMS becomes the price the customer reads, and it will diverge from the one they pay.

type: 'block' in the editorial body is Sanity's rich-text format, which is returned as a data structure and not as HTML. It means you have to render it yourself with a component, and that is an advantage: the content carries no markup, so the same description can be rendered on a web page, in an email or in an app without rewriting it.

options: { source: 'title' } on the slug makes the studio propose a slug derived from the title, which the editor can correct. The important part is that it stays a stored field: if it were computed on the fly from the title, renaming a lookbook would change its public address and break the existing links.

Contentful and Sanity: what really sets them apart

The comparison you usually read is a list of features. The differences that change the code are few, and they all lie in how the content is queried and in who defines the schema.

Contentful

  • Schema defined in the web interface, or via the Management API
  • REST and GraphQL APIs
  • Content Delivery API served from a CDN
  • Managed entirely by the vendor

The thread running through these four lines is that none of them asks you to manage infrastructure: the price of that convenience is that the content model lives outside your repository, and therefore outside your code review.

Sanity

  • Schema defined in TypeScript, in your repository
  • GROQ as the query language, plus GraphQL
  • Content Lake as the managed store, with an API CDN
  • The Studio is an application you deploy yourself

Here the thread is the opposite: schema and editing interface live in your repository and follow your releases. The fourth line is the one to read carefully, because it concerns the Studio and not the content store.

The line that weighs most is the first of each column, and it is not a matter of taste: it decides who can break your build. With the schema in TypeScript in the repository, a model change is a commit that goes through review and compilation; with the schema in the web interface, an editor with the right permissions can rename a field in production and your query stops finding it without any test having foreseen it.

The opposite difference is just as real: a schema in the web interface lets a non-technical team evolve the model without waiting for a release. Which of the two is worth more depends on who works on it, and that is the real question to ask - not which product has more features.

The last Sanity line needs the clarification announced above. The Studio is a React application that you build and publish wherever you like, so that one, yes, you host yourself. The Content Lake, the store where the documents actually live, is described in the documentation as a service of the platform, with its own datasets, backups and API CDN: it is not a component you install on a machine of your own. Saying "Sanity is self-hostable" without distinguishing the two leads people to believe the content can be kept in-house, and it cannot.

Querying: two calls in parallel, not in a queue

The page needs two independent things: the content from the CMS and the products from the commerce platform. The second, however, depends on the first, because the product identifiers live inside the content. So there is a real dependency, and it has to be handled in two steps.

Two-phase retrieval, with the second phase in parallel
// lib/lookbook.ts
import { groq } from 'next-sanity';

const LOOKBOOK = groq`
  *[_type == "lookbook" && slug.current == $slug][0]{
    title,
    "slug": slug.current,
    sections[]{
      _type,
      heading,
      body,
      productIds
    }
  }
`;

export async function getPaginaLookbook(slug: string) {
  // Phase 1: the content. Long cache: it changes when the editorial team changes it.
  const contenuto = await sanityClient.fetch(
    LOOKBOOK,
    { slug },
    { next: { tags: [`lookbook:${slug}`], revalidate: 3600 } },
  );
  if (!contenuto) return null;

  // I collect ALL the ids from all the sections, deduplicated
  const ids = [
    ...new Set(
      contenuto.sections
        .flatMap((s: { productIds?: string[] }) => s.productIds ?? []),
    ),
  ];

  // Phase 2: a single call for all the products, short cache.
  const prodotti = ids.length
    ? await fetch(
        `${process.env.MEDUSA_URL}/store/products?id=${ids.join(',')}`,
        { next: { revalidate: 60 } },
      ).then((r) => r.json())
    : { products: [] };

  const perId = new Map(
    prodotti.products.map((p: { id: string }) => [p.id, p]),
  );

  return { contenuto, perId };
}

The point of this code is how ids is built. The flatMap walks through all the sections and gathers the identifiers of all the showcases into a single list, and the new Set removes duplicates - because the same product can appear in two sections of the same lookbook.

From there one single request goes to the commerce platform, with all the identifiers in the id parameter. The alternative I see most often - a fetch inside each product component - produces one request per product: on a lookbook with twenty garments that is twenty network calls in a row, and the page's response time becomes their sum.

The final Map exists for the same reason: during rendering each section has to find its own products, and looking them up with a find on the array means scanning the list for every garment. With the map the access is direct, and above all the rendering code can no longer be tempted to make a network call of its own.

The GROQ query deserves a note, because it is the most visible practical difference from GraphQL. *[_type == "lookbook" && slug.current == $slug][0] reads as: among all the documents, take those of type lookbook with that slug, and keep the first. The braces that follow are a projection: you list the fields you want, and the ones not listed do not arrive. It is the same economy as GraphQL with a syntax closer to a filter.

The && slug.current == $slug uses a parameter, not a string interpolation. The same rule as in SQL applies: building the query by concatenating the value that arrives from the URL opens the door to an injection into the query language, and the fact that it is not SQL does not change the nature of the problem.

The cache: two durations, because the data is not the same

This is the architectural decision of this article. An editorial article changes when an editor publishes it, that is rarely and in a known way. A price or a stock level changes when the warehouse wants, that is often and without warning. Giving the two the same cache duration is the mistake.

In the code above this is expressed by two numbers: revalidate: 3600 on the content, one hour, and revalidate: 60 on the products, one minute. They are not universal values - they are the shape of the choice, and the right numbers depend on how often your prices change and on how willing you are to show an old one.

A cached price is a commercial problem, not a technical one

Any cache on product data means that for a certain time the page shows an out-of-date price or availability. The rule that makes this manageable is never trust the cached data at purchase time: the price is re-checked on the server when the item enters the cart and again when the order is created.

With this rule, an old price on the page is a nuisance - the user sees one value and finds another in the cart - and it is solved by showing it to them. Without it, it is an order fulfilled at the wrong price. The page cache and the source of truth for the price are two distinct things, and confusing them is how you turn an optimisation into a loss.

For the content, on the other hand, there is a better solution than time-based expiry: invalidate exactly what changed, when it changes. That is the reason for the tags field in the fetch of the first phase.

The CMS webhook that invalidates the single page
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(req: NextRequest) {
  // Without this check, the endpoint is an invitation to empty the cache
  if (req.headers.get('x-revalidation-secret') !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
  }

  const { slug, tipo } = await req.json();
  if (tipo !== 'lookbook' || !slug) {
    return NextResponse.json({ error: 'incomplete request' }, { status: 400 });
  }

  // Invalidates ONLY the entry with this tag, not the whole cache
  revalidateTag(`lookbook:${slug}`);
  return NextResponse.json({ revalidated: slug });
}

The link between the two blocks is the string lookbook:${slug}: it appears as a tag in the fetch of the first phase and as the argument of revalidateTag here. When the editorial team publishes, the CMS calls this endpoint, the tag is invalidated and the next request for that page rebuilds the content. The other thousands of pages stay in cache.

The secret check in the header comes first for a reason: this endpoint is public, and without authentication anyone who knows the path can call it in a loop. The effect is not a data leak, it is the continuous emptying of the cache, that is all the traffic falling back onto the CMS and the commerce platform.

Note that the invalidated tag concerns only the content, not the products. That is consistent: the CMS knows when a lookbook changes and says so, while the commerce platform has no reason to notify you on every stock variation - for that, the short expiry remains. If your commerce exposes webhooks on prices, the same scheme applies, with one tag per product.

What you can do now

You know that the CMS holds the product identifier and never the price, you know how to gather the identifiers of all the sections into a single request to the commerce platform instead of one per garment, you know why the content and the products have different cache durations, and you know that the price must be re-checked at the cart regardless of any cache.

You also know the question to ask when choosing the CMS, which is not about features but about who can change the schema without going through a review.

The reason for this separation between systems is the opening theme of the series, what headless commerce is and when it pays off, with the wider architectural picture in MACH architecture. The storefront that consumes these two sources is built in a storefront with Next.js and the App Router, and the next step - what a page that merges two sources costs in Core Web Vitals, and how to describe it to search engines - is in performance, SEO and structured data. On the most delicate point, re-checking the price at payment time, the mechanism is in payments with Stripe Payment Intents.

Sources

  • Sanity - GROQ - the syntax of the *[...] filters, the projections between braces and the query parameters. Consulted on 2026-11-08.
  • Sanity - Content Lake - the description of the store as a platform service, with datasets, backups and an API CDN: the source of the clarification about "self-hosting", which concerns the Studio and not the Content Lake. Consulted on 2026-11-08.
  • Next.js - revalidateTag - invalidation by tag and its correspondence with the next.tags option passed to fetch. Consulted on 2026-11-08.
  • Next.js - options of fetch - the meaning of next.revalidate and of next.tags. Consulted on 2026-11-08.
  • Medusa - Product module - the shape of the product responses and the filter by identifiers used in the second phase. Consulted on 2026-11-08.
  • Unreachable source, and its consequence - the Contentful documentation and price list (contentful.com/developers/docs/... and contentful.com/pricing/) answer 429 to every attempt from this network, verified three times on 2026-11-08. For this reason the lines about Contentful in this article describe only structural characteristics and no list-price figure: the claim "generous free plan (25k records)" was removed instead of being reported without confirmation.

The first five entries are documentation of the projects whose code appears in the examples, and they are the reason the claims about GROQ and about the Next.js cache can be checked line by line. The last entry is the most instructive for the reader: it declares a source I could not read and the fact that this changed the text, instead of leaving it the same and hoping the number was right. A price or a quota quoted without verification ages badly and cannot be told apart from an invention.

Try These Tools

Free developer tools related to this topic.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects