A storefront built with the Next.js App Router decides what is generated on the server and what runs in the browser. Here you build the catalogue, cart and order logic so that the price cannot be tampered with and product pages stay fast and indexable.
The boundary that decides everything: server and browser
A storefront that talks to a headless backend must answer one question before any code is written: what does the browser need and what does it not? In the Next.js App Router, the
documentation on server and client components settles it like this: Server Components fetch data, produce HTML on the server and can be cached;
Client Components are needed when there is interactivity or browser APIs are used, and are declared with the 'use client' directive.
For a store the split draws itself: the product page (title, price, description, structured data) is content to be indexed and goes on the server; the “add to cart” button and the side cart are interaction and go in the browser.
Here the backend is deliberately generic: in the previous chapters you saw three (Shopify, Medusa, Saleor)
and all of them expose products, variants and a way to order. The practical advice is to isolate the calls in a single module, an adapter that exports functions such as getProductBySlug, so the rest of the application does not know which backend it is using.
The cost of this choice is one more layer to write and test; the advantage is that changing vendor means rewriting one file, not fifty components. If the texts and images of the pages come from a CMS, the article on content commerce shows how to put it alongside the same adapter.
The product page: ISR, real 404s and metadata
A product page rarely changes and is visited often: the ideal case for incremental static regeneration (ISR). According to the Next.js guide
(version 16.4.6, consulted on 2026-11-12, in the variant without Cache Components) it is enough to export revalidate and generateStaticParams: the pages listed are generated at build time; after the stated time the first request still receives the old copy,
while the new one is prepared in the background; the next request gets the new one. It is the same stale-while-revalidate mechanism seen in the article on Hydrogen, applied to the whole page.
// app/products/[slug]/page.tsx - Server Component with ISR (Next.js without Cache Components)
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { getProductBySlug, listSlugs } from '@/lib/commerce'; // YOUR adapter towards the backend
import { productJsonLd, serializza } from '@/lib/json-ld';
import { AddToCartButton } from '@/components/AddToCartButton'; // Client Component ('use client')
export const revalidate = 3600; // after one hour the next visit starts the regeneration
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: 'Product not found' };
}
export default async function ProductPage({ params }: Props) {
const { slug } = await params;
const p = await getProductBySlug(slug);
if (!p) notFound(); // a real 404, not a "not found" page with status 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>
);
}
This block was not run: it requires a Next.js project and a backend, and @/lib/commerce is the adapter I was talking about, which you have to write yourself; it is a schema to adapt. There are three lines to look at.
With revalidate = 3600 a price changed in the backend can stay old for up to an hour plus the regeneration time: the guide recommends long times and, when precision is needed, on-demand revalidation with revalidatePath.
The notFound() call produces a real 404: a “product not found” page with status 200 would be indexed as duplicate content. Finally params is a promise to be awaited, as in the guide’s example.
The cart: little logic, all pure, no prices
The cart is the state that most tutorials make more complicated than necessary. Its logic is small: add a variant (summing if it is already there), set the quantity (zero removes it) and compute a total. Written as pure functions that return a new array without touching the one received, it can be tested without React and then connected to any store. The listing below is that logic with a test at the bottom, run with Node 24.
export type Riga = { variantId: string; quantita: number };
// Pure functions: they never modify the array they receive, they return a new one.
export function aggiungi(righe: readonly Riga[], variantId: string, quantita = 1): Riga[] {
if (!Number.isInteger(quantita) || quantita < 1) throw new Error('invalid quantity');
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));
}
// Prices are NOT in the cart: they are read from the catalogue (in whole cents) when needed.
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(`unknown variant: ${r.variantId}`);
return somma + v.prezzoCents * r.quantita;
}, 0);
}
// --- Test: runs only if you launch this file directly ---
if (import.meta.main) {
const catalogo: Catalogo = new Map([
['v_moka', { titolo: 'Moka pot 3 cups', 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'); // same variant: quantities add up, the line is not duplicated
const d = impostaQuantita(c, 'v_filtro', 0); // quantity 0: the line disappears
console.log('empty is still empty:', vuoto.length === 0);
console.log('lines after c:', JSON.stringify(c));
console.log('total c:', totaleCents(c, catalogo) / 100, 'EUR');
console.log('lines after d:', JSON.stringify(d));
try { aggiungi(d, 'v_moka', 0); } catch (e) { console.log('expected error:', (e as Error).message); }
}
// Output:
// empty is still empty: true
// lines after c: [{"variantId":"v_moka","quantita":2},{"variantId":"v_filtro","quantita":2}]
// total c: 73 EUR
// lines after d: [{"variantId":"v_moka","quantita":2}]
// expected error: invalid quantity
Two choices matter more than the rest. The first: the cart contains only variantId and quantity, not the price. If you save the price in the cart, you keep it even when it changes in the catalogue, and the customer sees a total that no longer exists;
this way instead the price is read from the catalogue every time it is needed (totaleCents). The second: amounts are integers in cents, because adding floating-point decimals produces errors such as 0.1 + 0.2. The output shows that the empty cart stays empty
after the operations (no in-place modification), that the same variant adds up and that an invalid quantity is rejected.
Connecting these functions to a store takes little code. The following listing uses Zustand with the persist middleware, which saves the state in the browser’s localStorage; I did not run it because Zustand is not installed here. The limit to know: localStorage
exists only in the browser, so the server does not know what is in the cart and cannot show it in the first HTML; that is why Shopify and Saleor keep the cart on the server and your storefront keeps only its identifier.
A local cart is fine as long as the checkout restarts from the server’s data, as you see in the next section.
// lib/cart-store.ts - the cart as a Zustand store that reuses the pure functions
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: 'cart', storage: createJSONStorage(() => localStorage) },
),
);
The order: the server does not trust the browser
The most common version of this flow sends the server the cart and the total calculated in the browser. It is a fundamental mistake: anyone who opens the developer tools can change the total to one cent and resend the request.
The server must treat what it receives as an intention (which variants, how many) and recalculate the rest from its own catalogue. The listing reuses totaleCents and rejects empty carts, unknown variants and quantities that are not integers or are out of range.
import { totaleCents, type Catalogo, type Riga } from './carrello.ts';
// What comes from the browser is only an intention: variant and quantity.
// The total is ALWAYS recalculated by the server, from its own catalogue.
export function creaOrdine(corpoRichiesta: unknown, catalogo: Catalogo) {
const righe = (corpoRichiesta as { righe?: Riga[] })?.righe;
if (!Array.isArray(righe) || righe.length === 0) throw new Error('empty cart');
for (const r of righe) {
if (!catalogo.has(r.variantId)) throw new Error(`unknown variant: ${r.variantId}`);
if (!Number.isInteger(r.quantita) || r.quantita < 1 || r.quantita > 20) throw new Error('invalid quantity');
}
return { righe, totaleCents: totaleCents(righe, catalogo) };
}
// --- Test: runs only if you launch this file directly ---
if (import.meta.main) {
const catalogo: Catalogo = new Map([['v_moka', { titolo: 'Moka pot 3 cups', prezzoCents: 2400 }]]);
// A tampered browser tries to pay 1 cent
const manomesso = { righe: [{ variantId: 'v_moka', quantita: 2 }], totale: 1 };
console.log('accepted total:', creaOrdine(manomesso, catalogo).totaleCents, 'cents');
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('rejected:', (e as Error).message); }
}
}
// Output:
// accepted total: 4800 cents
// rejected: empty cart
// rejected: invalid quantity
// rejected: unknown variant: v_x
In the test, the tampered browser declares a total of 1 and two moka pots: the server ignores that field and accepts 4,800 cents (two times 24.00 euros). The three rejections show the conditions that a real backend must always check. The limit of twenty pieces per line is my own arbitrary choice, for the example: pick yours according to your store. This check does not replace the one in the commerce backend, which is the source of truth for prices and stock and which, in the three cases of the series, already calculates the checkout total. In the next article we will see how to connect payment at this point with Stripe Payment Intents.
Structured data: the price Google can read
Structured data tells search engines price and availability without their having to deduce them from the page. The Google documentation on Product markup explains that it makes it possible to show price, availability and ratings in the results.
The listing produces the Product object with an Offer, formats the price as a string with the decimal point and chooses availability between the two schema.org URLs.
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": a string with the dot, never the comma
availability: p.disponibile ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock',
},
};
}
// Inside a <script> the text "</script>" would close the tag early: the "<" is neutralised.
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('contains "</script>":', serializza(ld).includes('</script>'));
}
// Output:
// {"@context":"https://schema.org","@type":"Product","name":"Moka
The serializza function exists for a security reason: the JSON ends up inside a <script> tag via dangerouslySetInnerHTML, and a product name containing </script> would close the tag early and allow code to be injected.
The Next.js JSON-LD guide recommends neutralising the < character by replacing it with <, and the test confirms it: the hostile name comes out without the dangerous sequence. The rule that always holds remains: structured data must describe what the page really shows.
An offer marked as available on a page that says “sold out” is a sure way to lose the search engine’s trust.
What you can do now
You can decide what goes on the server and what in the browser, build a product page with real regeneration and real 404s, write the cart logic as pure functions without prices, recompute the total on the server and produce structured data that is safe to insert into a page.
What you are missing is payment, which we tackle in the chapter on Stripe and SCA, and performance measurement, which closes the series with the article on
Core Web Vitals and SEO.
Sources
- Next.js: How to implement ISR - revalidate, generateStaticParams, stale behaviour, on-demand revalidation (v16.4.6); consulted on 2026-11-12
- Next.js: Server and Client Components - when to use each, the use client directive; consulted on 2026-11-12
- Next.js: JSON-LD - JSON-LD injection and neutralisation of the < character; consulted on 2026-11-12
- Google Search Central: Product structured data - price and availability in results; consulted on 2026-11-12
Commenti
Caricamento commenti...
Sign in to comment