On a monolithic e-commerce platform, a good share of SEO ships with the theme: tags, sitemap, redirects. On a headless storefront none of it exists by default. What Google sees is exactly the HTML our Nuxt server renders, nothing more, nothing less. If a piece of information is not in there, it does not exist.

We knew it when we launched the new site on 23 February. So we treated SEO as a feature in its own right, delivered in small increments over six months: nine storefronts, five languages, tens of thousands of product URLs and images. No big bang, a hundred precise decisions. Here are the ones that matter.

The principle: every artefact is born in one place

Before writing a line, we set an architecture rule. Every SEO element has a single owner, as close as possible to its source:

  • data (category copy, attributes, composition) lives in the PIM and Magento;
  • markup (JSON-LD, hreflang, canonical, meta robots) is produced by the Nuxt server render, from data indexed in Elasticsearch and served by Helios, our catalogue API;
  • redirects happen as early as possible: in the Nuxt server when they depend on the catalogue, at the edge on CloudFront when they do not;
  • sitemaps are generated by Magento, which knows the full catalogue, and published to S3.
SEO pipeline: the PIM feeds Magento; Magento indexes the catalogue, served by Helios to the Nuxt render, which produces markup and redirects; CloudFront adds its own redirects and serves pages to bots; Magento also generates the sitemaps, published to S3 and served by CloudFront PIM copy, attributes Magento catalogue, SEO Helios catalogue index Nuxt SSR markup, 301s CloudFront redirects Bots Google, LLMs Sitemaps pages and images published to S3
Every SEO artefact is born in one place: data in Magento, markup in the Nuxt render, redirects as close to the edge as possible.

This rule prevents the worst habit of headless SEO: the same information computed in three places, ending up saying three different things.

URLs first: one address per page

A search engine that finds two URLs for the same content has to pick one, and it rarely picks the one you want. Our first job was therefore to guarantee that a page has a single address, and that every other one leads there through a permanent redirect.

