Salta al contenuto principale

06 - Storefront Headless con Next.js App Router: Catalog, Cart Persistente e Checkout Flow

Costruisci uno storefront con Next.js App Router: pagina prodotto con rigenerazione, carrello senza prezzi, totale ricalcolato dal server e JSON-LD sicuro.

Cosa ne pensi di questo articolo?

06 - Storefront Headless con Next.js App Router: Catalog, Cart Persistente e Checkout Flow
HeadlessCommerceFoto:Daniil KomovPexels

In Breve

Costruisci uno storefront con Next.js App Router: pagina prodotto con rigenerazione, carrello senza prezzi, totale ricalcolato dal server e JSON-LD sicuro.

Headless Commerce 11 min

Un storefront con Next.js App Router decide che cosa si genera sul server e che cosa gira nel browser. Qui costruisci la logica di catalogo, carrello e ordine in modo che il prezzo non si possa manomettere e che le pagine prodotto restino veloci e indicizzabili.

next.js app router storefront headless carrello incremental static regeneration json-ld product

Il confine che decide tutto: server e browser

Uno storefront che parla con un backend headless deve rispondere a una domanda prima di scrivere codice: che cosa serve al browser e che cosa no? Nell'App Router di Next.js, la documentazione sui componenti server e client la risolve così: i Server Components recuperano dati, producono HTML sul server e possono essere messi in cache; i Client Components servono quando c'è interattività o si usano le API del browser, e si dichiarano con la direttiva 'use client'. Per un negozio la divisione si traccia da sola: la pagina del prodotto (titolo, prezzo, descrizione, dati strutturati) è contenuto da indicizzare e va sul server; il pulsante «aggiungi al carrello» e il carrello laterale sono interazione e vanno nel browser.

Qui il backend è volutamente generico: nei capitoli precedenti ne hai visti tre (Shopify, Medusa, Saleor) e tutti espongono prodotti, varianti e un modo per ordinare. Il consiglio pratico è di isolare le chiamate in un unico modulo, un adattatore che esporta funzioni come getProductBySlug, così il resto dell'applicazione non sa quale backend sta usando. Il costo di questa scelta è uno strato in più da scrivere e testare; il vantaggio è che cambiare fornitore significa riscrivere un file, non cinquanta componenti. Se testi e immagini delle pagine vengono da un CMS, l'articolo sul content commerce mostra come affiancarlo allo stesso adattatore.

La pagina prodotto: ISR, 404 veri e metadati

Una pagina prodotto cambia di rado e si visita spesso: il caso ideale per la rigenerazione statica incrementale (ISR). Secondo la guida di Next.js (versione 16.4.6, consultata il 2026-11-12, nella variante senza Cache Components) basta esportare revalidate e generateStaticParams: le pagine elencate vengono generate in fase di build; dopo il tempo indicato la prima richiesta riceve ancora la copia vecchia, mentre in background si prepara quella nuova; alla richiesta successiva arriva la nuova. È lo stesso meccanismo stale-while-revalidate visto nell’articolo su Hydrogen, applicato alla pagina intera.

// app/products/[slug]/page.tsx - Server Component con ISR (Next.js senza Cache Components)
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { getProductBySlug, listSlugs } from '@/lib/commerce'; // il TUO adattatore verso il backend
import { productJsonLd, serializza } from '@/lib/json-ld';
import { AddToCartButton } from '@/components/AddToCartButton'; // Client Component ('use client')

export const revalidate = 3600; // dopo un'ora la prossima visita avvia la rigenerazione

export async function generateStaticParams() {
  return (await listSlugs()).map((slug) => ({ slug }));
}

type Props = { params: Promise<{ slug: string }> };

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const p = await getProductBySlug(slug);
  return p ? { title: p.nome, description: p.descrizioneBreve } : { title: 'Prodotto non trovato' };
}

export default async function ProductPage({ params }: Props) {
  const { slug } = await params;
  const p = await getProductBySlug(slug);
  if (!p) notFound(); // 404 vero, non una pagina "non trovato" con stato 200

  return (
    <main>
      <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: serializza(productJsonLd(p)) }} />
      <h1>{p.nome}</h1>
      <p>{(p.prezzoCents / 100).toFixed(2)} {p.valuta}</p>
      <AddToCartButton variantId={p.variantIdPredefinita} />
    </main>
  );
}

