Uno storefront headless non è veloce per definizione. Qui misuri come si giudicano Core Web Vitals, sistemi l’immagine che decide l’LCP, scrivi dati strutturati onesti, eviti il conflitto tra canonical e robots.txt e dividi una sitemap che supera i 50.000 indirizzi.
Headless non vuol dire veloce: vuol dire che la velocità è affar tuo
Nel primo articolo della serie avevi visto che separare frontend e backend non garantisce prestazioni migliori di un buon tema. Qui chiudiamo il cerchio: quando il frontend è tuo, ogni scelta (quale immagine caricare per prima, che cosa pre-generare, quanti script aggiungere) diventa una tua responsabilità. Il metro di giudizio è quello di Google e di web.dev: tre metriche, dette Core Web Vitals, ciascuna con una soglia. Secondo la pagina Defining the Core Web Vitals metrics thresholds, il Largest Contentful Paint (LCP, il momento in cui compare l’elemento più grande della pagina) è buono fino a 2.500 ms e scarso oltre 4.000; l’Interaction to Next Paint (INP, la reattività ai clic) è buono fino a 200 ms e scarso oltre 500; il Cumulative Layout Shift (CLS, quanto la pagina si sposta mentre carica) è buono fino a 0,1 e scarso oltre 0,25.
La stessa pagina specifica un dettaglio che cambia come si misura: si valuta il 75° percentile dei caricamenti reali, cioè il valore che il 75% dei visitatori sta sotto. Il listato qui sotto lo mostra su venti caricamenti inventati di una pagina prodotto, dove una parte dei visitatori ha una rete lenta. Il calcolo usa il metodo detto nearest rank (la posizione «75% del numero dei campioni» nell’elenco ordinato); web.dev non prescrive un metodo, e strumenti diversi possono dare valori leggermente diversi.
// Soglie di web.dev (Defining the Core Web Vitals metrics thresholds): "good" fino a, "poor" oltre.
const SOGLIE = {
LCP: { buono: 2500, scarso: 4000 }, // millisecondi
INP: { buono: 200, scarso: 500 }, // millisecondi
CLS: { buono: 0.1, scarso: 0.25 }, // senza unita'
} as const;
export function classifica(metrica: keyof typeof SOGLIE, valore: number) {
const s = SOGLIE[metrica];
return valore <= s.buono ? 'buono' : valore <= s.scarso ? 'da migliorare' : 'scarso';
}
// Percentile con il metodo "nearest rank": il valore sotto cui sta la quota p dei campioni.
export function percentile(campioni: number[], p: number) {
const ordinati = [...campioni].sort((a, b) => a - b);
return ordinati[Math.max(0, Math.ceil((p / 100) * ordinati.length) - 1)];
}
if (import.meta.main) {
// 20 caricamenti di una pagina prodotto (valori inventati per l'esempio, in ms; una parte dei visitatori ha una rete lenta)
const lcp = [900, 1000, 1000, 1100, 1100, 1200, 1200, 1300, 1400, 1500, 1500, 1600, 1700, 1800, 2700, 2800, 2900, 3100, 3300, 3500];
const media = lcp.reduce((s, v) => s + v, 0) / lcp.length;
const p75 = percentile(lcp, 75);
console.log(`media: ${Math.round(media)} ms -> ${classifica('LCP', media)}`);
console.log(`75o percentile: ${p75} ms -> ${classifica('LCP', p75)}`);
console.log(`caricamenti oltre 2500 ms: ${lcp.filter((v) => v > 2500).length} su ${lcp.length}`);
for (const [m, v] of [['INP', 180], ['INP', 350], ['CLS', 0.08], ['CLS', 0.3]] as const) console.log(`${m} ${v} -> ${classifica(m, v)}`);
}
// Output:
// media: 1830 ms -> buono
// 75o percentile: 2700 ms -> da migliorare
// caricamenti oltre 2500 ms: 6 su 20
// INP 180 -> buono
// INP 350 -> da migliorare
// CLS 0.08 -> buono
// CLS 0.3 -> scarso
L’uscita (Node 24, deterministica, dati inventati) mostra la ragione della regola. La media è 1.830 ms e direbbe «buono»; il 75° percentile è 2.700 ms e dice «da migliorare», perché sei visitatori su venti (il 30%) aspettano più di 2.500 ms.
La media nasconde chi soffre; il percentile lo rende visibile. In pratica: non ottimizzare per la tua connessione in ufficio, e per misurare i dati reali usa gli strumenti di campo (il rapporto CrUX in PageSpeed Insights, oppure la libreria web-vitals nel tuo sito) invece del solo punteggio di laboratorio.
LCP: in quattro pezzi, e quello che conta è l’immagine
La guida Optimize Largest Contentful Paint scompone il tempo in quattro parti: il tempo fino al primo byte del documento (TTFB), il ritardo prima che il browser cominci a scaricare la risorsa dell’LCP, la durata del download, il ritardo prima che l’elemento venga disegnato. Per una pagina prodotto l’elemento LCP è spesso la prima immagine, e le leve sono due: farla scoprire e scaricare subito, e non farla pesare più del necessario. Non ho una percentuale di problemi LCP dovuti alle immagini da citare con una fonte; guarda, per la tua pagina, quale elemento riporta PageSpeed Insights come LCP, invece di presumerlo.
Il listato usa il componente Image di Next.js. La documentazione (Next.js 16) segnala due fatti utili: la proprietà priority è deprecata in favore di preload, e nella maggior parte dei casi conviene usare loading="eager" o fetchPriority="high" invece di preload.
La prima immagine li riceve, le altre restano pigre; width e height riservano lo spazio, e sizes dice al browser quale versione scaricare in base alla larghezza dello schermo.
// app/prodotti/[slug]/page.tsx (estratto) - Next.js 16
import Image from 'next/image';
import type { Metadata } from 'next';
type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
return {
// Il canonical e' sempre l'URL senza ?colore=...&taglia=...: le varianti non sono pagine diverse.
alternates: { canonical: `https://negozio.example/prodotti/${slug}` },
};
}
export default function Galleria({ immagini }: { immagini: { url: string; alt: string }[] }) {
return (
<div>
{immagini.map((img, i) => (
<Image
key={img.url}
src={img.url}
alt={img.alt}
width={800}
height={800}
sizes="(min-width: 768px) 50vw, 100vw"
// Solo la prima immagine e' l'elemento LCP: le altre restano pigre.
{...(i === 0 ? { loading: 'eager', fetchPriority: 'high' } : {})}
/>
))}
</div>
);
}
Non ho eseguito questo blocco: richiede un progetto Next.js, ed è uno schema da adattare (gli indirizzi sono d’esempio). Che cosa succede se togli le dimensioni? Il browser non sa quanto spazio riservare e, quando l’immagine arriva, spinge giù il resto della pagina: è la causa di CLS che la guida Cumulative Layout Shift elenca per prima, insieme a font che cambiano misura e a widget che si ridimensionano. Per l’INP vale lo stesso principio di misura: la pagina Interaction to Next Paint lo definisce come il successore del First Input Delay; se i clic su variante e carrello sono lenti, la causa tipica è JavaScript che occupa il thread principale, e il rimedio parte dal profilo delle prestazioni del browser, non da una ricetta valida per tutti.
Dati strutturati: descrivi la pagina, non desiderarla
Nell’articolo sullo storefront hai visto l’oggetto Product con una Offer. Il caso reale ha due complicazioni: varianti con prezzi diversi e recensioni. Per le prime, schema.org prevede AggregateOffer con prezzo minimo e massimo;
per le seconde, il comportamento corretto è non scrivere nulla se non hai recensioni vere. La documentazione di Google sul markup Product mostra aggregateRating con ratingValue e reviewCount come dati che descrivono recensioni esistenti; inventarli, o mostrarli nel markup senza che compaiano nella pagina,
contraddice la regola che il markup deve riflettere il contenuto visibile.
type Variante = { prezzoCents: number; valuta: string; disponibile: boolean };
type Recensioni = { media: number; conteggio: number }; // SOLO se esistono recensioni vere, visibili nella pagina
export function productJsonLd(nome: string, url: string, varianti: Variante[], recensioni?: Recensioni) {
const valute = new Set(varianti.map((v) => v.valuta));
if (valute.size !== 1) throw new Error('valute miste: servono offerte separate');
const prezzi = varianti.map((v) => v.prezzoCents);
const eur = (c: number) => (c / 100).toFixed(2);
const disponibilita = varianti.some((v) => v.disponibile) ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock';
const [valuta] = valute;
const offers =
new Set(prezzi).size === 1
? { '@type': 'Offer', url, priceCurrency: valuta, price: eur(prezzi[0]), availability: disponibilita }
: { '@type': 'AggregateOffer', url, priceCurrency: valuta, lowPrice: eur(Math.min(...prezzi)), highPrice: eur(Math.max(...prezzi)), offerCount: varianti.length };
return {
'@context': 'https://schema.org',
'@type': 'Product',
name: nome,
offers,
// niente aggregateRating inventato: compare solo se hai dati veri
...(recensioni && recensioni.conteggio > 0
? { aggregateRating: { '@type': 'AggregateRating', ratingValue: recensioni.media, reviewCount: recensioni.conteggio } }
: {}),
};
}
if (import.meta.main) {
const url = 'https://negozio.example/prodotti/moka';
console.log(JSON.stringify(productJsonLd('Moka', url, [{ prezzoCents: 2400, valuta: 'EUR', disponibile: true }, { prezzoCents: 3200, valuta: 'EUR', disponibile: false }])));
console.log(JSON.stringify(productJsonLd('Filtro', url, [{ prezzoCents: 1250, valuta: 'EUR', disponibile: true }], { media: 4.4, conteggio: 89 })));
try { productJsonLd('X', url, [{ prezzoCents: 1, valuta: 'EUR', disponibile: true }, { prezzoCents: 1, valuta: 'USD', disponibile: true }]); } catch (e) { console.log('errore:', (e as Error).message); }
}
// Output:
// {"@context":"https://schema.org","@type":"Product","name":"Moka","offers":{"@type":"AggregateOffer","url":"https://negozio.example/prodotti/moka","priceCurrency":"EUR","lowPrice":"24.00","highPrice":"32.00","offerCount":2}}
// {"@context":"https://schema.org","@type":"Product","name":"Filtro","offers":{"@type":"Offer","url":"https://negozio.example/prodotti/moka","priceCurrency":"EUR","price":"12.50","availability":"https://schema.org/InStock"},"aggregateRating":{"@type":"AggregateRating","ratingValue":4.4,"reviewCount":89}}
// errore: valute miste: servono offerte separate
Nel listato, un prodotto con due varianti (24 e 32 euro) produce un AggregateOffer da 24,00 a 32,00 e non ha aggregateRating; il secondo, con 89 recensioni vere passate come dato, lo include; il terzo caso mostra una scelta di progetto: con valute miste la funzione si rifiuta di aggregare, perché un minimo tra euro e dollari non ha senso.
Il listato non scrive priceValidUntil: il campo compare negli esempi di Google, ma una data messa a ogni pagina come «trenta giorni da oggi», senza sapere quando il prezzo cambierà, sarebbe un’informazione inventata. Convalida sempre il risultato con il Rich Results Test di Google prima del rilascio.
Canonical e robots.txt: due strumenti che non vanno mescolati
Le varianti di un prodotto generano indirizzi come ?colore=blu&taglia=m che mostrano la stessa pagina. La tentazione è bloccarli in robots.txt. La documentazione di Google sul consolidamento degli URL duplicati dice espressamente di non usare il file robots.txt per la canonicalizzazione:
un indirizzo bloccato non viene letto, quindi Google non vede nemmeno l’indicazione di canonical che lo ricondurrebbe alla pagina principale. La strada corretta è la proprietà alternates.canonical mostrata nel listato precedente, che indica per ogni variante l’indirizzo senza parametri.
Secondo la stessa pagina il canonical è un segnale forte, non l’unico: reindirizzamenti e inclusione nella sitemap si sommano, e quest’ultima è un segnale debole. Conviene quindi che anche i link interni e la sitemap indichino l’indirizzo principale.
Sitemap: dividerle, e dire la verità sulle date
La pagina Google Build and submit a sitemap dice che un file di sitemap può contenere al massimo 50.000 indirizzi o 50 MB non compressi, e che oltre questo limite devi dividere in più file con un indice.
Aggiunge due fatti che si dimenticano spesso: i valori nei tag devono avere le entità XML escapate (il & di un parametro diventa &), e Google ignora <priority> e <changefreq>; usa <lastmod> solo se è costantemente verificabile, cioè se riflette l’ultima modifica significativa della pagina.
Mettere come lastmod la data di oggi su ogni indirizzo, a ogni generazione, è il modo per far perdere valore al campo.
const MAX_URL = 50_000; // limite per file, dalla documentazione di Google (e 50 MB non compressi)
const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
export type Voce = { loc: string; lastmod: string }; // lastmod: data ISO dell'ultima modifica significativa
export function dividi<T>(voci: readonly T[], dimensione = MAX_URL): T[][] {
const gruppi: T[][] = [];
for (let i = 0; i < voci.length; i += dimensione) gruppi.push(voci.slice(i, i + dimensione));
return gruppi;
}
export const urlset = (voci: readonly Voce[]) =>
`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n` +
voci.map((v) => ` <url><loc>${esc(v.loc)}</loc><lastmod>${v.lastmod}</lastmod></url>`).join('\n') +
`\n</urlset>`;
export const indice = (base: string, n: number) =>
`<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n` +
Array.from({ length: n }, (_, i) => ` <sitemap><loc>${esc(`${base}/sitemap-prodotti-${i + 1}.xml`)}</loc></sitemap>`).join('\n') +
`\n</sitemapindex>`;
if (import.meta.main) {
const prodotti: Voce[] = Array.from({ length: 120_000 }, (_, i) => ({
loc: `https://negozio.example/prodotti/articolo-${i}`,
lastmod: '2026-11-12',
}));
prodotti[0].loc = 'https://negozio.example/prodotti/tazza?colore=blu&taglia=m'; // un "&" va scritto come &
const file = dividi(prodotti);
console.log('prodotti:', prodotti.length, '-> file necessari:', file.length, '- voci per file:', file.map((f) => f.length).join(', '));
console.log(urlset(file[0]).split('\n')[2]);
console.log(indice('https://negozio.example', file.length));
}
// Output:
// prodotti: 120000 -> file necessari: 3 - voci per file: 50000, 50000, 20000
// <url><loc>https://negozio.example/prodotti/tazza?colore=blu&taglia=m</loc><lastmod>2026-11-12</lastmod></url>
// <?xml version="1.0" encoding="UTF-8"?>
// <sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
// <sitemap><loc>https://negozio.example/sitemap-prodotti-1.xml</loc></sitemap>
// <sitemap><loc>https://negozio.example/sitemap-prodotti-2.xml</loc></sitemap>
// <sitemap><loc>https://negozio.example/sitemap-prodotti-3.xml</loc></sitemap>
// </sitemapindex>
Il listato divide 120.000 indirizzi (inventati) in tre file da 50.000, 50.000 e 20.000, ed è stato eseguito con Node 24. La riga che stampa il primo indirizzo mostra l’escape: blu&taglia nell’XML. Il limite è deliberatamente scritto come costante, perché è l’unico numero che il file non deve superare.
Se usi Next.js non serve scrivere l’XML a mano: la funzione generateSitemaps restituisce gli identificativi dei file e il tuo sitemap li riempie a blocchi, con gli indirizzi disponibili come /prodotti/sitemap/[id].xml; il listato serve a capire che cosa c’è sotto e dove si sbaglia.
Che cosa sai fare adesso, e dove si chiude la serie
Sai giudicare una pagina con le soglie di web.dev e con il 75° percentile, rendere veloce e stabile l’immagine che decide l’LCP, scrivere dati strutturati che dicono il vero, tenere distinti canonical e robots.txt e dividere una sitemap rispettando limiti e codifica. La serie ti ha portato dalla scelta dell’architettura (che cos’è headless e MACH) al backend (Shopify, Medusa, Saleor), alla vetrina (Next.js), ai pagamenti e ai contenuti. Il passo successivo non è un altro strumento: è misurare il tuo negozio e scegliere una sola cosa da correggere.
Fonti
- Defining the Core Web Vitals metrics thresholds (web.dev) - soglie di LCP, INP e CLS, valutazione al 75° percentile; consultata il 2026-11-12
- Optimize Largest Contentful Paint (web.dev) - le quattro parti del tempo LCP; consultata il 2026-11-12
- Cumulative Layout Shift (web.dev) - cause tipiche dello spostamento di layout; consultata il 2026-11-12
- Interaction to Next Paint (web.dev) - definizione di INP e rapporto con FID; consultata il 2026-11-12
- Next.js: Image component - preload, priority deprecata, loading e fetchPriority (v16); consultata il 2026-11-12
- Google Search Central: Product snippet structured data - esempi di Offer e aggregateRating; consultata il 2026-11-12
- Google Search Central: Consolidate duplicate URLs - indicazione di non usare robots.txt per la canonicalizzazione; consultata il 2026-11-12
- Google Search Central: Build and submit a sitemap - limiti di 50.000 URL e 50 MB, escape, priority e changefreq ignorati, lastmod; consultata il 2026-11-12
- Next.js: generateSitemaps - sitemap multiple a blocchi; consultata il 2026-11-12

Commenti
Caricamento commenti...
Accedi per commentare