Next.js metadata async throw : Google indexe \"Next.js\" en title

Next.js metadata async qui throw : 1 200 pages servent le title par défaut pendant 18 jours

Jeudi 16h42. L'équipe backend déploie un changement mineur sur l'API catalogue : le champ seo_title migre de string | null vers un objet { value: string, locale: string }. Côté front, personne n'est prévenu. Le site Next.js 14.2 App Router continue de build, de se déployer, de servir des pages. Dans le navigateur, les titles semblent corrects — le layout affiche un <h1> dynamique. Mais dans le <head>, 1 247 pages produit portent désormais un title identique : "Next.js". Le fallback par défaut du framework. Il faudra 18 jours avant que quelqu'un s'en aperçoive.

Lundi matin, T+4 jours — "Pourquoi le CTR produit est en chute libre ?"

9h12, standup marketing. La responsable acquisition partage un export Search Console : le CTR moyen des pages /produit/[slug] est passé de 3.8 % à 1.1 % sur les quatre derniers jours. Les impressions tiennent encore — Google continue d'afficher les pages — mais personne ne clique. Les SERPs montrent un title générique : "Next.js".

Le lead SEO ouvre Search Console, filtre sur le répertoire /produit/. L'outil d'inspection d'URL confirme : le HTML rendu par Google contient <title>Next.js</title> sur chaque page testée. Première hypothèse : un problème de cache CDN. L'équipe ops invalide le cache Vercel sur une poignée d'URLs. Retest. Le title reste "Next.js".

Deuxième hypothèse : une régression dans le composant <Head>. Un développeur front fouille le code. Aucun composant <Head> n'existe — le projet utilise l'API generateMetadata du App Router. Le fichier app/produit/[slug]/page.tsx exporte bien une fonction async. Elle semble correcte.

10h30. Le lead SEO lance un crawl Screaming Frog en mode "JavaScript rendering" sur 200 URLs produit. Résultat : 197 pages sur 200 retournent <title>Next.js</title>. Les 3 exceptions sont des pages dont le produit n'a pas de champ seo_title — le code fallback sur le nom du produit, qui lui n'a pas changé de format.

11h15. Le dev front ouvre la console navigateur sur une page produit. Aucune erreur visible. Le title dans l'onglet affiche bien le nom du produit. Confusion totale. Comment le title peut-il être correct dans le navigateur et faux pour Google ?

La réponse est dans la différence entre ce que le navigateur affiche après hydratation côté client et ce que le serveur envoie dans la réponse HTML initiale. Le développeur n'avait jamais regardé le HTML brut. Un simple curl va tout révéler.

curl -s https://www.example.com/produit/chaise-ergonomique-pro \
  | grep -i '<title>'

Sortie :

<title>Next.js</title>

Le serveur envoie le fallback. Le navigateur corrige ensuite côté client via un useEffect ou un mécanisme d'hydratation — mais Googlebot, lui, indexe le HTML initial. Le problème est confirmé. Reste à comprendre pourquoi generateMetadata ne fait pas son travail.

Le bug : une promise qui throw en silence dans generateMetadata

Le fichier incriminé ressemble à ceci :

// app/produit/[slug]/page.tsx
import { Metadata } from 'next'
import { getProduct } from '@/lib/api'

export async function generateMetadata(
  { params }: { params: { slug: string } }
): Promise<Metadata> {
  const product = await getProduct(params.slug)

  return {
    title: product.seo_title.trim(),
    description: product.seo_description.trim(),
    openGraph: {
      title: product.seo_title.trim(),
      description: product.seo_description.trim(),
      images: [product.og_image],
    },
  }
}

Le problème tient en six caractères : .trim(). Avant la migration API, product.seo_title était une string. Après la migration, c'est un objet { value: "Chaise ergonomique Pro", locale: "fr" }. Appeler .trim() sur un objet ne throw pas immédiatement en JavaScript — undefined est retourné par la propriété inexistante, et .trim() sur undefined throw un TypeError: Cannot read properties of undefined.