Questo blocco non è stato eseguito: richiede un progetto Next.js e un backend, e @/lib/commerce è l'adattatore di cui parlavo, che devi scrivere tu; è uno schema da adattare. Le righe da guardare sono tre. Con revalidate = 3600 un prezzo cambiato nel backend può restare vecchio fino a un'ora più il tempo di rigenerazione: la guida consiglia tempi alti e, quando serve precisione, la rivalidazione su richiesta con revalidatePath. La chiamata a notFound() produce un 404 vero: la pagina «prodotto non trovato» con stato 200 verrebbe indicizzata come contenuto duplicato. Infine params è una promessa da attendere, come nell'esempio della guida.

Il carrello: poca logica, tutta pura, nessun prezzo

Il carrello è lo stato che la maggior parte dei tutorial rende più complicato del necessario. La sua logica è piccola: aggiungere una variante (sommando se c'è già), impostare la quantità (zero la toglie) e calcolare un totale. Scritta come funzioni pure che restituiscono un nuovo array senza toccare quello ricevuto, si testa senza React e si collega poi a qualsiasi store. Il listato qui sotto è quella logica con una prova in fondo, eseguita con Node 24.

export type Riga = { variantId: string; quantita: number };

// Funzioni pure: non modificano mai l'array ricevuto, ne restituiscono uno nuovo.
export function aggiungi(righe: readonly Riga[], variantId: string, quantita = 1): Riga[] {
  if (!Number.isInteger(quantita) || quantita < 1) throw new Error('quantita non valida');
  const esiste = righe.some((r) => r.variantId === variantId);
  return esiste
    ? righe.map((r) => (r.variantId === variantId ? { ...r, quantita: r.quantita + quantita } : r))
    : [...righe, { variantId, quantita }];
}

export function impostaQuantita(righe: readonly Riga[], variantId: string, quantita: number): Riga[] {
  if (quantita <= 0) return righe.filter((r) => r.variantId !== variantId);
  return righe.map((r) => (r.variantId === variantId ? { ...r, quantita } : r));
}

// I prezzi NON stanno nel carrello: si leggono dal catalogo (in centesimi interi) quando servono.
export type Catalogo = ReadonlyMap<string, { titolo: string; prezzoCents: number }>;

export function totaleCents(righe: readonly Riga[], catalogo: Catalogo): number {
  return righe.reduce((somma, r) => {
    const v = catalogo.get(r.variantId);
    if (!v) throw new Error(`variante sconosciuta: ${r.variantId}`);
    return somma + v.prezzoCents * r.quantita;
  }, 0);
}


// --- Prova: gira solo se lanci questo file direttamente ---
if (import.meta.main) {

  const catalogo: Catalogo = new Map([
    ['v_moka', { titolo: 'Moka 3 tazze', prezzoCents: 2400 }],
    ['v_filtro', { titolo: 'Filtro V60', prezzoCents: 1250 }],
  ]);

  const vuoto: Riga[] = [];
  const a = aggiungi(vuoto, 'v_moka');
  const b = aggiungi(a, 'v_filtro', 2);
  const c = aggiungi(b, 'v_moka'); // stessa variante: si somma, non si duplica la riga
  const d = impostaQuantita(c, 'v_filtro', 0); // quantita 0: la riga sparisce

  console.log('vuoto ancora vuoto:', vuoto.length === 0);
  console.log('righe dopo c:', JSON.stringify(c));
  console.log('totale c:', totaleCents(c, catalogo) / 100, 'EUR');
  console.log('righe dopo d:', JSON.stringify(d));
  try { aggiungi(d, 'v_moka', 0); } catch (e) { console.log('errore atteso:', (e as Error).message); }
}

// Output:
// vuoto ancora vuoto: true
// righe dopo c: [{"variantId":"v_moka","quantita":2},{"variantId":"v_filtro","quantita":2}]
// totale c: 73 EUR
// righe dopo d: [{"variantId":"v_moka","quantita":2}]
// errore atteso: quantita non valida

Due scelte contano più del resto. La prima: il carrello contiene solo variantId e quantità, non il prezzo. Se salvi il prezzo nel carrello, lo conservi anche quando nel catalogo cambia, e il cliente vede un totale che non esiste più; così invece il prezzo si legge dal catalogo ogni volta che serve (totaleCents). La seconda: gli importi sono interi in centesimi, perché sommare decimali in virgola mobile produce errori come 0,1 + 0,2. L’uscita mostra che il carrello vuoto resta vuoto dopo le operazioni (nessuna modifica in place), che la stessa variante si somma e che una quantità non valida viene rifiutata.

Collegare queste funzioni a uno store è poco codice. Il listato seguente usa Zustand con il middleware persist, che salva lo stato nel localStorage del browser; non l'ho eseguito perché Zustand non è installato qui. Il limite da conoscere: il localStorage esiste solo nel browser, quindi il server non sa che cosa c'è nel carrello e non può mostrarlo nel primo HTML; per questo Shopify e Saleor tengono il carrello sul server e il tuo storefront ne conserva soltanto l'identificativo. Un carrello locale va bene finché il checkout riparte dai dati del server, come vedi nella sezione successiva.

// lib/cart-store.ts - il carrello come store Zustand che riusa le funzioni pure
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import { aggiungi, impostaQuantita, type Riga } from './carrello';

type CartStore = {
  righe: Riga[];
  aggiungi: (variantId: string, quantita?: number) => void;
  impostaQuantita: (variantId: string, quantita: number) => void;
  svuota: () => void;
};

export const useCart = create<CartStore>()(
  persist(
    (set) => ({
      righe: [],
      aggiungi: (id, q = 1) => set((s) => ({ righe: aggiungi(s.righe, id, q) })),
      impostaQuantita: (id, q) => set((s) => ({ righe: impostaQuantita(s.righe, id, q) })),
      svuota: () => set({ righe: [] }),
    }),
    { name: 'carrello', storage: createJSONStorage(() => localStorage) },
  ),
);

L'ordine: il server non si fida del browser

La versione più comune di questo flusso invia al server il carrello e il totale calcolato nel browser. È un errore di fondo: chiunque apra gli strumenti di sviluppo può cambiare il totale in un centesimo e rispedire la richiesta. Il server deve trattare ciò che riceve come un’intenzione (quali varianti, quante) e ricalcolare il resto dal proprio catalogo. Il listato riusa totaleCents e rifiuta carrelli vuoti, varianti sconosciute e quantità non intere o fuori misura.

import { totaleCents, type Catalogo, type Riga } from './carrello.ts';

// Cio' che arriva dal browser e' solo un'intenzione: variante e quantita'.
// Il totale lo ricalcola SEMPRE il server, dal proprio catalogo.
export function creaOrdine(corpoRichiesta: unknown, catalogo: Catalogo) {
  const righe = (corpoRichiesta as { righe?: Riga[] })?.righe;
  if (!Array.isArray(righe) || righe.length === 0) throw new Error('carrello vuoto');
  for (const r of righe) {
    if (!catalogo.has(r.variantId)) throw new Error(`variante sconosciuta: ${r.variantId}`);
    if (!Number.isInteger(r.quantita) || r.quantita < 1 || r.quantita > 20) throw new Error('quantita non valida');
  }
  return { righe, totaleCents: totaleCents(righe, catalogo) };
}


// --- Prova: gira solo se lanci questo file direttamente ---
if (import.meta.main) {

  const catalogo: Catalogo = new Map([['v_moka', { titolo: 'Moka 3 tazze', prezzoCents: 2400 }]]);

  // Un browser manomesso prova a pagare 1 centesimo
  const manomesso = { righe: [{ variantId: 'v_moka', quantita: 2 }], totale: 1 };
  console.log('totale accettato:', creaOrdine(manomesso, catalogo).totaleCents, 'centesimi');

  for (const corpo of [{ righe: [] }, { righe: [{ variantId: 'v_moka', quantita: -3 }] }, { righe: [{ variantId: 'v_x', quantita: 1 }] }]) {
    try { creaOrdine(corpo, catalogo); } catch (e) { console.log('rifiutato:', (e as Error).message); }
  }
}

// Output:
// totale accettato: 4800 centesimi
// rifiutato: carrello vuoto
// rifiutato: quantita non valida
// rifiutato: variante sconosciuta: v_x

Nella prova, il browser manomesso dichiara un totale di 1 e due mokette: il server ignora quel campo e accetta 4.800 centesimi (due volte 24,00 euro). I tre rifiuti mostrano le condizioni che un backend vero deve controllare sempre. Il limite di venti pezzi per riga è una scelta mia, arbitraria, per l’esempio: scegli il tuo in base al tuo negozio. Questo controllo non sostituisce quello del backend commerce, che è la fonte di verità per prezzi e scorte e che, nei tre casi della serie, calcola già il totale del checkout. Nel prossimo articolo vedremo come collegare il pagamento a questo punto con Stripe Payment Intents.

Dati strutturati: il prezzo che Google può leggere

I dati strutturati dicono ai motori di ricerca prezzo e disponibilità senza doverli dedurre dalla pagina. La documentazione di Google sul markup Product spiega che permettono di mostrare nei risultati prezzo, disponibilità e valutazioni. Il listato produce l’oggetto Product con un Offer, formatta il prezzo come stringa con il punto decimale e sceglie la disponibilità tra i due URL schema.org.

type Prodotto = { nome: string; sku: string; prezzoCents: number; valuta: string; disponibile: boolean; url: string };

export function productJsonLd(p: Prodotto) {
  return {
    '@context': 'https://schema.org',
    '@type': 'Product',
    name: p.nome,
    sku: p.sku,
    offers: {
      '@type': 'Offer',
      url: p.url,
      priceCurrency: p.valuta,
      price: (p.prezzoCents / 100).toFixed(2), // "24.00": stringa con il punto, mai la virgola
      availability: p.disponibile ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock',
    },
  };
}

// Dentro un <script> il testo "</script>" chiuderebbe il tag in anticipo: si neutralizza il "<".
export const serializza = (oggetto: unknown) => JSON.stringify(oggetto).replace(/</g, '\\u003c');

if (import.meta.main) {
  const ld = productJsonLd({ nome: 'Moka </script><b>', sku: 'MOKA-3', prezzoCents: 2400, valuta: 'EUR', disponibile: true, url: 'https://negozio.example/p/moka-3' });
  console.log(serializza(ld));
  console.log('contiene "</script>":', serializza(ld).includes('</script>'));
}

// Output:
// {"@context":"https://schema.org","@type":"Product","name":"Moka \u003c/script>\u003cb>","sku":"MOKA-3","offers":{"@type":"Offer","url":"https://negozio.example/p/moka-3","priceCurrency":"EUR","price":"24.00","availability":"https://schema.org/InStock"}}
// contiene "</script>": false

La funzione serializza esiste per un motivo di sicurezza: il JSON finisce dentro un tag <script> tramite dangerouslySetInnerHTML, e un nome di prodotto che contiene </script> chiuderebbe il tag in anticipo e permetterebbe di iniettare codice. La guida JSON-LD di Next.js raccomanda di neutralizzare il carattere < sostituendolo con \u003c, e la prova lo conferma: il nome ostile esce senza la sequenza pericolosa. Resta la regola che vale sempre: i dati strutturati devono descrivere ciò che la pagina mostra davvero. Un’offerta marcata come disponibile su una pagina che dice «esaurito» è un modo sicuro per perdere la fiducia del motore di ricerca.

Che cosa sai fare adesso

Sai decidere che cosa va sul server e che cosa nel browser, costruire una pagina prodotto con rigenerazione e 404 veri, scrivere la logica del carrello come funzioni pure senza prezzi, ricalcolare il totale lato server e produrre dati strutturati sicuri da inserire in una pagina. Ti manca il pagamento, che affrontiamo nel capitolo su Stripe e SCA, e la misura delle prestazioni, che chiude la serie con l’articolo su Core Web Vitals e SEO.

Fonti

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