TanStack Router SSR : quand le title du layout écrase 312 fiches produit
Jeudi 14h. L'équipe front d'une marketplace mode française — 8 400 pages indexées, 120 000 sessions organiques par mois — valide la migration de React Router v6 vers TanStack Router v1.62. Le rendu côté client est impeccable. Chaque fiche produit affiche son title dans l'onglet du navigateur. Le déploiement passe la CI, les tests Playwright sont verts. Vendredi soir, la branche arrive en production. Trois semaines plus tard, Search Console affiche −48 000 clics sur les pages produit. Le title indexé par Google est le même partout : "Catalogue — NomDuSite".
Lundi T+18 jours — "Pourquoi toutes nos fiches s'appellent Catalogue ?"
9h12. La responsable SEO ouvre le rapport de performances Search Console sur le segment /produit/. Elle filtre par pages. Les CTR se sont effondrés. Elle clique sur une dizaine d'URLs au hasard dans la colonne "Page". Toutes remontent le même title dans les SERPs : "Catalogue — NomDuSite".
Premier réflexe : vérifier dans le navigateur. Elle ouvre /produit/robe-lin-marine. L'onglet affiche bien "Robe Lin Marine — NomDuSite". Elle inspecte le DOM : le <title> est correct. Elle rafraîchit. Toujours correct.
9h31. Elle contacte le lead dev : "Les titles sont bons chez moi, mais Google affiche autre chose. Cache Google ?"
Le lead dev ouvre le cache Google d'une fiche produit. Le <title> dans le HTML mis en cache est "Catalogue — NomDuSite". Il vérifie une deuxième page. Même chose. Une troisième. Identique.
9h47. Hypothèse #1 : un problème de cache CDN. L'équipe purge le cache Vercel pour cinq URLs produit, attend 10 minutes, puis fait un curl brut :
curl -s https://www.exemple.com/produit/robe-lin-marine | grep '<title>'
Résultat :
<title>Catalogue — NomDuSite</title>
Le title de la fiche produit n'est pas dans la réponse HTML initiale. Il n'est jamais dans la réponse HTML. Le navigateur l'affiche uniquement parce que le JavaScript côté client le réécrit après hydratation.
10h03. Le lead dev lance Screaming Frog en mode "JavaScript rendering" et en mode "HTML brut" sur 50 URLs produit. Résultat :
| Mode | Title détecté |
|---|---|
| HTML brut | "Catalogue — NomDuSite" (50/50) |
| Avec JS | "Robe Lin Marine — NomDuSite" (50/50) |
10h22. L'ampleur se précise. L'équipe exporte les 312 URLs produit crawlées par Screaming Frog en HTML brut. 312 sur 312 portent le title du layout parent. Pas une seule exception.
La responsable SEO croise avec Search Console. Sur les 21 derniers jours, le segment /produit/ est passé de 4 200 clics/jour à 2 480 clics/jour. Un recul de 41 %. Les impressions ont baissé de 18 %, mais le CTR moyen a chuté de 3,8 % à 2,1 %. Les titles dupliqués ont tué la différenciation dans les SERPs.
10h40. L'équipe comprend que ce n'est pas un bug mineur. C'est une régression SSR silencieuse, passée à travers tous les tests parce que chaque test s'exécutait dans un navigateur — jamais sur le HTML brut.
Le bug : la hiérarchie de metas de TanStack Router n'est pas celle de Next.js
Pour comprendre la régression, il faut remonter à l'architecture de routing mise en place lors de la migration.
L'ancien monde : React Router + react-helmet-async
Avec React Router v6, l'équipe utilisait react-helmet-async dans chaque composant page. Le <Helmet> de la fiche produit écrasait celui du layout. L'ordre d'application était simple : le dernier <Helmet> monté gagne. Le SSR produisait le bon title parce que react-helmet-async collectait toutes les instances pendant le rendu serveur et prenait la plus profonde.
Le nouveau monde : TanStack Router et le piège du head
TanStack Router v1 introduit un système de head déclaratif directement dans la définition des routes, via la propriété head du createFileRoute ou createRoute. L'idée est séduisante : plus besoin de helmet, les metas sont colocalisées avec la route.
Voici la structure de routes mise en place par l'équipe :
// routes/__root.tsx
export const Route = createRootRoute({
head: () => ({
meta: [
{ title: 'NomDuSite' },
{ name: 'description', content: 'La marketplace mode référence.' },
],
}),
component: RootLayout,
})
// routes/catalogue.tsx (layout route)
export const Route = createFileRoute('/catalogue')({
head: () => ({
meta: [
{ title: 'Catalogue — NomDuSite' },
{ name: 'description', content: 'Parcourez notre catalogue.' },
],
}),
component: CatalogueLayout,
})
// routes/catalogue/produit/$slug.tsx (leaf route)
export const Route = createFileRoute('/catalogue/produit/$slug')({
loader: async ({ params }) => {
const product = await fetchProduct(params.slug)
return { product }
},
head: ({ loaderData }) => ({
meta: [
{ title: `${loaderData.product.name} — NomDuSite` },
{ name: 'description', content: loaderData.product.description },
],
}),
component: ProductPage,
})
En apparence, tout est logique. Chaque niveau de la hiérarchie déclare ses propres metas. La leaf route (produit/$slug) déclare un title dynamique basé sur les données du loader.
Ce que le développeur voit vs ce que Googlebot reçoit
Côté client, TanStack Router résout la hiérarchie correctement après hydratation. Le head de la leaf route remplace celui du layout dans le DOM vivant. L'onglet du navigateur affiche "Robe Lin Marine — NomDuSite". Les tests Playwright passent.
Côté serveur (SSR), le problème est différent. Dans TanStack Router v1.x, la résolution du head pendant le rendu serveur suit un modèle de merge ascendant : les metas sont collectées de la racine vers la feuille, et les clés identiques sont écrasées dans l'ordre de la résolution. Mais — et c'est là le piège — si le head de la leaf route dépend de données asynchrones (le loader), et que la configuration SSR ne gère pas correctement l'attente du loader avant la collecte des metas, le title du layout est déjà injecté dans le HTML avant que le loader de la leaf ne résolve.
Concrètement, voici ce que le serveur produit :
<!DOCTYPE html>
<html>
<head>
<title>Catalogue — NomDuSite</title>
<meta name="description" content="Parcourez notre catalogue." />
<!-- ... autres metas du layout -->
</head>
<body>
<div id="root">
<!-- HTML SSR avec les données produit dans le body -->
<h1>Robe Lin Marine</h1>
<p>Robe en lin lavé, coupe droite...</p>
</div>
<script>
// Le head correct est appliqué ici, côté client, après hydratation
window.__TSR_DEHYDRATED__ = { ... }
</script>
</body>
</html>
Le body contient les bonnes données produit — le loader a bien résolu. Mais le <head> contient le title du layout parent. Le problème réside dans la séquence SSR du @tanstack/react-router-server : la fonction head est collectée au moment de la construction de l'arbre de routes, pas après la résolution complète de tous les loaders.
Pourquoi les tests n'ont rien détecté
L'équipe avait trois couches de tests :
- Tests unitaires des loaders — ils vérifiaient que
fetchProductretournait les bonnes données. Pas de test sur lehead. - Tests Playwright e2e — ils naviguaient vers une fiche produit et vérifiaient
await page.title(). Playwright exécute le JavaScript. Le title côté client était correct. - Tests de snapshot HTML — l'équipe n'en avait pas. C'est le trou dans la raquette.
Le problème classique : personne ne testait le HTML brut renvoyé par le serveur. Personne ne faisait un curl sur les URLs après déploiement. La CI ne simulait pas un crawler HTTP-only.
C'est un pattern qu'on retrouve dans d'autres frameworks. L'incident est similaire à ce qui se produit avec Nuxt et useSeoMeta quand un composant enfant override silencieusement les metas du layout parent, ou avec SvelteKit quand le layout.ts voit son title écrasé par une page vide. La direction du merge change selon le framework, mais le symptôme est le même : une divergence entre ce que le navigateur affiche et ce que le serveur sert.
Reproduction step-by-step
Pour confirmer le diagnostic, l'équipe a reproduit le bug localement en 4 étapes :
- Démarrer le serveur SSR en mode production :
npm run build && npm run serve - Faire un curl sur une fiche produit :
curl -s http://localhost:3000/catalogue/produit/robe-lin-marine \ -H "User-Agent: Mozilla/5.0 (compatible; Googlebot/2.1)" \ | head -20 - Observer le
<title>dans la sortie : "Catalogue — NomDuSite". - Ouvrir la même URL dans Chrome, attendre l'hydratation, vérifier
document.titledans la console : "Robe Lin Marine — NomDuSite".
La divergence est confirmée. Le SSR produit le title du layout. Le CSR corrige après hydratation. Googlebot, qui fait du rendu JavaScript mais conserve souvent le HTML initial pour les metas critiques comme le <title>, indexe le mauvais title.
La documentation Google sur le rendu JavaScript précise que le rendering peut prendre des heures ou des jours après le crawl initial. Pendant ce délai, c'est le HTML brut qui fait référence.
Le fix : forcer la résolution du head après les loaders
Le correctif comporte deux parties : un fix immédiat sur la séquence SSR, et un garde-fou à long terme.
Partie 1 — Attendre les loaders avant de collecter le head
Le problème vient de l'entry server. L'équipe utilisait createStartHandler de @tanstack/start avec une configuration par défaut. La solution : s'assurer que les données des loaders sont entièrement résolues avant que le HTML du <head> ne soit sérialisé.
Dans app/ssr.tsx (le point d'entrée serveur), le correctif consiste à utiliser router.load() explicitement et à attendre sa résolution avant de rendre :
// app/ssr.tsx — AVANT (configuration par défaut)
import { createStartHandler, defaultStreamHandler } from '@tanstack/start/server'
import { createRouter } from './router'
export default createStartHandler({
createRouter,
})(defaultStreamHandler)
// app/ssr.tsx — APRÈS (attente explicite des loaders)
import { createStartHandler } from '@tanstack/start/server'
import { createRouter } from './router'
import { renderToString } from 'react-dom/server'
import { StartServer } from '@tanstack/start/server'
export default createStartHandler({
createRouter,
})(async ({ request, router }) => {
// Forcer le chargement complet de toutes les routes matchées
// y compris les loaders des leaf routes
await router.load()
const html = renderToString(<StartServer router={router} />)
return new Response(`<!DOCTYPE html>${html}`, {
status: router.state.statusCode,
headers: {
'Content-Type': 'text/html; charset=utf-8',
},
})
})
Le point clé : await router.load() garantit que tous les loaders de la hiérarchie de routes — y compris celui de la leaf route produit/$slug — ont résolu avant que renderToString ne produise le HTML. Le head de la leaf route, qui dépend de loaderData, a maintenant accès aux données.
Partie 2 — Test automatisé sur le HTML brut
L'équipe ajoute un test d'intégration qui vérifie le HTML SSR sans exécuter de JavaScript :
// tests/ssr-titles.test.ts
import { describe, it, expect } from 'vitest'
const SSR_BASE = 'http://localhost:3000'
const testCases = [
{ url: '/catalogue/produit/robe-lin-marine', expectedTitle: 'Robe Lin Marine — NomDuSite' },
{ url: '/catalogue/produit/jean-selvedge-brut', expectedTitle: 'Jean Selvedge Brut — NomDuSite' },
{ url: '/catalogue', expectedTitle: 'Catalogue — NomDuSite' },
]
describe('SSR title integrity', () => {
testCases.forEach(({ url, expectedTitle }) => {
it(`${url} should have correct SSR title`, async () => {
const res = await fetch(`${SSR_BASE}${url}`)
const html = await res.text()
const match = html.match(/<title>(.*?)<\/title>/)
expect(match).not.toBeNull()
expect(match![1]).toBe(expectedTitle)
})
})
})
Ce test tourne dans la CI sur le serveur SSR démarré en mode production. Pas de navigateur headless. Un simple fetch HTTP. Si le title SSR diverge du title attendu, la CI casse.
Déploiement et récupération
Le fix est déployé un mardi à 11h. L'équipe purge le cache Vercel via la CLI :
vercel --prod --force
Puis elle demande une ré-indexation dans Search Console pour les 50 URLs produit les plus stratégiques (celles avec le plus d'impressions). Le reste est laissé au crawl naturel.
Chronologie de récupération observée :
- J+2 : Googlebot recrawle 38 des 50 URLs soumises. Les titles dans le cache Google sont corrigés.
- J+5 : 189 des 312 fiches produit affichent le bon title dans les SERPs.
- J+9 : 298 URLs corrigées. Le CTR moyen remonte à 2,9 %.
- J+16 : Retour au CTR de référence (3,7 %). Les clics quotidiens reviennent à 4 000+.
- J+21 : Récupération complète. Les 14 URLs restantes (pages à très faible trafic) sont recrawlées naturellement.
Au total, l'incident a duré 39 jours entre le déploiement initial et la récupération complète. L'impact estimé : environ 36 000 clics perdus sur la période.
Leçons opérationnelles
L'équipe met en place trois garde-fous :
- Le test SSR title décrit ci-dessus, exécuté à chaque PR touchant le routing ou les composants page.
- Un script de smoke test post-deploy qui
curl10 URLs critiques et vérifie les<title>et<meta name="description">dans le HTML brut. - Une alerte Search Console sur le rapport "Améliorations > Balises title" : si le nombre de titles dupliqués dépasse un seuil, un webhook Slack notifie l'équipe.
Ces mesures rejoignent les patterns déjà documentés dans des incidents similaires — comme la migration Next.js où un metadata async qui throw servait le title par défaut, ou le composant heading d'un design system qui rendait un div au lieu d'un h1. Le pattern est toujours le même : une divergence entre ce que le développeur voit et ce que le crawler reçoit, invisible sans test HTTP brut.
Ce qu'on en retient
TanStack Router est un excellent routeur. Mais son modèle de head déclaratif, couplé à des loaders asynchrones, introduit un risque SSR que ni Next.js (avec son generateMetadata attendu nativement) ni Remix (avec sa fonction meta résolue côté serveur) ne présentent de la même façon. Le bug n'est pas dans le framework — il est dans l'hypothèse que le head sera toujours résolu après les données.
La règle est simple : tout ce que Googlebot voit doit être testé comme Googlebot le voit — un curl, pas un navigateur. Un monitoring continu type Seogard détecte ce type de divergence SSR/CSR en quelques minutes, avant que trois semaines de clics ne disparaissent dans un title générique.
Ne faites jamais confiance à l'onglet du navigateur pour valider vos metas. Faites confiance au curl.