Mais le vrai piège est ailleurs. Next.js App Router gère les erreurs dans generateMetadata d'une façon spécifique : quand la fonction throw, le framework ne fait pas crasher la page. Il sert la page normalement, mais avec les metadata par défaut. Et la metadata par défaut, si aucun layout.tsx parent ne la surcharge, c'est le title défini dans le package Next.js lui-même : "Next.js".

Voici ce qui se passe côté serveur, reconstruit à partir des logs :

[ERROR] app/produit/[slug]/page.tsx generateMetadata
TypeError: Cannot read properties of undefined (reading 'trim')
    at generateMetadata (app/produit/[slug]/page.tsx:9:36)
    at resolveMetadata (next/dist/lib/metadata/resolve-metadata.js:142:23)
    at async renderToHTMLOrFlight (next/dist/server/app-render.js:891:5)

L'erreur est loggée côté serveur — dans les logs Vercel, noyée parmi des centaines de lignes. Mais la page retourne un status 200. Le body HTML est complet. Seul le <head> porte les metadata fallback.

Pourquoi le navigateur affiche le bon title

Le composant page contient un <h1>{product.name}</h1> qui, lui, fonctionne parfaitement : product.name est toujours une string. Le navigateur récupère le HTML serveur avec <title>Next.js</title>, puis l'hydratation client re-exécute le JavaScript. Côté client, un hook useEffect dans un composant analytics met à jour document.title pour le tracking. Le développeur, en testant dans Chrome, voit le title corrigé dans l'onglet sans jamais regarder le source initial.

Pourquoi les tests n'ont rien détecté

L'équipe a trois couches de tests :

  1. Tests unitaires sur getProduct — ils mockent la réponse API avec l'ancien format. Le mock n'a pas été mis à jour.
  2. Tests E2E Playwright — ils vérifient await page.title() qui retourne le title après hydratation client. Le test passe.
  3. Tests de build (next build) — la build statique génère les pages sans erreur car generateMetadata est exécutée au build time avec des données de fallback qui n'appellent pas l'API réelle en mode ISR.

Aucune de ces trois couches ne vérifie le HTML brut retourné par le serveur en production. C'est le trou dans la raquette.

Ce que Googlebot voit vs ce que le dev voit

Pour rendre le diagnostic explicite, voici les deux versions du <head> :

HTML brut serveur (ce que Googlebot indexe) :

<!DOCTYPE html>
<html lang="fr">
<head>
  <meta charSet="utf-8"/>
  <meta name="viewport" content="width=device-width, initial-scale=1"/>
  <title>Next.js</title>
  <meta name="description" content=""/>
  <meta property="og:title" content=""/>
  <meta property="og:description" content=""/>
  <!-- ... scripts Next.js ... -->
</head>

DOM après hydratation client (ce que voit Chrome DevTools) :

<head>
  <meta charset="utf-8"/>
  <meta name="viewport" content="width=device-width, initial-scale=1"/>
  <title>Chaise Ergonomique Pro | Example Store</title>
  <meta name="description" content="Chaise de bureau ergonomique..."/>
  <!-- ... -->
</head>

Le décalage est total. Et comme l'outil d'inspection d'URL de Search Console montre le HTML après exécution JavaScript de Google, il peut parfois afficher le title corrigé — ce qui ajoute encore à la confusion. Mais le cache d'indexation, lui, a capturé le title serveur initial sur la majorité des URLs.

Un passage dans l'onglet "Pages" de Search Console, filtre "Title tag issues", révèle 1 247 pages avec le title "Next.js". Le signal est sans ambiguïté.

L'ampleur du dégât

Sur les 18 jours d'exposition :

  • 1 247 pages produit affectées sur 1 580 au total (79 % du catalogue)
  • Clics organiques sur /produit/ : de 8 400/semaine à 2 100/semaine (−75 %)
  • Impressions stables à ~210K/semaine — Google continue de montrer les pages, mais avec "Next.js" en title, personne ne clique
  • 3 mots-clés top 3 perdent entre 4 et 11 positions (le title est un signal de ranking direct)
  • Revenue organique estimé : −34K€ sur la période