Case Handling Where
Legacy VSF category URL, without /c/ 301 to the new URL Nuxt server
Product page with a wrong slug 301 to the canonical slug Nuxt server
Unknown SKU a real 404, no more empty page served as 200 Nuxt server
Double slashes (/eu/fr//c/...) 301 to the normalised URL Nitro middleware
www. to the bare domain 302 turned into 301 CloudFront Function
Residual paths from the old site targeted 301s CloudFront Function

Slash normalisation takes a few lines, and it is the kind of detail that matters on a site whose links come from everywhere (newsletters, social networks, partners):

export default defineEventHandler((event) => {
  const url = event.node.req.url
  if (!url) return

  const qIndex = url.indexOf('?')
  const path = qIndex === -1 ? url : url.slice(0, qIndex)
  const queryString = qIndex === -1 ? '' : url.slice(qIndex)
  const normalized = path.replace(/\/{2,}/g, '/')

  if (normalized === path) return
  return sendRedirect(event, queryString ? `${normalized}${queryString}` : normalized, 301)
})

The www redirect shows what you gain by rereading what already exists: it worked, but as a 302. A temporary redirect tells the engine that the old address will come back. One digit changed in April, and the authority of links pointing to www finally consolidates on the main domain.

Paginated product listing pages have been excluded from the index (noindex) since March, like the checkout: they bring nothing in search and dilute crawling.

hreflang and canonical across nine storefronts

Zadig&Voltaire serves nine storefronts from a single domain: France, United Kingdom, Germany, Spain, Italy, Belgium, Switzerland, rest of the world and United States. Several share English. Without guidance, Google may serve the British page to a Swiss visitor, or treat the English pages as duplicates.

Every page therefore declares its alternates, with a language and country code when several markets share a language:

<link rel="alternate" hreflang="fr-fr" href="https://zadig-et-voltaire.com/eu/fr/p/KWCO01541257/manteau-long-en-laine-et-cachemire-camel-femme">
<link rel="alternate" hreflang="en-gb" href="https://zadig-et-voltaire.com/eu/uk/p/KWCO01541257/womens-long-wool-and-cashmere-camel-coat">
<link rel="alternate" hreflang="en-ch" href="https://zadig-et-voltaire.com/eu/ch/p/KWCO01541257/womens-long-wool-and-cashmere-camel-coat">
<link rel="alternate" hreflang="de" href="https://zadig-et-voltaire.com/eu/de/p/KWCO01541257/langer-damenmantel-aus-wolle-und-kaschmir-in-camel">
<link rel="alternate" hreflang="x-default" href="https://zadig-et-voltaire.com/">

It all comes down to the special cases. Category URLs differ from one country to another, so a listing page only declares the alternates whose address we actually know. FAQ answers have no guaranteed equivalent across languages: they declare none rather than a wrong one. A wrong alternate is worse than a missing one.

In January, before going live, we had added canonicals to every page that lacked one (home, FAQ, content, contact) along with Open Graph and Twitter Card tags, so that every shared link shows the right image and title.

Structured data, page by page

JSON-LD is the most visible part of the work, the one that earns rich results: price, availability, breadcrumbs, shipping. We built it with Nuxt’s Schema.org module, page by page.

The product page: a group of variants

A coat available in two sizes is not one product, it is a group of products. The page declares a ProductGroup with its variants, each with its own SKU, GTIN, price and availability. Composition and care instructions, coming from the PIM, are exposed as additionalProperty.

The listing page: a collection holding real products

The July project. Our category pages already declared a CollectionPage, but each product was a bare URL, and the announced item count stayed frozen whatever the actual pagination. We embedded real Product objects, with image, price and availability, matching the page actually rendered. Here is what the women’s jackets page renders today, first item of the list:

{
  "@type": "ListItem",
  "position": 1,
  "item": {
    "@type": "Product",
    "name": "Veste Vallo",
    "url": "https://zadig-et-voltaire.com/eu/fr/p/WWBL01989011/veste-blazer-militaire-noire-femme-col-officier",
    "sku": "WWBL01989011",
    "brand": { "@type": "Brand", "name": "Zadig&Voltaire" },
    "offers": {
      "@type": "Offer",
      "price": 495,
      "priceCurrency": "EUR",
      "availability": "https://schema.org/InStock",
      "shippingDetails": { "@id": "https://zadig-et-voltaire.com/eu/fr/c/jackets-coats-for-women-121#shipping" },
      "hasMerchantReturnPolicy": { "@id": "https://zadig-et-voltaire.com/eu/fr/c/jackets-coats-for-women-121#return-policy" }
    }
  }
}

Two details make this markup good. First, shipping and return conditions are declared once in the page, then referenced by @id in every offer. On a page of forty products, that is forty duplicated blocks fewer in the HTML. Second, no offer without a firm price: an open-amount gift card has no price, and declaring 0 would be false data that Google would eventually penalise.

// No offers without a firm price (e.g. open-amount gift cards): a 0 price would be false data
...(prices.price && {
  offers: {
    '@type': 'Offer',
    'price': prices.price,
    'priceCurrency': prices.currency || 'EUR',
    'availability': isProductInStock(product) ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock',
    'shippingDetails': { '@id': `${pageUrl}#shipping` },
    'hasMerchantReturnPolicy': { '@id': `${pageUrl}#return-policy` },
  },
}),

The same project fixed a quiet defect on product pages: delivery times declared unitCode: 'd'. The expected standard is the UN/CEFACT code DAY. A silent typo, fixed in passing on both page types.

The other pages

  • Commitment (CSR) pages declare an Article, with semantic headings. Their publication and update dates come from the git history of the content, not from a hand-typed date nobody would ever update.
  • Material care pages have had their own markup since July.
  • The contact page declares a ContactPage with the customer service phone and opening hours, read from the editorial configuration rather than hard-coded.
  • Archived products stay indexable. A piece no longer on sale is still searched for, notably by second-hand shoppers. We keep the page, with a Product and no offer: the truth, and nothing else.

The menu Google could not see

In May, comparing the HTML rendered by the old and the new site, we put our finger on a blind spot. Vue Storefront exposed 88 category links in the server HTML of the home page. The new site exposed 8. Our mega-menu, which opens up the whole catalogue tree, only mounts when opened: until someone clicks, its links do not exist. An engine that does not run JavaScript no longer saw deep categories from the home page, and one that does runs it days late.

The fix: a list of links rendered in the server HTML, collapsed on screen and removed from the accessibility tree, following the same hierarchy, since the real menu remains the one visitors use. Same links, same labels, no page hidden from humans.

The trap was elsewhere. The first version shared its data key with the interactive menu. As a result, the navigation data went into the SSR payload, that payload was cached by CloudFront, and the interactive menu read stale data until the next manual cache flush. The component now uses its own key:

// Dedicated SSR key: sharing navigationKey with useNavigation() would dump the
// navigation into the SSR payload, which CloudFront caches with the page
const { data: navigationData } = await useAsyncData(
  () => `seo-${navigationKey.value}`,
  fetchNavigationData,
)

This is the kind of bug no unit test catches: it only exists where rendering, hydration and edge caching meet.

Sitemaps at catalogue scale

The sitemap protocol sets a hard limit: 50,000 URLs per file. Our image sitemap holds one entry per product visual, across nine storefronts. The old generator simply stopped at the cap: beyond it, the tail of the catalogue vanished without a message.

The generator, reworked in April in Magento, now splits automatically into numbered files and publishes an index pointing to each of them. Search Console only has one URL to follow, whatever the size of the catalogue. On failure, it deletes every partial file: better the old complete sitemap than a new one that is valid but truncated. A second iteration enriched it with modification dates, the different storefronts and packshots.

The main index, served at the root by CloudFront from S3, lists one page sitemap per storefront plus the image sitemap.

Housekeeping at the edge

One last finding, in May, in the access logs. Browsers that had visited the old site still had Vue Storefront’s service worker installed, and it regularly checks for its own update. Our new Nuxt server answered with a 404, rendering a full error page of about 70 KB each time. Measured over one hour of production: 14,612 requests, all 404s, 88% of them from Chrome.

The catch: a browser does not uninstall a service worker that gets a 404. It retries, forever. The only way to reach those browsers is through the URL they ask for. So we answered them, right at the edge, with a CloudFront Function serving a service worker whose only job is to uninstall itself:

var SW_BODY = "self.addEventListener('install',function(){self.skipWaiting();});" +
  "self.addEventListener('activate',function(e){" +
  "e.waitUntil(self.registration.unregister().then(function(){return self.clients.matchAll();})" +
  ".then(function(cs){cs.forEach(function(c){if(c.url&&'navigate' in c){c.navigate(c.url);}});}));" +
  "});";

The browser receives it once, unregisters, reloads the page, and never asks for the file again. Those requests no longer reach the cluster.

The edge is also where llms.txt is served, the manifest meant for AI agents, published the same month. It opens another chapter, told in the article on agentic commerce.

What we learned

  • In headless, SEO is a feature, not a setting. It has a backlog, tickets and acceptance testing, like everything else.
  • One piece of data, one owner. Computing the same information in two places guarantees it will diverge.
  • Never declare what you do not know. No approximate hreflang, no zero price, no made-up date. Search engines forgive absence; they end up penalising lies.
  • Read the access logs. The service worker 404s, the 302 redirect: the best findings came from no SEO tool, but from logs and existing code.
  • SEO bugs live at the seams. Server render, hydration, CDN cache: the menu served stale data from between the layers.