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/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.
// 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.
// 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 thenext.tagsoption passed tofetch. Consulted on 2026-11-08. -
Next.js - options of
fetch- the meaning ofnext.revalidateand ofnext.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/...andcontentful.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.
Commenti
Caricamento commenti...
Sign in to comment