Ce type de divergence entre rendu serveur et rendu client est un classique documenté. L'article sur le composant heading qui rend un div selon la prop mal configurée décrit un mécanisme similaire : ce que le développeur voit dans le navigateur ne correspond pas à ce que le crawler reçoit.

Le fix : try/catch, fallback explicite et monitoring du HTML brut

Patch 1 — Adapter le type et protéger generateMetadata

Le correctif immédiat est double : adapter l'accès au nouveau format API et envelopper la fonction dans un try/catch avec un fallback SEO-safe.

// app/produit/[slug]/page.tsx
import { Metadata } from 'next'
import { getProduct } from '@/lib/api'

function extractSeoTitle(field: unknown): string {
  if (typeof field === 'string') return field.trim()
  if (field && typeof field === 'object' && 'value' in field) {
    return String((field as { value: string }).value).trim()
  }
  return ''
}

export async function generateMetadata(
  { params }: { params: { slug: string } }
): Promise<Metadata> {
  try {
    const product = await getProduct(params.slug)

    const title = extractSeoTitle(product.seo_title) || product.name
    const description =
      extractSeoTitle(product.seo_description) ||
      `Découvrez ${product.name} sur Example Store.`

    return {
      title,
      description,
      openGraph: {
        title,
        description,
        images: product.og_image ? [product.og_image] : [],
      },
    }
  } catch (error) {
    console.error('[SEO] generateMetadata failed:', params.slug, error)

    return {
      title: 'Example Store — Produit',
      description: 'Découvrez notre catalogue sur Example Store.',
    }
  }
}

Deux changements critiques :

  1. extractSeoTitle gère les deux formats (ancien string, nouveau objet) — ce qui protège aussi pendant la période de migration progressive de l'API.
  2. Le catch retourne un fallback métier explicite, jamais le title par défaut du framework. Si generateMetadata échoue, la page sert au moins un title identifiable et brandé.

Patch 2 — Surcharger le title par défaut dans le layout racine

Pour se protéger globalement contre tout futur throw non capturé dans un generateMetadata enfant, le layout racine doit définir un title par défaut métier :

// app/layout.tsx
import { Metadata } from 'next'

export const metadata: Metadata = {
  title: {
    template: '%s | Example Store',
    default: 'Example Store — Catalogue en ligne',
  },
  description: 'Example Store, votre boutique en ligne.',
}

Avec ce template, même si un generateMetadata enfant throw et que Next.js fallback sur le parent, le title sera "Example Store — Catalogue en ligne" et non "Next.js". C'est un filet de sécurité permanent. La documentation officielle Next.js sur les metadata détaille ce mécanisme de template et default.

Patch 3 — Test E2E sur le HTML brut

L'équipe ajoute un test Playwright qui vérifie le HTML serveur, pas le DOM post-hydratation :

// tests/seo/metadata-ssr.spec.ts
import { test, expect } from '@playwright/test'

const PRODUCT_URLS = [
  '/produit/chaise-ergonomique-pro',
  '/produit/bureau-assis-debout-xl',
  '/produit/repose-pieds-bambou',
]

for (const url of PRODUCT_URLS) {
  test(`SSR title is not default fallback: ${url}`, async ({ request }) => {
    const response = await request.get(url)
    const html = await response.text()

    // Vérifie que le title n'est pas le fallback Next.js
    expect(html).not.toContain('<title>Next.js</title>')

    // Vérifie que le title contient le brand
    const titleMatch = html.match(/<title>(.*?)<\/title>/)
    expect(titleMatch).not.toBeNull()
    expect(titleMatch![1]).toContain('Example Store')
    expect(titleMatch![1].length).toBeGreaterThan(15)
  })
}

Ce test utilise request.get — pas page.goto. Il récupère le HTML brut, comme le ferait curl ou Googlebot. C'est la différence fondamentale avec les tests E2E classiques qui naviguent dans un vrai navigateur. Ce pattern devrait être systématique sur tout site Next.js App Router qui repose sur generateMetadata.

Redéploiement et récupération

