Sur un CMS e-commerce monolithique, une bonne partie du SEO vient avec le thème : balises, plan de site, redirections. Sur un storefront headless, rien de tout ça n’existe par défaut. Ce que Google voit, c’est exactement le HTML que notre serveur Nuxt rend, ni plus ni moins. Si une information n’y est pas, elle n’existe pas.
On le savait en lançant le nouveau site le 23 février. On a donc traité le SEO comme une fonctionnalité à part entière, livrée par petites touches pendant six mois : neuf storefronts, cinq langues, des dizaines de milliers d’URL produits et d’images. Pas de grand soir, une centaine de décisions précises. Voici celles qui comptent.
Le principe : chaque artefact naît à un seul endroit
Avant d’écrire une ligne, on a fixé une règle d’architecture. Chaque élément SEO a un seul propriétaire, au plus près de sa source :
- la donnée (textes de catégories, attributs, composition) vit dans le PIM et Magento ;
- le balisage (JSON-LD, hreflang, canonical, meta robots) est produit par le rendu serveur Nuxt, à partir de données indexées dans Elasticsearch et servies par Helios, notre API catalogue ;
- les redirections se font le plus tôt possible : dans le serveur Nuxt quand elles dépendent du catalogue, en périphérie sur CloudFront quand elles n’en dépendent pas ;
- les sitemaps sont générés par Magento, qui connaît le catalogue complet, et publiés sur S3.
Cette règle évite le pire travers du SEO headless : la même information calculée à trois endroits, qui finit par dire trois choses différentes.
Les URL d’abord : une seule adresse par page
Un moteur qui trouve deux URL pour un même contenu doit choisir, et il choisit rarement comme on voudrait. Le premier chantier a donc été de garantir qu’une page n’a qu’une adresse, et que toutes les autres y mènent par une redirection permanente.
| Cas | Traitement | Où |
|---|---|---|
Ancienne URL de catégorie VSF, sans /c/ |
301 vers la nouvelle URL | serveur Nuxt |
| Fiche produit avec un slug erroné | 301 vers le slug canonique | serveur Nuxt |
| SKU inexistant | vrai 404, plus de page vide en 200 | serveur Nuxt |
Barres obliques en double (/eu/fr//c/...) |
301 vers l’URL normalisée | middleware Nitro |
www. vers le domaine nu |
302 devenu 301 | CloudFront Function |
| Chemins résiduels de l’ancien site | 301 ciblées | CloudFront Function |
La normalisation des barres obliques tient en quelques lignes, et c’est le genre de détail qui fait la différence sur un site dont les liens viennent de partout (newsletters, réseaux sociaux, partenaires) :
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)
})
La redirection www est une bonne illustration de ce qu’on gagne à relire l’existant : elle fonctionnait, mais en 302. Une redirection temporaire dit au moteur que l’ancienne adresse reviendra. Un seul chiffre changé en avril, et l’autorité des liens vers www se consolide enfin sur le domaine principal.
Les pages paginées des listes produits sont exclues de l’index (noindex) depuis mars, comme le tunnel de commande : elles n’apportent rien en recherche et diluent l’exploration.
hreflang et canonical sur neuf storefronts
Zadig&Voltaire sert neuf storefronts depuis un seul domaine : France, Royaume-Uni, Allemagne, Espagne, Italie, Belgique, Suisse, reste du monde et États-Unis. Plusieurs partagent l’anglais. Sans indication, Google peut servir la fiche britannique à un internaute suisse, ou considérer les pages anglaises comme des doublons.
Chaque page déclare donc ses alternatives, avec un code langue et pays quand plusieurs marchés partagent une langue :
<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/">
Tout se joue dans les cas particuliers. Les URL de catégories ne sont pas les mêmes d’un pays à l’autre, donc une liste produits ne déclare que les alternatives dont on connaît réellement l’adresse. Les réponses de FAQ n’ont pas d’équivalent garanti d’une langue à l’autre : elles n’en déclarent aucune, plutôt qu’une fausse. Une alternative fausse est pire qu’une alternative absente.
En janvier, avant même la mise en ligne, on avait posé les canonicals sur toutes les pages qui n’en avaient pas (accueil, FAQ, contenus, contact) et les balises Open Graph et Twitter Card, pour que chaque lien partagé s’affiche avec la bonne image et le bon titre.
Les données structurées, page par page
Le JSON-LD est la partie la plus visible du travail, celle qui produit des résultats enrichis : prix, disponibilité, fil d’Ariane, livraison. On l’a construit avec le module Schema.org de Nuxt, page par page.
La fiche produit : un groupe de variantes
Un manteau en deux tailles n’est pas un produit, c’est un groupe de produits. La fiche déclare un ProductGroup avec ses variantes, chacune avec son SKU, son GTIN, son prix et sa disponibilité. La composition et les conseils d’entretien, issus du PIM, sont exposés en additionalProperty.
La liste produits : une collection qui contient de vrais produits
Le chantier de juillet. Nos pages catégories déclaraient déjà une CollectionPage, mais chaque produit n’y était qu’une URL nue, et le nombre d’éléments annoncé restait figé quelle que soit la pagination réelle. On y a embarqué de vrais objets Product, avec image, prix et disponibilité, fidèles à la page effectivement rendue. Voici ce que rend aujourd’hui la page des vestes femme, premier élément de la liste :
{
"@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" }
}
}
}
Deux détails font la qualité de ce balisage. D’abord, les conditions de livraison et de retour sont déclarées une seule fois dans la page, puis référencées par @id dans chaque offre. Sur une page de quarante produits, c’est quarante blocs dupliqués en moins dans le HTML. Ensuite, pas d’offre sans prix ferme : une carte cadeau à montant libre n’a pas de prix, et déclarer 0 serait une donnée fausse que Google finirait par sanctionner.
// 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` },
},
}),
Ce chantier a aussi corrigé un défaut discret sur les fiches produit : les délais de livraison déclaraient unitCode: 'd'. Le standard attendu est le code UN/CEFACT DAY. Une coquille silencieuse, corrigée au passage sur les deux types de pages.
Les autres pages
- Les pages engagements (RSE) déclarent un
Article, avec des titres sémantiques. Leurs dates de publication et de mise à jour viennent de l’historique git du contenu, pas d’une date saisie à la main qui ne serait jamais mise à jour. - Les pages entretien des matières ont leur propre balisage depuis juillet.
- La page contact déclare un
ContactPageavec le téléphone et les horaires du service client, lus dans la configuration éditoriale plutôt que codés en dur. - Les produits archivés restent indexables. Une pièce qui n’est plus en vente continue d’être recherchée, notamment par les acheteurs du marché de seconde main. On garde la page, avec un
Productsans offre : la vérité, et rien qu’elle.
Le menu que Google ne voyait pas
En mai, en comparant le HTML rendu par l’ancien et le nouveau site, on a mis le doigt sur un angle mort. Vue Storefront exposait 88 liens de catégories dans le HTML serveur de l’accueil. Le nouveau site en exposait 8. Notre mega-menu, qui donne accès à toute l’arborescence du catalogue, ne se monte qu’à l’ouverture : tant que personne ne clique, ses liens n’existent pas. Un moteur qui n’exécute pas le JavaScript ne voyait plus les catégories profondes depuis l’accueil, et celui qui l’exécute le fait avec des jours de retard.
La correction : une liste de liens rendue dans le HTML serveur, repliée à l’écran et sortie de l’arbre d’accessibilité, qui reprend la même hiérarchie, puisque le vrai menu reste celui que les visiteurs utilisent. Mêmes liens, mêmes libellés, aucune page cachée aux humains.
Le piège était ailleurs. La première version partageait la clé de données avec le menu interactif. Conséquence : les données de navigation partaient dans le payload SSR, ce payload était mis en cache par CloudFront, et le menu interactif lisait des données périmées jusqu’au prochain vidage manuel du cache. Le composant utilise désormais sa propre clé :
// 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,
)
C’est le genre de bug qu’aucun test unitaire ne trouve : il n’existe qu’à la jonction entre le rendu, l’hydratation et le cache de périphérie.
Des sitemaps à l’échelle du catalogue
Le protocole sitemap impose une limite stricte : 50 000 URL par fichier. Notre sitemap d’images compte une entrée par visuel produit, sur neuf storefronts. L’ancien générateur s’arrêtait simplement au plafond : au-delà, la fin du catalogue disparaissait sans un message.
Le générateur, réécrit en avril dans Magento, découpe désormais automatiquement en fichiers numérotés et publie un index qui pointe vers chacun. La Search Console n’a qu’une URL à suivre, quelle que soit la taille du catalogue. En cas d’échec, il supprime tous les fichiers partiels : mieux vaut l’ancien sitemap complet qu’un nouveau sitemap valide mais tronqué. Une seconde itération l’a enrichi de la date de modification, des différents storefronts et des packshots.
L’index général, servi à la racine par CloudFront depuis S3, liste un sitemap de pages par storefront et le sitemap d’images.
Le ménage en périphérie
Dernière découverte, en mai, dans les journaux d’accès. Les navigateurs ayant visité l’ancien site gardaient installé le service worker de Vue Storefront, qui vérifie régulièrement sa propre mise à jour. Notre nouveau serveur Nuxt répondait 404, en rendant une page d’erreur complète d’environ 70 Ko à chaque fois. Mesure sur une heure de production : 14 612 requêtes, toutes en 404, à 88 % depuis Chrome.
Le piège : un navigateur ne désinstalle pas un service worker qui reçoit un 404. Il réessaie, indéfiniment. Impossible de joindre ces navigateurs autrement que par l’URL qu’ils demandent. On leur a donc répondu, directement en périphérie, avec une CloudFront Function qui sert un service worker dont le seul rôle est de se désinstaller :
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);}});}));" +
"});";
Le navigateur le reçoit une fois, se désinscrit, recharge la page, et ne redemande plus jamais le fichier. Ces requêtes n’atteignent plus le cluster.
C’est aussi en périphérie qu’est servi llms.txt, le manifeste destiné aux agents IA, mis en ligne le même mois. Il ouvre un autre chapitre, raconté dans l’article sur le commerce agentique.
Ce qu’on a appris
- En headless, le SEO est une fonctionnalité, pas une configuration. Il a un backlog, des tickets et une recette, comme le reste.
- Une donnée, un propriétaire. Calculer la même information à deux endroits garantit qu’elle divergera.
- Ne jamais déclarer ce qu’on ne sait pas. Pas d’alternative hreflang approximative, pas de prix à zéro, pas de date inventée. Un moteur pardonne l’absence, il finit par sanctionner le mensonge.
- Lire les journaux d’accès. Les 404 du service worker, la redirection en 302 : les meilleures trouvailles ne venaient d’aucun outil SEO, mais des logs et du code existant.
- Les bugs SEO vivent aux jonctions. Rendu serveur, hydratation, cache CDN : c’est entre les couches que le menu servait des données périmées.