Migration Next.js App Router : 1 200 pages sans metadata pendant 19 jours
Jeudi 6 mars, 16h40. L'équipe frontend d'une marketplace française de mobilier (14 000 pages, 380K visites organiques mensuelles) déploie la dernière tranche de sa migration Next.js 13 Pages Router vers Next.js 14 App Router. Le ticket Jira passe en "Done". Les smoke tests sont verts. Dans le navigateur, chaque page affiche le bon <title> et la bonne <meta name="description">. Personne ne regarde le HTML source. Le week-end passe. Puis trois semaines.
Lundi 24 mars, 9h12 — L'alerte Search Console
Le Lead SEO ouvre son rapport hebdomadaire Search Console. La courbe des clics sur les pages catégories et produits s'est affaissée. Pas un effondrement brutal — une glissade lente, régulière, qui commence pile le 7 mars. Moins 31% de clics sur les requêtes transactionnelles en 18 jours. Environ 118K clics perdus.
Premier réflexe : vérifier les Core Web Vitals. Tout est stable. Deuxième réflexe : chercher un changement d'algorithme. Le core update de mai 2026 n'a pas encore commencé à cette date. Troisième réflexe : regarder les pages impactées une par une dans le rapport "Performances".
Le pattern saute aux yeux. Les pages dont le CTR a chuté sont exactement celles migrées vers l'App Router le 6 mars. 1 247 URLs. Toutes les fiches produit et les pages catégories avec filtres dynamiques.
Le Lead SEO ouvre l'une de ces pages dans Chrome, fait un clic droit, "Afficher le code source". Le <title> affiché dans l'onglet du navigateur est correct : "Canapé convertible 3 places — NomDuSite". Mais dans le HTML source brut servi par le serveur, c'est autre chose :
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charSet="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<link rel="stylesheet" href="/_next/static/css/app.css"/>
</head>
<body>
<!-- ... -->
Pas de <title>. Pas de <meta name="description">. Pas de <meta property="og:title">. Rien.
Il lance un crawl Screaming Frog sur les 1 247 URLs. Résultat : 1 203 pages renvoient un <title> vide. 44 pages ont conservé leurs metadata — ce sont celles qui n'avaient pas été migrées vers l'App Router.
Le Slack explose à 10h15. Le CTO est loopé. L'hypothèse initiale du frontend lead : "un problème de cache CDN qui sert une ancienne version". L'équipe purge le cache Vercel. Re-crawl Screaming Frog. Toujours vide. Ce n'est pas le cache.
Le diagnostic prend encore deux heures pour converger vers la vraie cause. Deux heures pendant lesquelles quelqu'un suggère un bug de Next.js 14, un autre pointe le middleware, un troisième accuse Vercel. La réponse est plus simple — et plus douloureuse.
Le bug : export const metadata ignoré dans les composants "use client"
Le système de metadata de Next.js App Router repose sur un export statique dans les fichiers page.tsx ou layout.tsx :
// app/products/[slug]/page.tsx — ce que l'équipe pensait avoir écrit
export const metadata = {
title: "Canapé convertible 3 places — NomDuSite",
description: "Découvrez notre canapé convertible 3 places en tissu...",
openGraph: {
title: "Canapé convertible 3 places",
images: ["/images/canape-3p.jpg"],
},
};
export default function ProductPage({ params }) {
// ...
}
Cet export fonctionne parfaitement quand le fichier est un Server Component — le comportement par défaut dans l'App Router. Next.js lit metadata au moment du rendu serveur, l'injecte dans le <head> HTML, et le navigateur (et Googlebot) le reçoit tel quel.
Mais voici ce que les fichiers contenaient réellement après migration :
// app/products/[slug]/page.tsx — la version déployée
"use client";
import { useState, useEffect } from "react";
import { useCart } from "@/hooks/useCart";
import { ProductGallery } from "@/components/ProductGallery";
export const metadata = {
title: "Canapé convertible 3 places — NomDuSite",
description: "Découvrez notre canapé convertible 3 places en tissu...",
};
export default function ProductPage({ params }) {
const [product, setProduct] = useState(null);
const { addToCart } = useCart();
useEffect(() => {
fetch(`/api/products/${params.slug}`).then(/* ... */);
}, [params.slug]);
// ...
}
La directive "use client" en haut du fichier transforme le composant en Client Component. Et dans un Client Component, Next.js ignore silencieusement l'export metadata. Pas d'erreur. Pas de warning dans la console. Pas de message dans le build log. L'export existe dans le code, il est syntaxiquement valide, mais le framework ne le consomme jamais.
C'est documenté dans la documentation officielle de Next.js sur les metadata — une ligne, facile à rater : "metadata can only be exported from Server Components."
Pourquoi l'équipe a ajouté "use client" partout
La raison est classique. Les pages produit utilisent :
useStatepour gérer l'état du panier côté clientuseEffectpour du lazy loading d'avis clientsuseCart, un hook custom basé suruseContext- Un composant galerie avec gestion de swipe (
onTouchStart,onTouchEnd)
En Pages Router, tout cela fonctionnait dans un composant React standard. En App Router, tout composant utilisant des hooks React côté client ou des event handlers doit porter la directive "use client". L'équipe a donc ajouté la directive en haut de chaque page.tsx migré.
Le problème : en faisant ça, ils ont aussi rendu l'export metadata invisible pour le serveur.
Ce que voit le développeur vs ce que voit Googlebot
Dans le navigateur, le <title> apparaît correctement dans l'onglet. Pourquoi ? Parce que l'application Next.js, une fois hydratée côté client, exécute du JavaScript qui modifie le DOM — y compris le <head>. Le développeur qui inspecte avec les DevTools de Chrome voit le DOM final, après hydratation. Tout semble correct.
Mais Googlebot, dans sa phase de crawl initiale, reçoit le HTML brut du serveur. Et ce HTML brut ne contient aucune balise <title> ni <meta name="description">. Googlebot peut exécuter du JavaScript (il utilise une version de Chromium), mais avec un délai — parfois plusieurs jours — et le rendering n'est pas garanti pour chaque crawl.
Pour reproduire exactement ce que reçoit Googlebot en première instance :
curl -s https://www.exemple.com/products/canape-convertible-3p \
-H "User-Agent: Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" \
| grep -i "<title>\|<meta name=\"description\""
Résultat sur les pages buggées : aucune ligne en sortie. Zéro. Le grep ne renvoie rien.
Sur une page non migrée (restée en Pages Router avec next/head) :
curl -s https://www.exemple.com/inspirations/salon-scandinave \
-H "User-Agent: Googlebot" \
| grep -i "<title>"
<title>Salon scandinave : 15 idées déco — NomDuSite</title>
Pourquoi les tests n'ont rien détecté
L'équipe avait une suite de tests Playwright qui vérifiait les titres de page :
// tests/product-page.spec.ts
test("product page has correct title", async ({ page }) => {
await page.goto("/products/canape-convertible-3p");
await expect(page).toHaveTitle(/Canapé convertible 3 places/);
});
Ce test passe. Playwright lance un vrai navigateur Chromium, attend le chargement complet, et vérifie le <title> du DOM final — après hydratation JavaScript. Exactement comme un humain dans Chrome. Le test ne vérifie jamais le HTML initial servi par le serveur.
Aucun test de la CI ne faisait un fetch HTTP brut pour inspecter la réponse HTML avant exécution JavaScript. C'est la faille.
L'inspecteur d'URL de Search Console aurait pu révéler le problème — il affiche le HTML tel que Googlebot le voit après rendering. Mais personne ne l'a utilisé entre le déploiement et l'alerte, 19 jours plus tard.
Le fix : séparer Server Component et Client Component
La solution architecturale est celle recommandée par la documentation Next.js : ne jamais mettre "use client" directement dans le fichier page.tsx. Le page component reste un Server Component. La logique interactive est extraite dans un composant enfant marqué "use client".
Étape 1 — Extraire la logique client
Créer un composant client dédié :
// app/products/[slug]/ProductPageClient.tsx
"use client";
import { useState, useEffect } from "react";
import { useCart } from "@/hooks/useCart";
import { ProductGallery } from "@/components/ProductGallery";
interface ProductPageClientProps {
initialProduct: {
name: string;
description: string;
images: string[];
price: number;
};
}
export default function ProductPageClient({ initialProduct }: ProductPageClientProps) {
const { addToCart } = useCart();
const [reviews, setReviews] = useState([]);
useEffect(() => {
fetch(`/api/reviews/${initialProduct.name}`)
.then((res) => res.json())
.then(setReviews);
}, [initialProduct.name]);
return (
<main>
<ProductGallery images={initialProduct.images} />
<h1>{initialProduct.name}</h1>
<p>{initialProduct.description}</p>
<button onClick={() => addToCart(initialProduct)}>
Ajouter au panier — {initialProduct.price} €
</button>
{/* reviews section */}
</main>
);
}
Étape 2 — Page Server Component avec metadata
Le fichier page.tsx redevient un Server Component pur. Les metadata sont exportées — soit statiquement, soit dynamiquement via generateMetadata :
// app/products/[slug]/page.tsx
import { Metadata } from "next";
import { getProduct } from "@/lib/api";
import ProductPageClient from "./ProductPageClient";
export async function generateMetadata({
params,
}: {
params: { slug: string };
}): Promise<Metadata> {
const product = await getProduct(params.slug);
return {
title: `${product.name} — NomDuSite`,
description: product.shortDescription,
openGraph: {
title: product.name,
description: product.shortDescription,
images: [product.images[0]],
},
alternates: {
canonical: `https://www.exemple.com/products/${params.slug}`,
},
};
}
export default async function ProductPage({
params,
}: {
params: { slug: string };
}) {
const product = await getProduct(params.slug);
return <ProductPageClient initialProduct={product} />;
}
Le <title> et les <meta> sont maintenant injectés dans le HTML initial par le serveur. Le composant client gère l'interactivité. Les deux coexistent sans conflit.
Étape 3 — Vérification avant déploiement
Avant de push en production, l'équipe a ajouté un test qui vérifie le HTML brut :
// tests/seo-metadata.spec.ts
import { test, expect } from "@playwright/test";
test("product page SSR contains metadata", async ({ request }) => {
const response = await request.get("/products/canape-convertible-3p");
const html = await response.text();
expect(html).toContain("<title>");
expect(html).toMatch(/<meta name="description" content=".+"/);
expect(html).toMatch(/<meta property="og:title" content=".+"/);
});
Ce test utilise request.get de Playwright — un appel HTTP brut, sans navigateur, sans exécution JavaScript. Il vérifie le HTML tel qu'un crawler le reçoit.
Étape 4 — Déploiement et invalidation
Le fix a été déployé sur Vercel un mercredi à 11h. L'équipe a :
- Purgé le cache CDN Vercel via le dashboard
- Soumis les 1 203 URLs impactées à l'indexation via l'API Indexing de Search Console (par lots de 200)
- Forcé un recrawl du sitemap en le re-soumettant dans Search Console
Temps de récupération
Les premiers résultats sont revenus en 4 jours. Le <title> correct réapparaissait dans les SERPs pour les pages les plus crawlées. La récupération complète du trafic a pris 16 jours. Au total, l'équipe estime la perte à environ 200K clics sur la période cumulée (19 jours d'incident + 16 jours de récupération).
Le CTR moyen des pages produit est remonté de 1.8% (pendant l'incident — Google générait ses propres titres à partir du contenu de la page) à 3.4% (niveau pré-incident). La différence de CTR explique à elle seule la majorité de la perte de clics : les titres auto-générés par Google étaient souvent tronqués ou non pertinents.
Mesures préventives mises en place
L'équipe a ajouté trois garde-fous :
-
Lint rule ESLint custom qui interdit
export const metadataouexport async function generateMetadatadans tout fichier contenant"use client". Le build échoue si la règle est violée. -
Test CI systématique : un script crawle 50 URLs échantillonnées en staging après chaque déploiement, vérifie la présence de
<title>,<meta name="description">, et<link rel="canonical">dans le HTML brut. -
Alerte Search Console : un check quotidien automatisé via l'API Search Console qui compare le nombre d'impressions J-1 vs J-8. Si la baisse dépasse 15% sur un segment d'URLs, une alerte Slack est envoyée.
Ce type de régression silencieuse — où l'interface visuelle fonctionne parfaitement mais le HTML servi au crawler est incomplet — est un classique des migrations de framework frontend. L'équipe qui a vécu l'incident Vue 2 vers Vue 3 a rencontré un pattern quasi identique : le navigateur compense, le crawler non.
Ce qu'on en retient
Trois règles pour toute migration vers Next.js App Router :
Un. Le fichier page.tsx ne porte jamais "use client". L'interactivité descend dans des composants enfants. C'est un choix architectural, pas une préférence de style.
Deux. Les tests SEO doivent vérifier le HTML brut, pas le DOM après hydratation. Un curl vaut mieux qu'un test Playwright classique pour détecter ce type de divergence.
Trois. Le délai entre un déploiement cassé et sa détection en Search Console est mesuré en semaines, pas en heures. Un monitoring continu type Seogard détecte cette divergence SSR/CSR en quelques minutes — pas 19 jours et 200K clics plus tard.
La migration App Router apporte des gains réels (streaming, Server Components, meilleure colocation des data). Mais chaque "use client" placé trop haut dans l'arbre de composants est une bombe à retardement SEO. Il faut la désamorcer avant le déploiement, pas après le rapport Search Console du lundi matin.