[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"$fWq8CnYHgST9PzHWMidweCqWXntWDnjqeWD1RfBj3z5A":3,"$f_edj069ZLfLvv1jti9JJEk3wVn648Pf2qkQfHFhs8ak":25,"$fcTFjTaPPT1sVcnh1wWEGoTWMSaOD4bwmwntkMae6w5s":106},{"_id":4,"slug":5,"__v":6,"author":7,"body":8,"canonical":9,"category":10,"createdAt":11,"date":12,"description":13,"htmlContent":14,"image":15,"imageAlt":15,"readingTime":16,"tags":17,"title":23,"updatedAt":24},"6a2cf253aa6b273b0c0c9a5f","tanstack-router-ssr-title-pris-du-layout-au-lieu-de-la-leaf-route",0,"Equipe Seogard","# TanStack Router SSR : quand le title du layout écrase 312 fiches produit\n\nJeudi 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\".\n\n## Lundi T+18 jours — \"Pourquoi toutes nos fiches s'appellent Catalogue ?\"\n\n9h12. 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\"**.\n\nPremier 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 `\u003Ctitle>` est correct. Elle rafraîchit. Toujours correct.\n\n9h31. Elle contacte le lead dev : \"Les titles sont bons chez moi, mais Google affiche autre chose. Cache Google ?\"\n\nLe lead dev ouvre le cache Google d'une fiche produit. Le `\u003Ctitle>` dans le HTML mis en cache est \"Catalogue — NomDuSite\". Il vérifie une deuxième page. Même chose. Une troisième. Identique.\n\n9h47. 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 :\n\n```bash\ncurl -s https://www.exemple.com/produit/robe-lin-marine | grep '\u003Ctitle>'\n```\n\nRésultat :\n\n```html\n\u003Ctitle>Catalogue — NomDuSite\u003C/title>\n```\n\nLe 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.\n\n10h03. Le lead dev lance Screaming Frog en mode \"JavaScript rendering\" et en mode \"HTML brut\" sur 50 URLs produit. Résultat :\n\n| Mode | Title détecté |\n|---|---|\n| HTML brut | \"Catalogue — NomDuSite\" (50/50) |\n| Avec JS | \"Robe Lin Marine — NomDuSite\" (50/50) |\n\n10h22. 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.\n\nLa 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.\n\n10h40. 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.\n\n## Le bug : la hiérarchie de metas de TanStack Router n'est pas celle de Next.js\n\nPour comprendre la régression, il faut remonter à l'architecture de routing mise en place lors de la migration.\n\n### L'ancien monde : React Router + react-helmet-async\n\nAvec React Router v6, l'équipe utilisait `react-helmet-async` dans chaque composant page. Le `\u003CHelmet>` de la fiche produit écrasait celui du layout. L'ordre d'application était simple : le dernier `\u003CHelmet>` 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.\n\n### Le nouveau monde : TanStack Router et le piège du `head`\n\nTanStack 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.\n\nVoici la structure de routes mise en place par l'équipe :\n\n```typescript\n// routes/__root.tsx\nexport const Route = createRootRoute({\n  head: () => ({\n    meta: [\n      { title: 'NomDuSite' },\n      { name: 'description', content: 'La marketplace mode référence.' },\n    ],\n  }),\n  component: RootLayout,\n})\n```\n\n```typescript\n// routes/catalogue.tsx (layout route)\nexport const Route = createFileRoute('/catalogue')({\n  head: () => ({\n    meta: [\n      { title: 'Catalogue — NomDuSite' },\n      { name: 'description', content: 'Parcourez notre catalogue.' },\n    ],\n  }),\n  component: CatalogueLayout,\n})\n```\n\n```typescript\n// routes/catalogue/produit/$slug.tsx (leaf route)\nexport const Route = createFileRoute('/catalogue/produit/$slug')({\n  loader: async ({ params }) => {\n    const product = await fetchProduct(params.slug)\n    return { product }\n  },\n  head: ({ loaderData }) => ({\n    meta: [\n      { title: `${loaderData.product.name} — NomDuSite` },\n      { name: 'description', content: loaderData.product.description },\n    ],\n  }),\n  component: ProductPage,\n})\n```\n\nEn 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.\n\n### Ce que le développeur voit vs ce que Googlebot reçoit\n\nCô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.\n\nCô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**.\n\nConcrètement, voici ce que le serveur produit :\n\n```html\n\u003C!DOCTYPE html>\n\u003Chtml>\n\u003Chead>\n  \u003Ctitle>Catalogue — NomDuSite\u003C/title>\n  \u003Cmeta name=\"description\" content=\"Parcourez notre catalogue.\" />\n  \u003C!-- ... autres metas du layout -->\n\u003C/head>\n\u003Cbody>\n  \u003Cdiv id=\"root\">\n    \u003C!-- HTML SSR avec les données produit dans le body -->\n    \u003Ch1>Robe Lin Marine\u003C/h1>\n    \u003Cp>Robe en lin lavé, coupe droite...\u003C/p>\n  \u003C/div>\n  \u003Cscript>\n    // Le head correct est appliqué ici, côté client, après hydratation\n    window.__TSR_DEHYDRATED__ = { ... }\n  \u003C/script>\n\u003C/body>\n\u003C/html>\n```\n\nLe body contient les bonnes données produit — le loader a bien résolu. Mais le `\u003Chead>` 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.\n\n### Pourquoi les tests n'ont rien détecté\n\nL'équipe avait trois couches de tests :\n\n1. **Tests unitaires** des loaders — ils vérifiaient que `fetchProduct` retournait les bonnes données. Pas de test sur le `head`.\n2. **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.\n3. **Tests de snapshot HTML** — l'équipe n'en avait pas. C'est le trou dans la raquette.\n\nLe 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.\n\nC'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](/blog/nuxt-useseometa-le-child-override-silencieusement-les-meta-du-layout-parent), ou avec [SvelteKit quand le `layout.ts` voit son title écrasé par une page vide](/blog/sveltekit-layout-ts-title-override-par-page-svelte-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.\n\n### Reproduction step-by-step\n\nPour confirmer le diagnostic, l'équipe a reproduit le bug localement en 4 étapes :\n\n1. Démarrer le serveur SSR en mode production : `npm run build && npm run serve`\n2. Faire un curl sur une fiche produit :\n   ```bash\n   curl -s http://localhost:3000/catalogue/produit/robe-lin-marine \\\n     -H \"User-Agent: Mozilla/5.0 (compatible; Googlebot/2.1)\" \\\n     | head -20\n   ```\n3. Observer le `\u003Ctitle>` dans la sortie : \"Catalogue — NomDuSite\".\n4. Ouvrir la même URL dans Chrome, attendre l'hydratation, vérifier `document.title` dans la console : \"Robe Lin Marine — NomDuSite\".\n\nLa 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 `\u003Ctitle>`, indexe le mauvais title.\n\nLa [documentation Google sur le rendu JavaScript](https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics) 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.\n\n## Le fix : forcer la résolution du head après les loaders\n\nLe correctif comporte deux parties : un fix immédiat sur la séquence SSR, et un garde-fou à long terme.\n\n### Partie 1 — Attendre les loaders avant de collecter le head\n\nLe 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 `\u003Chead>` ne soit sérialisé.\n\nDans `app/ssr.tsx` (le point d'entrée serveur), le correctif consiste à utiliser `router.load()` explicitement et à attendre sa résolution avant de rendre :\n\n```typescript\n// app/ssr.tsx — AVANT (configuration par défaut)\nimport { createStartHandler, defaultStreamHandler } from '@tanstack/start/server'\nimport { createRouter } from './router'\n\nexport default createStartHandler({\n  createRouter,\n})(defaultStreamHandler)\n```\n\n```typescript\n// app/ssr.tsx — APRÈS (attente explicite des loaders)\nimport { createStartHandler } from '@tanstack/start/server'\nimport { createRouter } from './router'\nimport { renderToString } from 'react-dom/server'\nimport { StartServer } from '@tanstack/start/server'\n\nexport default createStartHandler({\n  createRouter,\n})(async ({ request, router }) => {\n  // Forcer le chargement complet de toutes les routes matchées\n  // y compris les loaders des leaf routes\n  await router.load()\n\n  const html = renderToString(\u003CStartServer router={router} />)\n\n  return new Response(`\u003C!DOCTYPE html>${html}`, {\n    status: router.state.statusCode,\n    headers: {\n      'Content-Type': 'text/html; charset=utf-8',\n    },\n  })\n})\n```\n\nLe 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.\n\n### Partie 2 — Test automatisé sur le HTML brut\n\nL'équipe ajoute un test d'intégration qui vérifie le HTML SSR sans exécuter de JavaScript :\n\n```typescript\n// tests/ssr-titles.test.ts\nimport { describe, it, expect } from 'vitest'\n\nconst SSR_BASE = 'http://localhost:3000'\n\nconst testCases = [\n  { url: '/catalogue/produit/robe-lin-marine', expectedTitle: 'Robe Lin Marine — NomDuSite' },\n  { url: '/catalogue/produit/jean-selvedge-brut', expectedTitle: 'Jean Selvedge Brut — NomDuSite' },\n  { url: '/catalogue', expectedTitle: 'Catalogue — NomDuSite' },\n]\n\ndescribe('SSR title integrity', () => {\n  testCases.forEach(({ url, expectedTitle }) => {\n    it(`${url} should have correct SSR title`, async () => {\n      const res = await fetch(`${SSR_BASE}${url}`)\n      const html = await res.text()\n      const match = html.match(/\u003Ctitle>(.*?)\u003C\\/title>/)\n      expect(match).not.toBeNull()\n      expect(match![1]).toBe(expectedTitle)\n    })\n  })\n})\n```\n\nCe 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.\n\n### Déploiement et récupération\n\nLe fix est déployé un mardi à 11h. L'équipe purge le cache Vercel via la CLI :\n\n```bash\nvercel --prod --force\n```\n\nPuis 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.\n\n**Chronologie de récupération observée :**\n\n- **J+2** : Googlebot recrawle 38 des 50 URLs soumises. Les titles dans le cache Google sont corrigés.\n- **J+5** : 189 des 312 fiches produit affichent le bon title dans les SERPs.\n- **J+9** : 298 URLs corrigées. Le CTR moyen remonte à 2,9 %.\n- **J+16** : Retour au CTR de référence (3,7 %). Les clics quotidiens reviennent à 4 000+.\n- **J+21** : Récupération complète. Les 14 URLs restantes (pages à très faible trafic) sont recrawlées naturellement.\n\nAu 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.\n\n### Leçons opérationnelles\n\nL'équipe met en place trois garde-fous :\n\n1. **Le test SSR title** décrit ci-dessus, exécuté à chaque PR touchant le routing ou les composants page.\n2. **Un script de smoke test post-deploy** qui `curl` 10 URLs critiques et vérifie les `\u003Ctitle>` et `\u003Cmeta name=\"description\">` dans le HTML brut.\n3. **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.\n\nCes 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](/blog/next-js-metadata-async-qui-throw-la-page-sert-le-fallback-default-next-js), ou [le composant heading d'un design system qui rendait un `div` au lieu d'un `h1`](/blog/design-system-composant-heading-qui-rend-div-selon-la-prop-as-mal-configuree). 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.\n\n## Ce qu'on en retient\n\nTanStack 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.\n\nLa 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.\n\nNe faites jamais confiance à l'onglet du navigateur pour valider vos metas. Faites confiance au `curl`.\n```","https://seogard.io/blog/tanstack-router-ssr-title-pris-du-layout-au-lieu-de-la-leaf-route","Framework","2026-06-13T06:01:55.020Z","2026-06-13","Un e-commerce perd 40 % de clics organiques : TanStack Router applique le title du layout parent au lieu de la leaf route. Récit, diagnostic, fix.","\u003Ch1>TanStack Router SSR : quand le title du layout écrase 312 fiches produit\u003C/h1>\n\u003Cp>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\".\u003C/p>\n\u003Ch2>Lundi T+18 jours — \"Pourquoi toutes nos fiches s'appellent Catalogue ?\"\u003C/h2>\n\u003Cp>9h12. La responsable SEO ouvre le rapport de performances Search Console sur le segment \u003Ccode>/produit/\u003C/code>. 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 : \u003Cstrong>\"Catalogue — NomDuSite\"\u003C/strong>.\u003C/p>\n\u003Cp>Premier réflexe : vérifier dans le navigateur. Elle ouvre \u003Ccode>/produit/robe-lin-marine\u003C/code>. L'onglet affiche bien \"Robe Lin Marine — NomDuSite\". Elle inspecte le DOM : le \u003Ccode>&#x3C;title>\u003C/code> est correct. Elle rafraîchit. Toujours correct.\u003C/p>\n\u003Cp>9h31. Elle contacte le lead dev : \"Les titles sont bons chez moi, mais Google affiche autre chose. Cache Google ?\"\u003C/p>\n\u003Cp>Le lead dev ouvre le cache Google d'une fiche produit. Le \u003Ccode>&#x3C;title>\u003C/code> dans le HTML mis en cache est \"Catalogue — NomDuSite\". Il vérifie une deuxième page. Même chose. Une troisième. Identique.\u003C/p>\n\u003Cp>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 \u003Ccode>curl\u003C/code> brut :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">curl\u003C/span>\u003Cspan style=\"color:#79B8FF\"> -s\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> https://www.exemple.com/produit/robe-lin-marine\u003C/span>\u003Cspan style=\"color:#F97583\"> |\u003C/span>\u003Cspan style=\"color:#B392F0\"> grep\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> '&#x3C;title>'\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Résultat :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">title\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>Catalogue — NomDuSite&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">title\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>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.\u003C/p>\n\u003Cp>10h03. Le lead dev lance Screaming Frog en mode \"JavaScript rendering\" et en mode \"HTML brut\" sur 50 URLs produit. Résultat :\u003C/p>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Mode\u003C/th>\n\u003Cth>Title détecté\u003C/th>\n\u003C/tr>\n\u003C/thead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>HTML brut\u003C/td>\n\u003Ctd>\"Catalogue — NomDuSite\" (50/50)\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>Avec JS\u003C/td>\n\u003Ctd>\"Robe Lin Marine — NomDuSite\" (50/50)\u003C/td>\n\u003C/tr>\n\u003C/tbody>\n\u003C/table>\n\u003Cp>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.\u003C/p>\n\u003Cp>La responsable SEO croise avec Search Console. Sur les 21 derniers jours, le segment \u003Ccode>/produit/\u003C/code> 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.\u003C/p>\n\u003Cp>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.\u003C/p>\n\u003Ch2>Le bug : la hiérarchie de metas de TanStack Router n'est pas celle de Next.js\u003C/h2>\n\u003Cp>Pour comprendre la régression, il faut remonter à l'architecture de routing mise en place lors de la migration.\u003C/p>\n\u003Ch3>L'ancien monde : React Router + react-helmet-async\u003C/h3>\n\u003Cp>Avec React Router v6, l'équipe utilisait \u003Ccode>react-helmet-async\u003C/code> dans chaque composant page. Le \u003Ccode>&#x3C;Helmet>\u003C/code> de la fiche produit écrasait celui du layout. L'ordre d'application était simple : le dernier \u003Ccode>&#x3C;Helmet>\u003C/code> monté gagne. Le SSR produisait le bon title parce que \u003Ccode>react-helmet-async\u003C/code> collectait toutes les instances pendant le rendu serveur et prenait la plus profonde.\u003C/p>\n\u003Ch3>Le nouveau monde : TanStack Router et le piège du \u003Ccode>head\u003C/code>\u003C/h3>\n\u003Cp>TanStack Router v1 introduit un système de \u003Ccode>head\u003C/code> déclaratif directement dans la définition des routes, via la propriété \u003Ccode>head\u003C/code> du \u003Ccode>createFileRoute\u003C/code> ou \u003Ccode>createRoute\u003C/code>. L'idée est séduisante : plus besoin de helmet, les metas sont colocalisées avec la route.\u003C/p>\n\u003Cp>Voici la structure de routes mise en place par l'équipe :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// routes/__root.tsx\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">export\u003C/span>\u003Cspan style=\"color:#F97583\"> const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> Route\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#B392F0\"> createRootRoute\u003C/span>\u003Cspan style=\"color:#E1E4E8\">({\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">  head\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: () \u003C/span>\u003Cspan style=\"color:#F97583\">=>\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> ({\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    meta: [\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      { title: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'NomDuSite'\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      { name: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'description'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, content: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'La marketplace mode référence.'\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    ],\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  }),\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  component: RootLayout,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">})\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// routes/catalogue.tsx (layout route)\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">export\u003C/span>\u003Cspan style=\"color:#F97583\"> const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> Route\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#B392F0\"> createFileRoute\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'/catalogue'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">)({\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">  head\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: () \u003C/span>\u003Cspan style=\"color:#F97583\">=>\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> ({\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    meta: [\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      { title: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'Catalogue — NomDuSite'\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      { name: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'description'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, content: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'Parcourez notre catalogue.'\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    ],\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  }),\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  component: CatalogueLayout,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">})\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// routes/catalogue/produit/$slug.tsx (leaf route)\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">export\u003C/span>\u003Cspan style=\"color:#F97583\"> const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> Route\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#B392F0\"> createFileRoute\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'/catalogue/produit/$slug'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">)({\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">  loader\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#F97583\">async\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> ({ \u003C/span>\u003Cspan style=\"color:#FFAB70\">params\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> }) \u003C/span>\u003Cspan style=\"color:#F97583\">=>\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">    const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> product\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#F97583\"> await\u003C/span>\u003Cspan style=\"color:#B392F0\"> fetchProduct\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(params.slug)\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">    return\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { product }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">  head\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: ({ \u003C/span>\u003Cspan style=\"color:#FFAB70\">loaderData\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> }) \u003C/span>\u003Cspan style=\"color:#F97583\">=>\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> ({\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    meta: [\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      { title: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">`${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">loaderData\u003C/span>\u003Cspan style=\"color:#9ECBFF\">.\u003C/span>\u003Cspan style=\"color:#E1E4E8\">product\u003C/span>\u003Cspan style=\"color:#9ECBFF\">.\u003C/span>\u003Cspan style=\"color:#E1E4E8\">name\u003C/span>\u003Cspan style=\"color:#9ECBFF\">} — NomDuSite`\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      { name: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'description'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, content: loaderData.product.description },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    ],\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  }),\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  component: ProductPage,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">})\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>En apparence, tout est logique. Chaque niveau de la hiérarchie déclare ses propres metas. La leaf route (\u003Ccode>produit/$slug\u003C/code>) déclare un title dynamique basé sur les données du loader.\u003C/p>\n\u003Ch3>Ce que le développeur voit vs ce que Googlebot reçoit\u003C/h3>\n\u003Cp>Côté client, TanStack Router résout la hiérarchie correctement \u003Cstrong>après hydratation\u003C/strong>. Le \u003Ccode>head\u003C/code> 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.\u003C/p>\n\u003Cp>Côté serveur (SSR), le problème est différent. Dans TanStack Router v1.x, la résolution du \u003Ccode>head\u003C/code> pendant le rendu serveur suit un modèle de \u003Cstrong>merge ascendant\u003C/strong> : les metas sont collectées de la racine vers la feuille, et les clés identiques sont écrasées \u003Cstrong>dans l'ordre de la résolution\u003C/strong>. Mais — et c'est là le piège — si le \u003Ccode>head\u003C/code> de la leaf route dépend de données asynchrones (le \u003Ccode>loader\u003C/code>), et que la configuration SSR ne gère pas correctement l'attente du loader avant la collecte des metas, \u003Cstrong>le title du layout est déjà injecté dans le HTML avant que le loader de la leaf ne résolve\u003C/strong>.\u003C/p>\n\u003Cp>Concrètement, voici ce que le serveur produit :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;!\u003C/span>\u003Cspan style=\"color:#85E89D\">DOCTYPE\u003C/span>\u003Cspan style=\"color:#B392F0\"> html\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">html\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">head\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">title\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>Catalogue — NomDuSite&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">title\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">meta\u003C/span>\u003Cspan style=\"color:#B392F0\"> name\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"description\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> content\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"Parcourez notre catalogue.\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> />\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">  &#x3C;!-- ... autres metas du layout -->\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">head\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">body\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">div\u003C/span>\u003Cspan style=\"color:#B392F0\"> id\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"root\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">    &#x3C;!-- HTML SSR avec les données produit dans le body -->\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">h1\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>Robe Lin Marine&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">h1\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">p\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>Robe en lin lavé, coupe droite...&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">p\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  &#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">div\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">script\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">    // Le head correct est appliqué ici, côté client, après hydratation\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    window.__TSR_DEHYDRATED__ \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { \u003C/span>\u003Cspan style=\"color:#F97583\">...\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  &#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">script\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">body\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">html\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Le body contient les bonnes données produit — le loader a bien résolu. Mais le \u003Ccode>&#x3C;head>\u003C/code> contient le title du layout parent. Le problème réside dans la séquence SSR du \u003Ccode>@tanstack/react-router-server\u003C/code> : la fonction \u003Ccode>head\u003C/code> est collectée \u003Cstrong>au moment de la construction de l'arbre de routes\u003C/strong>, pas après la résolution complète de tous les loaders.\u003C/p>\n\u003Ch3>Pourquoi les tests n'ont rien détecté\u003C/h3>\n\u003Cp>L'équipe avait trois couches de tests :\u003C/p>\n\u003Col>\n\u003Cli>\u003Cstrong>Tests unitaires\u003C/strong> des loaders — ils vérifiaient que \u003Ccode>fetchProduct\u003C/code> retournait les bonnes données. Pas de test sur le \u003Ccode>head\u003C/code>.\u003C/li>\n\u003Cli>\u003Cstrong>Tests Playwright e2e\u003C/strong> — ils naviguaient vers une fiche produit et vérifiaient \u003Ccode>await page.title()\u003C/code>. Playwright exécute le JavaScript. Le title côté client était correct.\u003C/li>\n\u003Cli>\u003Cstrong>Tests de snapshot HTML\u003C/strong> — l'équipe n'en avait pas. C'est le trou dans la raquette.\u003C/li>\n\u003C/ol>\n\u003Cp>Le problème classique : personne ne testait le HTML brut renvoyé par le serveur. Personne ne faisait un \u003Ccode>curl\u003C/code> sur les URLs après déploiement. La CI ne simulait pas un crawler HTTP-only.\u003C/p>\n\u003Cp>C'est un pattern qu'on retrouve dans d'autres frameworks. L'incident est similaire à ce qui se produit avec \u003Ca href=\"/blog/nuxt-useseometa-le-child-override-silencieusement-les-meta-du-layout-parent\">Nuxt et \u003Ccode>useSeoMeta\u003C/code> quand un composant enfant override silencieusement les metas du layout parent\u003C/a>, ou avec \u003Ca href=\"/blog/sveltekit-layout-ts-title-override-par-page-svelte-vide\">SvelteKit quand le \u003Ccode>layout.ts\u003C/code> voit son title écrasé par une page vide\u003C/a>. 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.\u003C/p>\n\u003Ch3>Reproduction step-by-step\u003C/h3>\n\u003Cp>Pour confirmer le diagnostic, l'équipe a reproduit le bug localement en 4 étapes :\u003C/p>\n\u003Col>\n\u003Cli>Démarrer le serveur SSR en mode production : \u003Ccode>npm run build &#x26;&#x26; npm run serve\u003C/code>\u003C/li>\n\u003Cli>Faire un curl sur une fiche produit :\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">curl\u003C/span>\u003Cspan style=\"color:#79B8FF\"> -s\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> http://localhost:3000/catalogue/produit/robe-lin-marine\u003C/span>\u003Cspan style=\"color:#79B8FF\"> \\\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">  -H\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> \"User-Agent: Mozilla/5.0 (compatible; Googlebot/2.1)\"\u003C/span>\u003Cspan style=\"color:#79B8FF\"> \\\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  |\u003C/span>\u003Cspan style=\"color:#B392F0\"> head\u003C/span>\u003Cspan style=\"color:#79B8FF\"> -20\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003C/li>\n\u003Cli>Observer le \u003Ccode>&#x3C;title>\u003C/code> dans la sortie : \"Catalogue — NomDuSite\".\u003C/li>\n\u003Cli>Ouvrir la même URL dans Chrome, attendre l'hydratation, vérifier \u003Ccode>document.title\u003C/code> dans la console : \"Robe Lin Marine — NomDuSite\".\u003C/li>\n\u003C/ol>\n\u003Cp>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 \u003Ccode>&#x3C;title>\u003C/code>, indexe le mauvais title.\u003C/p>\n\u003Cp>La \u003Ca href=\"https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics\">documentation Google sur le rendu JavaScript\u003C/a> 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.\u003C/p>\n\u003Ch2>Le fix : forcer la résolution du head après les loaders\u003C/h2>\n\u003Cp>Le correctif comporte deux parties : un fix immédiat sur la séquence SSR, et un garde-fou à long terme.\u003C/p>\n\u003Ch3>Partie 1 — Attendre les loaders avant de collecter le head\u003C/h3>\n\u003Cp>Le problème vient de l'entry server. L'équipe utilisait \u003Ccode>createStartHandler\u003C/code> de \u003Ccode>@tanstack/start\u003C/code> 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 \u003Ccode>&#x3C;head>\u003C/code> ne soit sérialisé.\u003C/p>\n\u003Cp>Dans \u003Ccode>app/ssr.tsx\u003C/code> (le point d'entrée serveur), le correctif consiste à utiliser \u003Ccode>router.load()\u003C/code> explicitement et à attendre sa résolution avant de rendre :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// app/ssr.tsx — AVANT (configuration par défaut)\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { createStartHandler, defaultStreamHandler } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> '@tanstack/start/server'\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { createRouter } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> './router'\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">export\u003C/span>\u003Cspan style=\"color:#F97583\"> default\u003C/span>\u003Cspan style=\"color:#B392F0\"> createStartHandler\u003C/span>\u003Cspan style=\"color:#E1E4E8\">({\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  createRouter,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">})(defaultStreamHandler)\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// app/ssr.tsx — APRÈS (attente explicite des loaders)\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { createStartHandler } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> '@tanstack/start/server'\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { createRouter } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> './router'\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { renderToString } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'react-dom/server'\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { StartServer } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> '@tanstack/start/server'\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">export\u003C/span>\u003Cspan style=\"color:#F97583\"> default\u003C/span>\u003Cspan style=\"color:#B392F0\"> createStartHandler\u003C/span>\u003Cspan style=\"color:#E1E4E8\">({\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  createRouter,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">})(\u003C/span>\u003Cspan style=\"color:#F97583\">async\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> ({ \u003C/span>\u003Cspan style=\"color:#FFAB70\">request\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#FFAB70\">router\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> }) \u003C/span>\u003Cspan style=\"color:#F97583\">=>\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">  // Forcer le chargement complet de toutes les routes matchées\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">  // y compris les loaders des leaf routes\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  await\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> router.\u003C/span>\u003Cspan style=\"color:#B392F0\">load\u003C/span>\u003Cspan style=\"color:#E1E4E8\">()\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> html\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#B392F0\"> renderToString\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(&#x3C;\u003C/span>\u003Cspan style=\"color:#B392F0\">StartServer\u003C/span>\u003Cspan style=\"color:#B392F0\"> router\u003C/span>\u003Cspan style=\"color:#E1E4E8\">={\u003C/span>\u003Cspan style=\"color:#FFAB70\">router\u003C/span>\u003Cspan style=\"color:#E1E4E8\">} />)\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  return\u003C/span>\u003Cspan style=\"color:#F97583\"> new\u003C/span>\u003Cspan style=\"color:#B392F0\"> Response\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">`&#x3C;!DOCTYPE html>${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">html\u003C/span>\u003Cspan style=\"color:#9ECBFF\">}`\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    status: router.state.statusCode,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    headers: {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">      'Content-Type'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'text/html; charset=utf-8'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  })\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">})\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Le point clé : \u003Ccode>await router.load()\u003C/code> garantit que tous les loaders de la hiérarchie de routes — y compris celui de la leaf route \u003Ccode>produit/$slug\u003C/code> — ont résolu avant que \u003Ccode>renderToString\u003C/code> ne produise le HTML. Le \u003Ccode>head\u003C/code> de la leaf route, qui dépend de \u003Ccode>loaderData\u003C/code>, a maintenant accès aux données.\u003C/p>\n\u003Ch3>Partie 2 — Test automatisé sur le HTML brut\u003C/h3>\n\u003Cp>L'équipe ajoute un test d'intégration qui vérifie le HTML SSR sans exécuter de JavaScript :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// tests/ssr-titles.test.ts\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { describe, it, expect } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'vitest'\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> SSR_BASE\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'http://localhost:3000'\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> testCases\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> [\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  { url: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'/catalogue/produit/robe-lin-marine'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, expectedTitle: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'Robe Lin Marine — NomDuSite'\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  { url: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'/catalogue/produit/jean-selvedge-brut'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, expectedTitle: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'Jean Selvedge Brut — NomDuSite'\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  { url: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'/catalogue'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, expectedTitle: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'Catalogue — NomDuSite'\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">]\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">describe\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'SSR title integrity'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, () \u003C/span>\u003Cspan style=\"color:#F97583\">=>\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  testCases.\u003C/span>\u003Cspan style=\"color:#B392F0\">forEach\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(({ \u003C/span>\u003Cspan style=\"color:#FFAB70\">url\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#FFAB70\">expectedTitle\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> }) \u003C/span>\u003Cspan style=\"color:#F97583\">=>\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">    it\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">`${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">url\u003C/span>\u003Cspan style=\"color:#9ECBFF\">} should have correct SSR title`\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#F97583\">async\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> () \u003C/span>\u003Cspan style=\"color:#F97583\">=>\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">      const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> res\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#F97583\"> await\u003C/span>\u003Cspan style=\"color:#B392F0\"> fetch\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">`${\u003C/span>\u003Cspan style=\"color:#79B8FF\">SSR_BASE\u003C/span>\u003Cspan style=\"color:#9ECBFF\">}${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">url\u003C/span>\u003Cspan style=\"color:#9ECBFF\">}`\u003C/span>\u003Cspan style=\"color:#E1E4E8\">)\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">      const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> html\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#F97583\"> await\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> res.\u003C/span>\u003Cspan style=\"color:#B392F0\">text\u003C/span>\u003Cspan style=\"color:#E1E4E8\">()\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">      const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> match\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> html.\u003C/span>\u003Cspan style=\"color:#B392F0\">match\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">/\u003C/span>\u003Cspan style=\"color:#DBEDFF\">&#x3C;title>(\u003C/span>\u003Cspan style=\"color:#79B8FF\">.\u003C/span>\u003Cspan style=\"color:#F97583\">*?\u003C/span>\u003Cspan style=\"color:#DBEDFF\">)&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D;font-weight:bold\">\\/\u003C/span>\u003Cspan style=\"color:#DBEDFF\">title>\u003C/span>\u003Cspan style=\"color:#9ECBFF\">/\u003C/span>\u003Cspan style=\"color:#E1E4E8\">)\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">      expect\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(match).not.\u003C/span>\u003Cspan style=\"color:#B392F0\">toBeNull\u003C/span>\u003Cspan style=\"color:#E1E4E8\">()\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">      expect\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(match\u003C/span>\u003Cspan style=\"color:#F97583\">!\u003C/span>\u003Cspan style=\"color:#E1E4E8\">[\u003C/span>\u003Cspan style=\"color:#79B8FF\">1\u003C/span>\u003Cspan style=\"color:#E1E4E8\">]).\u003C/span>\u003Cspan style=\"color:#B392F0\">toBe\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(expectedTitle)\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    })\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  })\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">})\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Ce test tourne dans la CI sur le serveur SSR démarré en mode production. Pas de navigateur headless. Un simple \u003Ccode>fetch\u003C/code> HTTP. Si le title SSR diverge du title attendu, la CI casse.\u003C/p>\n\u003Ch3>Déploiement et récupération\u003C/h3>\n\u003Cp>Le fix est déployé un mardi à 11h. L'équipe purge le cache Vercel via la CLI :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">vercel\u003C/span>\u003Cspan style=\"color:#79B8FF\"> --prod\u003C/span>\u003Cspan style=\"color:#79B8FF\"> --force\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>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.\u003C/p>\n\u003Cp>\u003Cstrong>Chronologie de récupération observée :\u003C/strong>\u003C/p>\n\u003Cul>\n\u003Cli>\u003Cstrong>J+2\u003C/strong> : Googlebot recrawle 38 des 50 URLs soumises. Les titles dans le cache Google sont corrigés.\u003C/li>\n\u003Cli>\u003Cstrong>J+5\u003C/strong> : 189 des 312 fiches produit affichent le bon title dans les SERPs.\u003C/li>\n\u003Cli>\u003Cstrong>J+9\u003C/strong> : 298 URLs corrigées. Le CTR moyen remonte à 2,9 %.\u003C/li>\n\u003Cli>\u003Cstrong>J+16\u003C/strong> : Retour au CTR de référence (3,7 %). Les clics quotidiens reviennent à 4 000+.\u003C/li>\n\u003Cli>\u003Cstrong>J+21\u003C/strong> : Récupération complète. Les 14 URLs restantes (pages à très faible trafic) sont recrawlées naturellement.\u003C/li>\n\u003C/ul>\n\u003Cp>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.\u003C/p>\n\u003Ch3>Leçons opérationnelles\u003C/h3>\n\u003Cp>L'équipe met en place trois garde-fous :\u003C/p>\n\u003Col>\n\u003Cli>\u003Cstrong>Le test SSR title\u003C/strong> décrit ci-dessus, exécuté à chaque PR touchant le routing ou les composants page.\u003C/li>\n\u003Cli>\u003Cstrong>Un script de smoke test post-deploy\u003C/strong> qui \u003Ccode>curl\u003C/code> 10 URLs critiques et vérifie les \u003Ccode>&#x3C;title>\u003C/code> et \u003Ccode>&#x3C;meta name=\"description\">\u003C/code> dans le HTML brut.\u003C/li>\n\u003Cli>\u003Cstrong>Une alerte Search Console\u003C/strong> sur le rapport \"Améliorations > Balises title\" : si le nombre de titles dupliqués dépasse un seuil, un webhook Slack notifie l'équipe.\u003C/li>\n\u003C/ol>\n\u003Cp>Ces mesures rejoignent les patterns déjà documentés dans des incidents similaires — comme \u003Ca href=\"/blog/next-js-metadata-async-qui-throw-la-page-sert-le-fallback-default-next-js\">la migration Next.js où un \u003Ccode>metadata\u003C/code> async qui throw servait le title par défaut\u003C/a>, ou \u003Ca href=\"/blog/design-system-composant-heading-qui-rend-div-selon-la-prop-as-mal-configuree\">le composant heading d'un design system qui rendait un \u003Ccode>div\u003C/code> au lieu d'un \u003Ccode>h1\u003C/code>\u003C/a>. 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.\u003C/p>\n\u003Ch2>Ce qu'on en retient\u003C/h2>\n\u003Cp>TanStack Router est un excellent routeur. Mais son modèle de \u003Ccode>head\u003C/code> déclaratif, couplé à des loaders asynchrones, introduit un risque SSR que ni Next.js (avec son \u003Ccode>generateMetadata\u003C/code> attendu nativement) ni Remix (avec sa fonction \u003Ccode>meta\u003C/code> 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 \u003Ccode>head\u003C/code> sera toujours résolu après les données.\u003C/p>\n\u003Cp>La règle est simple : \u003Cstrong>tout ce que Googlebot voit doit être testé comme Googlebot le voit\u003C/strong> — un \u003Ccode>curl\u003C/code>, 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.\u003C/p>\n\u003Cp>Ne faites jamais confiance à l'onglet du navigateur pour valider vos metas. Faites confiance au \u003Ccode>curl\u003C/code>.\u003C/p>\n\u003Cpre>\u003Ccode>\u003C/code>\u003C/pre>",null,12,[18,19,20,21,22],"tanstack router","react","ssr","title","meta tags","TanStack Router SSR : le title vient du layout, pas de la page","Sat Jun 13 2026 06:01:55 GMT+0000 (Coordinated Universal Time)",[26,41,54,68,79,93],{"_id":27,"slug":28,"__v":6,"author":7,"canonical":29,"category":10,"createdAt":30,"date":31,"description":32,"image":15,"imageAlt":15,"readingTime":33,"tags":34,"title":39,"updatedAt":40},"6a2ba0d0aa6b273b0cf5b507","astro-view-transitions-changement-de-route-ne-re-trigge-pas-le-head-update","https://seogard.io/blog/astro-view-transitions-changement-de-route-ne-re-trigge-pas-le-head-update","2026-06-12T06:01:52.619Z","2026-06-12","Un site Astro perd 40% de clics : les View Transitions ne mettent pas à jour les meta SEO lors des changements de route. Récit, diagnostic et fix.",11,[35,36,37,38],"astro","view transitions","head","spa","Astro View Transitions : meta head figées après navigation","Fri Jun 12 2026 06:01:52 GMT+0000 (Coordinated Universal Time)",{"_id":42,"slug":43,"__v":6,"author":7,"canonical":44,"category":10,"createdAt":45,"date":46,"description":47,"image":15,"imageAlt":15,"readingTime":16,"tags":48,"title":52,"updatedAt":53},"6a2adbecaa6b273b0c53007c","remix-meta-async-non-awaited-metas-vides-en-streaming","https://seogard.io/blog/remix-meta-async-non-awaited-metas-vides-en-streaming","2026-06-11T16:01:48.933Z","2026-06-11","Un site Remix perd 30% de trafic organique. La cause : meta() async non awaited, les balises arrivent après la fermeture du head en streaming.",[49,50,51,20],"remix","meta","streaming","Remix meta() async : metas vides en streaming SSR","Thu Jun 11 2026 16:01:48 GMT+0000 (Coordinated Universal Time)",{"_id":55,"slug":56,"__v":6,"author":7,"canonical":57,"category":10,"createdAt":58,"date":59,"description":60,"image":15,"imageAlt":15,"readingTime":16,"tags":61,"title":66,"updatedAt":67},"6a28fdbcaa6b273b0cc7b544","nuxt-useseometa-le-child-override-silencieusement-les-meta-du-layout-parent","https://seogard.io/blog/nuxt-useseometa-le-child-override-silencieusement-les-meta-du-layout-parent","2026-06-10T06:01:32.891Z","2026-06-10","Un site Nuxt 3 perd ses meta par défaut sur 340 pages. Récit technique du bug useSeoMeta, diagnostic et fix du fallback layout.",[62,63,64,65,50],"nuxt","useSeoMeta","layout","override","Nuxt useSeoMeta : le child override les meta du layout","Wed Jun 10 2026 06:01:32 GMT+0000 (Coordinated Universal Time)",{"_id":69,"slug":70,"__v":6,"author":7,"canonical":71,"category":10,"createdAt":72,"date":59,"description":73,"image":15,"imageAlt":15,"readingTime":16,"tags":74,"title":77,"updatedAt":78},"6a298a64aa6b273b0c3bfcab","sveltekit-layout-ts-title-override-par-page-svelte-vide","https://seogard.io/blog/sveltekit-layout-ts-title-override-par-page-svelte-vide","2026-06-10T16:01:40.148Z","Un +page.svelte sans title écrase le layout parent. Googlebot voit un \u003Ctitle> vide. Récit, diagnostic et fix complet en SvelteKit.",[75,64,21,76],"sveltekit","svelte","SvelteKit : title vide en prod, 0 clic sur 3 semaines","Wed Jun 10 2026 16:01:40 GMT+0000 (Coordinated Universal Time)",{"_id":80,"slug":81,"__v":6,"author":7,"canonical":82,"category":10,"createdAt":83,"date":84,"description":85,"image":15,"imageAlt":15,"readingTime":16,"tags":86,"title":91,"updatedAt":92},"6a27ac47aa6b273b0cb0f6f6","next-js-metadata-async-qui-throw-la-page-sert-le-fallback-default-next-js","https://seogard.io/blog/next-js-metadata-async-qui-throw-la-page-sert-le-fallback-default-next-js","2026-06-09T06:01:43.334Z","2026-06-09","Une promise non gérée dans generateMetadata fait tomber les titles sur 1 200 pages produit. Récit technique, diagnostic et fix complet.",[87,88,89,90],"next.js","metadata","async","error","Next.js metadata async throw : Google indexe \\\"Next.js\\\" en title","Tue Jun 09 2026 06:01:43 GMT+0000 (Coordinated Universal Time)",{"_id":94,"slug":95,"__v":6,"author":7,"canonical":96,"category":10,"createdAt":97,"date":98,"description":99,"image":15,"imageAlt":15,"readingTime":33,"tags":100,"title":104,"updatedAt":105},"6a26e765aa6b273b0c0e5507","astro-content-collections-frontmatter-title-non-passe-apres-refacto","https://seogard.io/blog/astro-content-collections-frontmatter-title-non-passe-apres-refacto","2026-06-08T16:01:41.030Z","2026-06-08","Un upgrade Astro casse le mapping frontmatter → composant. 80 articles perdent leur title. Récit du bug, diagnostic technique et fix complet.",[35,101,102,103],"content collections","frontmatter","refacto","Astro Content Collections : 80 titles vides après refacto","Mon Jun 08 2026 16:01:41 GMT+0000 (Coordinated Universal Time)",{"categories":107},[108,112,116,120,124,126,130,133,136,140,144,147,150,154,157,160,163,166,170],{"category":109,"slug":110,"count":111},"Actualités SEO","actualites-seo",168,{"category":113,"slug":114,"count":115},"Migration","migration",18,{"category":117,"slug":118,"count":119},"Rendering","rendering",9,{"category":121,"slug":122,"count":123},"Performance","performance",8,{"category":10,"slug":125,"count":123},"framework",{"category":127,"slug":128,"count":129},"Crawl","crawl",7,{"category":131,"slug":132,"count":129},"SEO Technique","seo-technique",{"category":134,"slug":135,"count":129},"Meta Tags","meta-tags",{"category":137,"slug":138,"count":139},"Architecture","architecture",6,{"category":141,"slug":142,"count":143},"Structured Data","structured-data",5,{"category":145,"slug":146,"count":143},"JavaScript SEO","javascript-seo",{"category":148,"slug":149,"count":143},"Monitoring","monitoring",{"category":151,"slug":152,"count":153},"E-commerce","e-commerce",4,{"category":155,"slug":156,"count":153},"Avancé","avance",{"category":158,"slug":159,"count":153},"Refonte","refonte",{"category":161,"slug":162,"count":153},"Redirections","redirections",{"category":164,"slug":165,"count":153},"Outils","outils",{"category":167,"slug":168,"count":169},"IA & SEO","ia-seo",3,{"category":171,"slug":172,"count":169},"Contenu","contenu"]