Le fix est déployé un mardi à 14h. Les étapes de récupération :

  1. Invalidation du cache ISR sur toutes les pages /produit/ via l'API revalidatePath de Next.js.
  2. Soumission manuelle des 50 URLs les plus stratégiques dans l'outil d'inspection d'URL Search Console (demande de réindexation).
  3. Sitemap ping vers Google via https://www.google.com/ping?sitemap=https://www.example.com/sitemap-products.xml.

Le suivi montre une récupération en trois phases :

  • J+2 : Google recrawle ~30 % des pages affectées. Les titles corrigés commencent à apparaître dans les SERPs.
  • J+7 : 85 % des pages affichent le bon title. Le CTR remonte à 2.9 %.
  • J+14 : CTR revenu à 3.6 %, proche du niveau initial. Les positions sur les 3 mots-clés top 3 mettent encore une semaine supplémentaire à se stabiliser.

Total : ~25 jours entre le déploiement du bug et la récupération complète. 18 jours d'exposition + 7 jours de récupération effective. L'impact SEO d'un title cassé est long à résorber parce que Google doit recrawler, réindexer, puis recalculer le ranking.

Le scénario rappelle d'autres incidents où un problème invisible côté navigateur cause des dégâts côté crawler. L'article sur le A/B test qui servait un noindex à 50 % du trafic illustre le même pattern : le dev ne voit rien d'anormal, mais le crawler reçoit un signal destructeur. De même, la migration Vercel vers Railway avec perte du edge ISR montre comment un changement d'infrastructure peut dégrader le rendu serveur sans symptôme visible dans le navigateur.

Ce qu'on en retient

Trois règles après cet incident.

Un. Toute fonction generateMetadata async doit être enveloppée dans un try/catch avec un fallback métier explicite. Le fallback par défaut de Next.js — "Next.js" — ne doit jamais atteindre la production.

Deux. Les tests E2E doivent vérifier le HTML brut serveur, pas le DOM post-hydratation. Un request.get dans Playwright coûte 5 lignes et détecte ce que page.goto manque.

Trois. Le monitoring ne peut pas reposer uniquement sur des tests manuels ou des alertes Search Console (qui arrivent avec des jours de retard). Un outil de monitoring continu type Seogard détecte la divergence entre title SSR et title attendu en quelques minutes, pas en 18 jours. La différence entre les deux, ici, c'est 34K€.

Le title <title>Next.js</title> en production est un signal d'alarme silencieux. Il ne casse rien visuellement. Il ne retourne pas de 500. Il passe tous les tests classiques. Et il détruit le trafic organique page par page, jour après jour.

Articles connexes

Framework8 juin 2026

Astro Content Collections : 80 titles vides après refacto

Un upgrade Astro casse le mapping frontmatter → composant. 80 articles perdent leur title. Récit du bug, diagnostic technique et fix complet.

Framework7 juin 2026

Astro sitemap pointe vers build.local : 4 000 URLs perdues

Un site Astro envoie un sitemap.xml avec 4 000 URLs build.local à Google. Récit de l'incident, diagnostic technique et fix complet.

Actualités SEO8 juin 2026

AI Visitors contextuels : préparer vos pages au blended retrieval

Les agents AI arrivent avec le contexte utilisateur. Comment adapter votre contenu pour rester utile face au blended retrieval.

Performance7 juin 2026

Variable font lazy-load : LCP dégradé de 1.2s, ranking en chute

Une refonte typo charge la police en lazy. Le LCP passe de 1.8s à 3.0s. Aucune meta ne bouge. Le trafic chute de 18%. Récit, diagnostic, fix.

Rendering6 juin 2026

noscript cloaking : splash screen SPA piège Google

Un e-commerce SPA cache son contenu dans une balise noscript pour les bots. Google détecte du cloaking. Récit, diagnostic et fix complet.

Actualités SEO6 juin 2026

57% de bots : impact SEO et stratégies de défense technique

Cloudflare révèle que 57% des requêtes web sont des bots. Analyse technique des impacts SEO et stratégies concrètes pour protéger votre crawl budget.