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.
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
- Next.js: How to implement ISR - revalidate, generateStaticParams, comportamento stale, rivalidazione su richiesta (v16.4.6); consultata il 2026-11-12
- Next.js: Server and Client Components - quando usare gli uni e gli altri, direttiva use client; consultata il 2026-11-12
- Next.js: JSON-LD - iniezione del JSON-LD e neutralizzazione del carattere <; consultata il 2026-11-12
- Google Search Central: Product structured data - prezzo e disponibilità nei risultati; consultata il 2026-11-12

Commenti
Caricamento commenti...
Accedi per commentare