Salta al contenuto principale

08 - Content Commerce: Integrare Headless CMS (Contentful/Sanity) con Piattaforma Commerce

Nel CMS va l'identificativo del prodotto, mai il prezzo: lo schema Sanity, una sola chiamata al commerce e due durate di cache invece di una sola.

Cosa ne pensi di questo articolo?

08 - Content Commerce: Integrare Headless CMS (Contentful/Sanity) con Piattaforma Commerce
HeadlessCommerceFoto:Danny MenesesPexels

In Breve

Nel CMS va l'identificativo del prodotto, mai il prezzo: lo schema Sanity, una sola chiamata al commerce e due durate di cache invece di una sola.

Un lookbook stagionale in cui ogni capo e' acquistabile ha un problema che una griglia di prodotti non ha: il testo e le immagini li scrive una redazione, i prezzi e le giacenze li tiene la piattaforma commerce, e la pagina deve mostrarli insieme. Sono due sistemi con due proprietari, due cicli di aggiornamento e due latenze.

La parte difficile non e' chiamare due API. E' decidere dove vive il riferimento fra un contenuto e un prodotto, e quanto a lungo si puo' mettere in cache una pagina che contiene un prezzo. Questo articolo risponde a queste due domande, perche' tutto il resto segue.

Che cosa e' stato verificato

Il codice di questo articolo non e' stato eseguito: richiede un progetto Contentful o Sanity, un backend Medusa e una distribuzione Next.js, che qui non ci sono. E' scritto contro la documentazione ufficiale linkata in fondo.

Due affermazioni della versione precedente sono state rimosse perche' non verificabili: il piano gratuito di Contentful «generoso (25k record)» - la documentazione di Contentful risponde 429 a ogni richiesta da questa rete, quindi non ho potuto confermare nessun numero di listino - e la descrizione di Sanity come «self-hostable», che confonde due componenti diversi. Il dettaglio e' nella sezione sul confronto.

Dove mettere il riferimento fra contenuto e prodotto

Questa e' la decisione da prendere prima di scrivere una riga, e ha due risposte possibili. Puoi tenere nel CMS l'identificativo del prodotto, oppure tenerci una copia dei dati di prodotto.

La seconda opzione e' tentatrice, perche' rende la pagina servibile con una sola chiamata. Ed e' sbagliata: nel momento in cui il prezzo o la disponibilita' sono copiati nel CMS, esistono due verita' sullo stesso prodotto, e la pagina mostrera' un prezzo che il carrello non applica. Non e' un problema di sincronizzazione da risolvere meglio - e' una duplicazione da non introdurre.

Quindi nel CMS va solo il riferimento: un campo di testo che contiene l'identificativo o lo slug del prodotto nella piattaforma commerce. Il contenuto resta responsabile del racconto, la piattaforma commerce del dato commerciale, e la pagina li unisce al momento della richiesta.

Sanity - lo schema, con il riferimento al prodotto
// 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' },      // generato dal titolo
      validation: (Rule) => Rule.required(),
    }),
    defineField({
      name: 'sections',
      type: 'array',
      of: [
        // Una sezione puramente editoriale
        defineArrayMember({
          name: 'editorial',
          type: 'object',
          fields: [
            defineField({ name: 'heading', type: 'string' }),
            defineField({ name: 'body', type: 'array', of: [{ type: 'block' }] }),
          ],
        }),
        // Una vitrina di prodotti: SOLO gli identificativi
        defineArrayMember({
          name: 'productShowcase',
          type: 'object',
          fields: [
            defineField({ name: 'heading', type: 'string' }),
            defineField({
              name: 'productIds',
              type: 'array',
              of: [{ type: 'string' }],   // nessun prezzo, nessuna giacenza
            }),
          ],
        }),
      ],
    }),
  ],
});

Il campo sections e' un array i cui elementi possono essere di due tipi diversi, editorial o productShowcase. E' questo che consente a un redattore di alternare testo e prodotti nell'ordine che vuole, senza che uno sviluppatore intervenga: la pagina non ha una struttura fissa, ha una sequenza.

