Contentful + Next.js : quand le title SEO existe dans le CMS mais n'arrive jamais dans le HTML
Mercredi 14h. L'équipe content d'un site e-commerce français spécialisé dans le mobilier — 4 200 pages produit, 380K visites organiques mensuelles — termine de renseigner les champs SEO title et SEO description dans Contentful. Chaque variante de produit a enfin son title unique. Le lendemain, un dev déploie une refonte du content type "Produit" sur Next.js 14. Dans le navigateur, les pages s'affichent. Les titles, eux, ne bougent pas. 1 200 variantes produit servent le même <title> : le H1 du template — "Canapé Oslo". Pendant 26 jours, personne ne voit rien.
Lundi 9h12 — "Pourquoi nos impressions fondent ?"
La Lead SEO ouvre Search Console le lundi matin. Le rapport Performance affiche une courbe familière : impressions stables pendant trois mois, puis une glissade régulière depuis trois semaines. Pas un effondrement brutal — une érosion. Moins 18 % de clics sur le segment "canapés", moins 22 % sur "fauteuils".
Premier réflexe : vérifier les positions. Les requêtes brandées tiennent. Les requêtes longue traîne — "canapé oslo 3 places tissu gris", "canapé oslo 2 places velours bleu" — perdent entre 4 et 9 positions. 47 URLs sortent du top 20.
L'équipe ouvre un ticket Slack. Le CTO demande : "On a touché quoi côté tech ces dernières semaines ?" La réponse du lead dev : un refacto du content model Contentful, un changement de la query GraphQL, et une mise à jour du composant <head> pour passer à generateMetadata de Next.js 14.
La Lead SEO lance un crawl Screaming Frog sur le répertoire /canapes/. 1 247 URLs crawlées. Colonne <title> : 1 198 pages affichent "Canapé Oslo". 49 affichent un title différent — celles qui n'ont pas de variante.
Le diagnostic initial est faux. L'équipe pense d'abord à un problème de cache CDN. Le site tourne sur Vercel, ISR activé, revalidation toutes les 60 secondes. Hypothèse : les pages auraient été mises en cache avant que les champs SEO soient remplis dans Contentful. Le lead dev purge le cache ISR. Nouveau crawl. Même résultat : 1 198 titles identiques.
Deuxième hypothèse : les champs SEO sont vides dans Contentful. La content manager vérifie. Les champs seoTitle et seoDescription sont bien remplis pour chaque variante. "Canapé Oslo 3 places — Tissu gris anthracite", "Canapé Oslo 2 places — Velours bleu nuit". Tout est là.
Troisième hypothèse, la bonne : le binding entre Contentful et Next.js ne récupère pas le bon champ. Le title affiché n'est pas le seoTitle du CMS. C'est le name du produit — celui qui sert aussi de H1.
L'impact se précise. Sur 26 jours, Search Console montre :
- −38K clics sur les pages variantes produit
- −210K impressions sur les requêtes longue traîne
- 47 URLs passées sous le top 20
- CTR moyen des variantes tombé de 4,2 % à 2,1 %
Le problème n'est pas un bug. C'est un mapping manquant.
Le bug : un champ GraphQL ignoré, un fallback silencieux
Pour comprendre l'incident, il faut remonter au content model Contentful et à la query GraphQL qui alimente Next.js.
Le content model Contentful
Le content type "Produit" dans Contentful a cette structure :
| Field ID | Field Name | Type |
|---|---|---|
name |
Nom du produit | Short text |
slug |
Slug | Short text |
description |
Description | Rich text |
seoTitle |
SEO Title | Short text |
seoDescription |
SEO Description | Long text |
variant |
Variante | Short text |
images |
Images | Media (array) |
Le champ seoTitle a été ajouté lors du refacto du content model — exactement le jour où le dev a aussi refactoré la query GraphQL. Le timing est la cause du bug.
La query GraphQL avant le refacto
query ProductPage($slug: String!) {
productCollection(where: { slug: $slug }, limit: 1) {
items {
name
slug
description {
json
}
seoTitle
seoDescription
variant
imagesCollection {
items {
url
description
}
}
}
}
}
Cette query existait dans l'ancienne codebase. Elle récupérait seoTitle et seoDescription. Sauf que le champ n'existait pas encore dans Contentful à cette époque — la query retournait null pour ces deux champs, et le composant <Head> de Next.js Pages Router utilisait un fallback.
La query GraphQL après le refacto
Le dev a migré de Pages Router vers App Router. Il a réécrit la query en utilisant le SDK Contentful pour TypeScript. Voici ce qu'il a produit :
// lib/contentful.ts
import { createClient } from 'contentful';
const client = createClient({
space: process.env.CONTENTFUL_SPACE_ID!,
accessToken: process.env.CONTENTFUL_ACCESS_TOKEN!,
});
export async function getProductBySlug(slug: string) {
const entries = await client.getEntries({
content_type: 'product',
'fields.slug': slug,
limit: 1,
select: [
'fields.name',
'fields.slug',
'fields.description',
'fields.variant',
'fields.images',
],
});
return entries.items[0]?.fields ?? null;
}
Le problème est là, ligne par ligne. Le paramètre select liste explicitement les champs à récupérer. fields.seoTitle et fields.seoDescription ne sont pas dans la liste. Le dev a copié les champs de l'ancien modèle, ajouté variant et images, et oublié les deux champs SEO qui venaient d'être créés dans Contentful au même moment.
Le SDK Contentful ne lève aucune erreur. Il retourne simplement les champs demandés. Pas de warning, pas de log. Silence total.
Le composant metadata dans Next.js App Router
Côté Next.js 14, le dev a implémenté generateMetadata dans le fichier page :
// app/produits/[slug]/page.tsx
import { getProductBySlug } from '@/lib/contentful';
import type { Metadata } from 'next';
type Props = {
params: { slug: string };
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const product = await getProductBySlug(params.slug);
return {
title: product?.seoTitle || product?.name || 'Produit',
description: product?.seoDescription || '',
};
}
export default async function ProductPage({ params }: Props) {
const product = await getProductBySlug(params.slug);
// ...render
}
La logique de fallback est correcte en théorie : si seoTitle est absent, utiliser name. Le problème : product.seoTitle est toujours undefined parce que le champ n'est jamais récupéré depuis Contentful. Le fallback product.name s'active sur 100 % des pages.
Et name, c'est le nom générique du produit — "Canapé Oslo" — sans la variante. Parce que la variante est un champ séparé, et personne n'a pensé à concaténer name + variant dans le fallback.
Ce que voit le navigateur vs ce que voit Googlebot
Le résultat HTML servi par Next.js :
<!DOCTYPE html>
<html>
<head>
<title>Canapé Oslo</title>
<meta name="description" content="" />
<!-- ... -->
</head>
<body>
<h1>Canapé Oslo</h1>
<p class="variant-name">3 places — Tissu gris anthracite</p>
<!-- ... -->
</body>
</html>
Le title est identique au H1. La meta description est vide (même logique de fallback : seoDescription est undefined, fallback vers chaîne vide). Googlebot voit exactement la même chose — pas de divergence SSR/CSR ici, le rendu est cohérent. Le problème est purement un problème de données.
1 198 pages avec le title "Canapé Oslo". Google ne peut pas distinguer les variantes. Les requêtes longue traîne ("canapé oslo 3 places tissu gris") matchent mal un title générique. Le CTR s'effondre. Les positions suivent.
Pourquoi personne n'a rien vu
Trois raisons.
1. Les tests end-to-end ne vérifient pas le contenu des balises meta. Le pipeline CI/CD utilise Playwright. Les tests vérifient que la page se charge, que le prix s'affiche, que le bouton "Ajouter au panier" fonctionne. Aucun expect(page).toHaveTitle() dans la suite de tests.
2. La preview Contentful montre le bon title. L'interface preview de Contentful affiche les champs tels qu'ils existent dans le CMS. La content manager voit "Canapé Oslo 3 places — Tissu gris anthracite" dans le champ SEO Title. Elle n'a aucune raison de suspecter que ce champ ne parvient pas au front.
3. Le title "Canapé Oslo" est visuellement correct. En naviguant sur le site, l'onglet du navigateur affiche "Canapé Oslo". C'est le nom du produit. Ça ne choque personne — sauf un SEO qui comparerait 1 200 onglets.
Ce type de régression silencieuse en architecture headless est un pattern récurrent. Le même phénomène de title pris au mauvais niveau a été documenté sur Astro, et le problème de metadata async qui échoue silencieusement est un classique de Next.js App Router.
Le fix : trois lignes, un cache purge, et 19 jours de patience
Le patch
Le correctif tient en une ligne dans la query Contentful :
// lib/contentful.ts — PATCH
export async function getProductBySlug(slug: string) {
const entries = await client.getEntries({
content_type: 'product',
'fields.slug': slug,
limit: 1,
select: [
'fields.name',
'fields.slug',
'fields.description',
'fields.variant',
'fields.images',
'fields.seoTitle', // ← ajouté
'fields.seoDescription', // ← ajouté
],
});
return entries.items[0]?.fields ?? null;
}
L'équipe en profite pour durcir le fallback dans generateMetadata, afin d'éviter un title générique même si le champ SEO est vide :
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const product = await getProductBySlug(params.slug);
const fallbackTitle = product?.variant
? `${product.name} ${product.variant}`
: product?.name || 'Produit';
return {
title: product?.seoTitle || fallbackTitle,
description:
product?.seoDescription ||
`Découvrez ${fallbackTitle} — livraison offerte dès 500€.`,
};
}
Ce fallback amélioré garantit que même si un rédacteur oublie de renseigner le seoTitle, la combinaison name + variant produit un title unique.
Invalidation et redéploiement
Le patch est mergé et déployé sur Vercel à 11h42. Mais le ISR cache sert encore les anciennes pages. L'équipe exécute une purge ciblée :
# Purge ISR pour toutes les pages produit via l'API Vercel
curl -X POST "https://api.vercel.com/v1/projects/$PROJECT_ID/revalidate" \
-H "Authorization: Bearer $VERCEL_TOKEN" \
-H "Content-Type: application/json" \
-d '{"paths": ["/produits/(.*)"]}'
La revalidation on-demand déclenche un re-render de chaque page lors de la prochaine visite. En 4 heures, les 1 200 pages variantes ont été re-rendues avec le bon title.
Vérification immédiate avec curl :
curl -s https://www.example.com/produits/canape-oslo-3-places-tissu-gris | \
grep -o '<title>[^<]*</title>'
# <title>Canapé Oslo 3 places — Tissu gris anthracite</title>
Un crawl Screaming Frog de contrôle confirme : 1 198 titles uniques sur 1 247 pages. Les 49 pages sans variante conservent le title générique — attendu, puisqu'il n'y a qu'un seul produit par page.
Les garde-fous ajoutés
L'équipe met en place trois mesures pour éviter la récurrence.
1. Test Playwright sur les meta. Ajout d'un test e2e qui compare le <title> rendu avec le champ seoTitle de Contentful pour un échantillon de 10 pages :
// e2e/seo-titles.spec.ts
import { test, expect } from '@playwright/test';
import { getProductBySlug } from '../lib/contentful';
const SAMPLE_SLUGS = [
'canape-oslo-3-places-tissu-gris',
'fauteuil-bergen-velours-bleu',
// ... 8 autres
];
for (const slug of SAMPLE_SLUGS) {
test(`title matches Contentful seoTitle for ${slug}`, async ({ page }) => {
const product = await getProductBySlug(slug);
await page.goto(`/produits/${slug}`);
const title = await page.title();
expect(title).toBe(product.seoTitle);
});
}
2. Alerte sur les titles dupliqués. Un script cron hebdomadaire crawle /produits/ avec Screaming Frog en mode CLI et alerte Slack si le ratio de titles dupliqués dépasse 5 %.
3. Validation du schéma de la query Contentful. L'équipe ajoute un type TypeScript strict pour le retour de getProductBySlug, généré depuis le content model Contentful avec contentful-typescript-codegen. Si un champ est supprimé de la query select mais utilisé dans le composant, TypeScript lève une erreur à la compilation.
La récupération
Le fix est déployé le jour J. Voici la timeline de récupération observée dans Search Console :
- J+3 : Google recrawle 40 % des pages variantes. Les nouveaux titles apparaissent dans le rapport "Pages".
- J+7 : 92 % des pages recrawlées. Les impressions cessent de baisser.
- J+12 : les positions commencent à remonter sur les requêtes longue traîne.
- J+19 : retour à 94 % du trafic pré-incident. Les 6 % restants ne reviennent jamais complètement — probablement des requêtes où un concurrent a pris la position entre-temps.
Au total, l'incident aura coûté 26 jours de régression + 19 jours de récupération. 45 jours. Environ 52K clics perdus.
Ce type de latence de récupération est cohérent avec d'autres incidents documentés, comme celui d'une migration Vercel vers Railway qui a multiplié le TTFB par 4 — où le retour au trafic nominal avait pris 23 jours.
Ce qu'on en retient
En architecture headless, le CMS et le front sont deux mondes. Un champ peut exister dans Contentful, être rempli par la rédaction, validé en preview — et ne jamais atteindre le HTML servi à Googlebot. Le SDK ne protège pas. TypeScript sans types générés ne protège pas. Les tests e2e classiques ne protègent pas.
La seule protection fiable, c'est une vérification continue du HTML réellement servi, comparé aux données source. Un outil comme Seogard détecte ce type de divergence — un title identique sur 1 200 pages — en quelques heures, pas en 26 jours.
Le champ existait. La donnée existait. Le mapping manquait. Trois lignes de code. 52 000 clics.