A headless storefront is not fast by definition. Here you measure how Core Web Vitals are judged, fix the image that decides the LCP, write honest structured data, avoid the conflict between canonical and robots.txt, and split a sitemap that exceeds 50,000 addresses.
Headless does not mean fast: it means speed is your business
In the first article of the series you saw that separating frontend and backend does not guarantee better performance than a good theme. Here we close the circle: when the frontend is yours, every choice (which image to load first, what to pre-generate, how many scripts to add) becomes your responsibility. The yardstick is the one used by Google and web.dev: three metrics, called Core Web Vitals, each with a threshold. According to the page Defining the Core Web Vitals metrics thresholds, Largest Contentful Paint (LCP, the moment the largest element on the page appears) is good up to 2,500 ms and poor beyond 4,000; Interaction to Next Paint (INP, responsiveness to clicks) is good up to 200 ms and poor beyond 500; Cumulative Layout Shift (CLS, how much the page moves while loading) is good up to 0.1 and poor beyond 0.25.
The same page specifies a detail that changes how you measure: the 75th percentile of real page loads is assessed, that is the value that 75% of visitors fall under. The listing below shows it on twenty invented loads of a product page, where some visitors have a slow network. The calculation uses the method called nearest rank (the position “75% of the number of samples” in the sorted list); web.dev does not prescribe a method, and different tools may give slightly different values.
// web.dev thresholds (Defining the Core Web Vitals metrics thresholds): "good" up to, "poor" above.
const SOGLIE = {
LCP: { buono: 2500, scarso: 4000 }, // milliseconds
INP: { buono: 200, scarso: 500 }, // milliseconds
CLS: { buono: 0.1, scarso: 0.25 }, // unitless
} as const;
export function classifica(metrica: keyof typeof SOGLIE, valore: number) {
const s = SOGLIE[metrica];
return valore <= s.buono ? 'good' : valore <= s.scarso ? 'needs improvement' : 'poor';
}
// Percentile with the "nearest rank" method: the value below which a share p of the samples lies.
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 loads of a product page (values invented for the example, in ms; some visitors have a slow network)
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(`mean: ${Math.round(media)} ms -> ${classifica('LCP', media)}`);
console.log(`75th percentile: ${p75} ms -> ${classifica('LCP', p75)}`);
console.log(`loads over 2500 ms: ${lcp.filter((v) => v > 2500).length} of ${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:
// mean: 1830 ms -> good
// 75th percentile: 2700 ms -> needs improvement
// loads over 2500 ms: 6 of 20
// INP 180 -> good
// INP 350 -> needs improvement
// CLS 0.08 -> good
// CLS 0.3 -> poor
The output (Node 24, deterministic, invented data) shows the reason for the rule. The mean is 1,830 ms and would say “good”; the 75th percentile is 2,700 ms and says “needs improvement”, because six visitors out of twenty (30%) wait more than 2,500 ms.
The mean hides who suffers; the percentile makes it visible. In practice: do not optimise for your office connection, and to measure real data use field tools (the CrUX report in PageSpeed Insights, or the web-vitals library on your site) instead of the lab score alone.
LCP: in four pieces, and what matters is the image
The guide Optimize Largest Contentful Paint breaks the time into four parts: the time to the first byte of the document (TTFB), the delay before the browser starts downloading the LCP resource, the duration of the download, the delay before the element is rendered. For a product page the LCP element is often the first image, and there are two levers: have it discovered and downloaded at once, and do not let it weigh more than necessary. I have no percentage of LCP problems due to images to cite with a source; look, for your page, at which element PageSpeed Insights reports as LCP, instead of assuming it.
The listing uses the Next.js Image component. The documentation (Next.js 16) points out two useful facts: the priority property is deprecated in favour of preload, and in most cases it is better to use loading="eager" or fetchPriority="high" instead of preload.
The first image gets them, the others stay lazy; width and height reserve the space, and sizes tells the browser which version to download based on the screen width.
// app/prodotti/[slug]/page.tsx (excerpt) - 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 {
// The canonical is always the URL without ?colore=...&taglia=...: variants are not different pages.
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"
// Only the first image is the LCP element: the others stay lazy.
{...(i === 0 ? { loading: 'eager', fetchPriority: 'high' } : {})}
/>
))}
</div>
);
}
I did not run this block: it requires a Next.js project, and it is a schema to adapt (the addresses are examples). What happens if you remove the dimensions? The browser does not know how much space to reserve and, when the image arrives, it pushes the rest of the page down: it is the cause of CLS that the guide Cumulative Layout Shift lists first, together with fonts that change size and widgets that resize. For INP the same measurement principle applies: the page Interaction to Next Paint defines it as the successor to First Input Delay; if clicks on variant and cart are slow, the typical cause is JavaScript occupying the main thread, and the remedy starts from the browser’s performance profile, not from a recipe valid for everyone.
Structured data: describe the page, do not wish for it
In the article on the storefront you saw the Product object with an Offer. The real case has two complications: variants with different prices and reviews. For the former, schema.org provides AggregateOffer with minimum and maximum price;
for the latter, the correct behaviour is to write nothing if you have no real reviews. The Google documentation on Product markup shows aggregateRating with ratingValue and reviewCount as data describing existing reviews; inventing them, or showing them in the markup without their appearing on the page,
contradicts the rule that markup must reflect the visible content.
type Variante = { prezzoCents: number; valuta: string; disponibile: boolean };
type Recensioni = { media: number; conteggio: number }; // ONLY if real reviews exist, visible on the page
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('mixed currencies: separate offers needed');
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,
// no invented aggregateRating: it appears only if you have real data
...(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('Filter', 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('error:', (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":"Filter","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}}
// error: mixed currencies: separate offers needed
In the listing, a product with two variants (24 and 32 euros) produces an AggregateOffer from 24.00 to 32.00 and has no aggregateRating; the second, with 89 real reviews passed as data, includes it; the third case shows a design choice: with mixed currencies the function refuses to aggregate, because a minimum between euros and dollars makes no sense.
The listing does not write priceValidUntil: the field appears in Google’s examples, but a date put on every page as “thirty days from today”, without knowing when the price will change, would be invented information. Always validate the result with Google’s Rich Results Test before release.
Canonical and robots.txt: two tools that must not be mixed
A product’s variants generate addresses such as ?colore=blu&taglia=m that show the same page. The temptation is to block them in robots.txt. Google’s documentation on consolidating duplicate URLs expressly says not to use the robots.txt file for canonicalization:
a blocked address is not read, so Google does not even see the canonical indication that would bring it back to the main page. The correct way is the alternates.canonical property shown in the previous listing, which indicates for each variant the address without parameters.
According to the same page the canonical is a strong signal, not the only one: redirects and inclusion in the sitemap add up, and the latter is a weak signal. It is therefore advisable that internal links and the sitemap also point to the main address.
Sitemaps: splitting them, and telling the truth about dates
The Google page Build and submit a sitemap says that a sitemap file can contain at most 50,000 addresses or 50 MB uncompressed, and that beyond this limit you must split into several files with an index.
It adds two facts that are often forgotten: the values in the tags must have XML entities escaped (the & of a parameter becomes &), and Google ignores <priority> and <changefreq>; it uses <lastmod> only if it is consistently verifiable, that is if it reflects the page’s last significant change.
Putting today’s date as lastmod on every address, at every generation, is the way to make the field lose value.
const MAX_URL = 50_000; // per-file limit, from Google's documentation (and 50 MB uncompressed)
const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
export type Voce = { loc: string; lastmod: string }; // lastmod: ISO date of the last significant change
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'; // an "&" must be written as &
const file = dividi(prodotti);
console.log('products:', prodotti.length, '-> files needed:', file.length, '- entries 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:
// products: 120000 -> files needed: 3 - entries 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>
The listing splits 120,000 (invented) addresses into three files of 50,000, 50,000 and 20,000, and was run with Node 24. The line that prints the first address shows the escaping: blu&taglia in the XML. The limit is deliberately written as a constant, because it is the only number the file must not exceed.
If you use Next.js you do not need to write the XML by hand: the generateSitemaps function returns the file identifiers and your sitemap fills them in blocks, with the addresses available as /prodotti/sitemap/[id].xml; the listing serves to understand what lies underneath and where mistakes happen.
What you can do now, and where the series ends
You can judge a page with the web.dev thresholds and the 75th percentile, make the image that decides the LCP fast and stable, write structured data that tells the truth, keep canonical and robots.txt distinct, and split a sitemap respecting limits and encoding. The series has taken you from the choice of architecture (what headless is and MACH) to the backend (Shopify, Medusa, Saleor), to the storefront (Next.js), to payments and to content. The next step is not another tool: it is measuring your store and choosing one single thing to fix.
Sources
- Defining the Core Web Vitals metrics thresholds (web.dev) - LCP, INP and CLS thresholds, assessment at the 75th percentile; consulted on 2026-11-12
- Optimize Largest Contentful Paint (web.dev) - the four parts of LCP time; consulted on 2026-11-12
- Cumulative Layout Shift (web.dev) - typical causes of layout shift; consulted on 2026-11-12
- Interaction to Next Paint (web.dev) - definition of INP and relationship with FID; consulted on 2026-11-12
- Next.js: Image component - preload, deprecated priority, loading and fetchPriority (v16); consulted on 2026-11-12
- Google Search Central: Product snippet structured data - Offer and aggregateRating examples; consulted on 2026-11-12
- Google Search Central: Consolidate duplicate URLs - advice not to use robots.txt for canonicalization; consulted on 2026-11-12
- Google Search Central: Build and submit a sitemap - limits of 50,000 URLs and 50 MB, escaping, priority and changefreq ignored, lastmod; consulted on 2026-11-12
- Next.js: generateSitemaps - multiple sitemaps in blocks; consulted on 2026-11-12
Commenti
Caricamento commenti...
Sign in to comment