La riga da difendere in revisione e' productIds con of: [{ type: 'string' }]. Contiene identificativi e nient'altro. La richiesta che arrivera', prima o poi, e' «aggiungiamo anche il prezzo qui cosi' la redazione lo vede»: a quel punto il prezzo nel CMS diventa il prezzo che il cliente legge, e divergera' da quello che paga.

type: 'block' nel corpo editoriale e' il formato di testo ricco di Sanity, che viene restituito come struttura dati e non come HTML. Significa che devi renderizzarlo tu con un componente, ed e' un vantaggio: il contenuto non porta markup, quindi la stessa descrizione puo' essere resa in una pagina web, in una email o in un'app senza riscriverla.

options: { source: 'title' } sullo slug fa proporre allo studio uno slug derivato dal titolo, che il redattore puo' correggere. La parte importante e' che resta un campo salvato: se fosse calcolato al volo dal titolo, rinominare un lookbook cambierebbe il suo indirizzo pubblico e romperebbe i collegamenti esistenti.

Contentful e Sanity: che cosa li distingue davvero

Il confronto che si legge di solito e' un elenco di funzionalita'. Le differenze che cambiano il codice sono poche, e stanno tutte in come si interroga il contenuto e in chi definisce lo schema.

Contentful

  • Schema definito nell'interfaccia web, o via Management API
  • API REST e GraphQL
  • Content Delivery API servita da CDN
  • Gestito interamente dal fornitore

Il filo che unisce queste quattro righe e' che nessuna ti chiede di gestire infrastruttura: il prezzo di quella comodita' e' che il modello dei contenuti vive fuori dal tuo repository, quindi fuori dalla tua revisione del codice.

Sanity

  • Schema definito in TypeScript, nel tuo repository
  • GROQ come linguaggio di interrogazione, piu' GraphQL
  • Content Lake come archivio gestito, con CDN delle API
  • Lo Studio e' un'applicazione che distribuisci tu

Qui il filo e' opposto: schema e interfaccia di redazione stanno nel tuo repository e seguono i tuoi rilasci. La quarta riga e' quella da leggere con attenzione, perche' riguarda lo Studio e non l'archivio dei contenuti.

La riga che pesa piu' di tutte e' la prima di ciascuna colonna, e non e' una questione di gusto: decide chi puo' rompere la tua build. Con lo schema in TypeScript nel repository, un cambiamento di modello e' un commit che passa dalla revisione e dalla compilazione; con lo schema nell'interfaccia web, un redattore con i permessi giusti puo' rinominare un campo in produzione e la tua query smette di trovarlo senza che nessun test lo abbia previsto.

La differenza opposta e' altrettanto reale: lo schema nell'interfaccia web permette a un team non tecnico di evolvere il modello senza aspettare un rilascio. Qual delle due vale piu' dipende da chi ci lavora, e questa e' la vera domanda da porsi - non quale prodotto abbia piu' funzionalita'.

Sull'ultima riga di Sanity serve la precisazione annunciata sopra. Lo Studio e' un'applicazione React che compili e pubblichi dove vuoi, quindi quello si', lo ospiti tu. Il Content Lake, cioe' l'archivio dove i documenti vivono davvero, e' descritto nella documentazione come un servizio della piattaforma, con i propri dataset, backup e CDN delle API: non e' un componente che installi su una tua macchina. Dire «Sanity e' self-hostable» senza distinguere i due induce a credere che si possa tenere il contenuto in casa, e non e' cosi'.

Interrogare: due chiamate in parallelo, non in fila

La pagina ha bisogno di due cose indipendenti: il contenuto dal CMS e i prodotti dalla piattaforma commerce. Il secondo, pero', dipende dal primo, perche' gli identificativi dei prodotti stanno dentro il contenuto. C'e' quindi una dipendenza reale, e va gestita in due tempi.

Recupero in due fasi, con la seconda in parallelo
// 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) {
  // Fase 1: il contenuto. Cache lunga: cambia quando lo cambia la redazione.
  const contenuto = await sanityClient.fetch(
    LOOKBOOK,
    { slug },
    { next: { tags: [`lookbook:${slug}`], revalidate: 3600 } },
  );
  if (!contenuto) return null;

  // Raccolgo TUTTI gli id di tutte le sezioni, deduplicati
  const ids = [
    ...new Set(
      contenuto.sections
        .flatMap((s: { productIds?: string[] }) => s.productIds ?? []),
    ),
  ];

  // Fase 2: una sola chiamata per tutti i prodotti, cache breve.
  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 };
}

Il punto di questo codice e' la costruzione di ids. Il flatMap attraversa tutte le sezioni e raccoglie gli identificativi di tutte le vitrine in un unico elenco, e il new Set rimuove i duplicati - perche' lo stesso prodotto puo' comparire in due sezioni dello stesso lookbook.

Da li' parte una sola richiesta alla piattaforma commerce, con tutti gli identificativi nel parametro id. L'alternativa che vedo piu' spesso - un fetch dentro ciascun componente di prodotto - produce una richiesta per prodotto: su un lookbook con venti capi sono venti chiamate di rete in serie, e il tempo di risposta della pagina diventa la loro somma.

La Map finale esiste per lo stesso motivo: durante il rendering ogni sezione deve trovare i propri prodotti, e cercarli con un find sull'array significa scorrere l'elenco per ogni capo. Con la mappa l'accesso e' diretto, e soprattutto il codice di rendering non puo' piu' essere tentato di fare una chiamata di rete per conto proprio.

La query GROQ merita una nota, perche' e' la differenza pratica piu' visibile rispetto a GraphQL. *[_type == "lookbook" && slug.current == $slug][0] si legge come: fra tutti i documenti, prendi quelli di tipo lookbook con quello slug, e tieni il primo. Le graffe che seguono sono una proiezione: elenchi i campi che vuoi, e quelli non elencati non arrivano. E' la stessa economia di GraphQL con una sintassi piu' vicina a un filtro.

Il && slug.current == $slug usa un parametro, non un'interpolazione di stringa. Vale la stessa regola di SQL: costruire la query concatenando il valore che arriva dall'URL apre la porta a un'iniezione nel linguaggio di interrogazione, e il fatto che non sia SQL non cambia la natura del problema.

La cache: due durate, perche' i dati non sono uguali

Qui sta la decisione architetturale di questo articolo. Un articolo editoriale cambia quando un redattore lo pubblica, cioe' raramente e in modo noto. Un prezzo o una giacenza cambiano quando vuole il magazzino, cioe' spesso e senza avvisare. Dare la stessa durata di cache ai due e' l'errore.

Nel codice sopra questo e' espresso da due numeri: revalidate: 3600 sul contenuto, un'ora, e revalidate: 60 sui prodotti, un minuto. Non sono valori universali - sono la forma della scelta, e i numeri giusti dipendono da quanto spesso cambiano i tuoi prezzi e da quanto sei disposto a mostrarne uno vecchio.

Il prezzo in cache e' un problema commerciale, non tecnico

Qualunque cache sui dati di prodotto implica che per un certo tempo la pagina mostri un prezzo o una disponibilita' superati. La regola che rende la cosa gestibile e' non fidarsi mai del dato in cache al momento dell'acquisto: il prezzo si riverifica lato server quando l'articolo entra nel carrello e di nuovo alla creazione dell'ordine.

Con questa regola, un prezzo vecchio in pagina e' un fastidio - l'utente vede un valore e al carrello ne trova un altro - e si risolve mostrandoglielo. Senza, e' un ordine evaso al prezzo sbagliato. La cache della pagina e la fonte di verita' del prezzo sono due cose distinte, e confonderle e' il modo di trasformare un'ottimizzazione in una perdita.

Per il contenuto, invece, esiste una soluzione migliore della scadenza a tempo: invalidare esattamente cio' che e' cambiato, quando cambia. E' il motivo del campo tags nel fetch della prima fase.

Il webhook del CMS che invalida la singola pagina
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(req: NextRequest) {
  // Senza questo controllo, l'endpoint e' un invito a svuotare la cache
  if (req.headers.get('x-revalidation-secret') !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json({ error: 'non autorizzato' }, { status: 401 });
  }

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

  // Invalida SOLO la voce con questo tag, non l'intera cache
  revalidateTag(`lookbook:${slug}`);
  return NextResponse.json({ revalidato: slug });
}

Il legame fra i due blocchi e' la stringa lookbook:${slug}: compare come tag nel fetch della prima fase e come argomento di revalidateTag qui. Quando la redazione pubblica, il CMS chiama questo endpoint, il tag viene invalidato e la prossima richiesta a quella pagina ricostruisce il contenuto. Le altre migliaia di pagine restano in cache.

Il controllo del segreto nell'intestazione e' la prima riga per un motivo: questo endpoint e' pubblico, e senza autenticazione chiunque conosca il percorso puo' invocarlo in ciclo. L'effetto non e' una fuga di dati, e' lo svuotamento continuo della cache, cioe' tutto il traffico che ricade sul CMS e sulla piattaforma commerce.

Nota che il tag invalidato riguarda solo il contenuto, non i prodotti. E' coerente: il CMS sa quando cambia un lookbook e lo comunica, mentre la piattaforma commerce non ha motivo di avvisarti a ogni variazione di giacenza - per quella resta la scadenza breve. Se il tuo commerce espone webhook sui prezzi, lo stesso schema si applica, con un tag per prodotto.

Che cosa sai fare adesso

Sai che nel CMS va l'identificativo del prodotto e mai il prezzo, sai raccogliere gli identificativi di tutte le sezioni in una sola richiesta al commerce invece di una per capo, sai perche' il contenuto e i prodotti hanno durate di cache diverse, e sai che il prezzo va riverificato al carrello indipendentemente da qualunque cache.

Sai anche la domanda da porsi scegliendo il CMS, che non riguarda le funzionalita' ma chi puo' cambiare lo schema senza passare da una revisione.

Il perche' di questa separazione fra sistemi e' il tema d'apertura della serie, cos'e' l'headless commerce e quando conviene, con il quadro architetturale piu' ampio in architettura MACH. Lo storefront che consuma queste due fonti e' costruito in storefront con Next.js e App Router, e il passo successivo - quanto costa in termini di Core Web Vitals una pagina che unisce due sorgenti, e come descriverla ai motori - e' in performance, SEO e dati strutturati. Sul punto piu' delicato, cioe' riverificare il prezzo al momento del pagamento, il meccanismo e' in pagamenti con Stripe Payment Intents.

Fonti

  • Sanity - GROQ - la sintassi dei filtri *[...], le proiezioni fra graffe e i parametri di query. Consultata il 2026-11-08.
  • Sanity - Content Lake - la descrizione dell'archivio come servizio della piattaforma, con dataset, backup e CDN delle API: la fonte della precisazione sul «self-hosting», che riguarda lo Studio e non il Content Lake. Consultata il 2026-11-08.
  • Next.js - revalidateTag - l'invalidazione per tag e la corrispondenza con l'opzione next.tags passata a fetch. Consultata il 2026-11-08.
  • Next.js - opzioni di fetch - il significato di next.revalidate e di next.tags. Consultata il 2026-11-08.
  • Medusa - modulo Product - la forma delle risposte di prodotto e il filtro per identificativi usato nella seconda fase. Consultata il 2026-11-08.
  • Fonte non raggiungibile, e sua conseguenza - la documentazione e il listino di Contentful (contentful.com/developers/docs/... e contentful.com/pricing/) rispondono 429 a ogni tentativo da questa rete, verificato tre volte il 2026-11-08. Per questo le righe su Contentful in questo articolo descrivono solo caratteristiche strutturali e nessun numero di listino: l'affermazione «piano gratuito generoso (25k record)» e' stata rimossa invece di essere riportata senza conferma.

Le prime cinque voci sono documentazione dei progetti il cui codice compare negli esempi, e sono la ragione per cui le affermazioni su GROQ e sulla cache di Next.js si possono controllare riga per riga. L'ultima voce e' la piu' istruttiva per il lettore: dichiara una fonte che non ho potuto leggere e il fatto che questo ha cambiato il testo, invece di lasciarlo uguale sperando che il numero fosse giusto. Un prezzo o una quota citati senza verifica invecchiano male e non si distinguono da un'invenzione.

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti