[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"$fgaCHN4r07GSbadAzN6mI8HbagAFXUHQTG4VpkntzJWo":3,"$fFobDPzquIZLwL7NN3iPHmNVlHmblFXZuyAq3mLS99xU":24,"$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":22,"updatedAt":23},"6a2adbecaa6b273b0c53007c","remix-meta-async-non-awaited-metas-vides-en-streaming",0,"Equipe Seogard","# Remix meta() async non awaited : quand le streaming SSR envoie un head vide à Googlebot\n\nMercredi 14h20. Le déploiement passe en production sans alerte. L'application Remix v2.8 d'un comparateur financier français — 3 200 pages produit, 410K clics organiques mensuels — vient de recevoir une mise à jour du système de métadonnées. L'équipe a migré les anciennes fonctions `meta` synchrones vers un pattern async qui fetch des données enrichies depuis un CMS headless. Le navigateur affiche tout. Les tests Playwright sont verts. Dix-huit jours plus tard, Search Console affiche un effondrement : −128K clics. Les pages produit ont perdu leurs title et description dans l'index Google.\n\n## T+18 jours — Jeudi 9h12, l'alerte Search Console\n\nLe Lead SEO ouvre son rapport hebdomadaire dans Google Search Console. La courbe de clics plonge. Pas un segment isolé — la quasi-totalité des pages `/produit/*` est touchée. Il filtre par type de page. Les 3 200 URLs produit affichent une chute moyenne de position de 4.2 à 11.7 sur les requêtes principales.\n\nPremier réflexe : vérifier le déploiement récent. Le changelog Git ne montre rien d'alarmant. Pas de modification du `robots.txt`. Pas de `noindex` ajouté. Le développeur fullstack vérifie dans Chrome : les balises meta sont bien présentes dans le DOM. Title correct, description correcte, Open Graph complet.\n\nL'hypothèse initiale : un problème d'indexation côté Google. Peut-être un crawl budget temporairement réduit. L'équipe attend 48 heures.\n\nSamedi, le Lead SEO lance un crawl Screaming Frog en mode \"JavaScript rendering\". Surprise : les 3 200 pages produit remontent avec un title et une description. Tout semble normal. Il switch en mode \"HTML brut\" — sans exécution JavaScript. Les balises `\u003Ctitle>` et `\u003Cmeta name=\"description\">` sont absentes du `\u003Chead>`. Totalement absentes.\n\nIl ouvre l'outil d'inspection d'URL dans Search Console. Le HTML rendu par Google confirme : le `\u003Chead>` est vide de métadonnées SEO. Le `\u003Cbody>` contient le contenu. Mais les balises meta critiques n'apparaissent nulle part dans le snapshot.\n\nLe développeur fronce les sourcils. \"Impossible. C'est du SSR. Remix rend côté serveur.\"\n\nIl ouvre `curl` :\n\n```bash\ncurl -s https://www.exemple-comparateur.fr/produit/assurance-auto-premium \\\n  | head -80\n```\n\nLe `\u003Chead>` retourné contient le charset, le viewport, les liens CSS — mais aucun `\u003Ctitle>`, aucune `\u003Cmeta name=\"description\">`. Les balises meta SEO ne sont pas dans la réponse HTTP initiale.\n\nL'équipe comprend que ce n'est pas un bug mineur. Le SSR streaming de Remix envoie le head avant que les métadonnées ne soient résolues. Depuis 18 jours, Googlebot reçoit un head vide sur chaque page produit.\n\nImpact mesuré à ce stade : −128K clics sur 18 jours, −31% de trafic organique sur le segment produit, 3 200 pages affectées. Les pages catégories et le blog, qui utilisent encore l'ancien pattern synchrone, sont intacts.\n\n## Le bug : meta() async dans un pipeline de streaming qui n'attend pas\n\nPour comprendre le problème, il faut comprendre comment Remix gère le streaming SSR et la fonction `meta()`.\n\n### Le mécanisme normal\n\nDans Remix v2, chaque route peut exporter une fonction `meta()` qui retourne un tableau de descripteurs de métadonnées. En fonctionnement classique, Remix résout le `loader` de la route, puis passe les données au `meta()`, qui retourne les balises de manière synchrone :\n\n```typescript\n// app/routes/produit.$slug.tsx — version synchrone (avant la régression)\nimport type { MetaFunction } from \"@remix-run/node\";\n\nexport const loader = async ({ params }: LoaderFunctionArgs) => {\n  const produit = await db.produit.findUnique({\n    where: { slug: params.slug },\n  });\n  if (!produit) throw new Response(\"Not Found\", { status: 404 });\n  return json({ produit });\n};\n\nexport const meta: MetaFunction\u003Ctypeof loader> = ({ data }) => {\n  if (!data) return [];\n  return [\n    { title: `${data.produit.nom} — Comparateur Assurance` },\n    { name: \"description\", content: data.produit.metaDescription },\n    { property: \"og:title\", content: data.produit.nom },\n  ];\n};\n```\n\nIci, `meta()` est synchrone. Elle reçoit `data` depuis le loader déjà résolu. Le head est complet avant l'envoi du premier chunk HTML.\n\n### La modification fatale\n\nL'équipe voulait enrichir les métadonnées avec des données provenant d'un CMS headless (Strapi). Le développeur a transformé `meta()` en fonction async pour fetch des données SEO supplémentaires — des meta descriptions A/B testées, des titres enrichis par un rédacteur :\n\n```typescript\n// app/routes/produit.$slug.tsx — version async (la régression)\nimport type { MetaFunction } from \"@remix-run/node\";\n\nexport const loader = async ({ params }: LoaderFunctionArgs) => {\n  const produit = await db.produit.findUnique({\n    where: { slug: params.slug },\n  });\n  if (!produit) throw new Response(\"Not Found\", { status: 404 });\n  return json({ produit });\n};\n\n// ⚠️ meta() est maintenant async\nexport const meta: MetaFunction\u003Ctypeof loader> = async ({ data }) => {\n  if (!data) return [];\n\n  // Fetch des métadonnées enrichies depuis Strapi\n  const enriched = await fetch(\n    `https://cms.exemple-comparateur.fr/api/seo-meta/${data.produit.slug}`\n  );\n  const seoData = await enriched.json();\n\n  return [\n    { title: seoData.title || `${data.produit.nom} — Comparateur` },\n    { name: \"description\", content: seoData.description || data.produit.metaDescription },\n    { property: \"og:title\", content: seoData.ogTitle || data.produit.nom },\n    { property: \"og:image\", content: seoData.ogImage || data.produit.image },\n  ];\n};\n```\n\nTypeScript ne proteste pas. L'application compile. Les tests passent.\n\n### Pourquoi ça casse en streaming\n\nLe problème réside dans l'architecture de streaming SSR de Remix. Quand `entry.server.tsx` utilise `renderToPipeableStream` (React 18), le flux HTML est envoyé progressivement au client. Le `\u003Chead>` part en premier, dans le chunk initial.\n\nVoici le `entry.server.tsx` du projet :\n\n```typescript\n// app/entry.server.tsx\nimport { PassThrough } from \"node:stream\";\nimport { renderToPipeableStream } from \"react-dom/server\";\nimport { RemixServer } from \"@remix-run/react\";\nimport { createReadableStreamFromReadable } from \"@remix-run/node\";\nimport { isbot } from \"isbot\";\n\nexport default function handleRequest(\n  request: Request,\n  responseStatusCode: number,\n  responseHeaders: Headers,\n  remixContext: EntryContext\n) {\n  const userAgent = request.headers.get(\"user-agent\") || \"\";\n  const callbackName = isbot(userAgent) ? \"onAllReady\" : \"onShellReady\";\n\n  return new Promise((resolve, reject) => {\n    const { pipe } = renderToPipeableStream(\n      \u003CRemixServer context={remixContext} url={request.url} />,\n      {\n        [callbackName]: () => {\n          const body = new PassThrough();\n          responseHeaders.set(\"Content-Type\", \"text/html\");\n          resolve(\n            new Response(createReadableStreamFromReadable(body), {\n              headers: responseHeaders,\n              status: responseStatusCode,\n            })\n          );\n          pipe(body);\n        },\n        onError(error) {\n          responseStatusCode = 500;\n          console.error(error);\n        },\n      }\n    );\n  });\n}\n```\n\nLa ligne critique : `const callbackName = isbot(userAgent) ? \"onAllReady\" : \"onShellReady\"`.\n\nEn théorie, quand le user-agent est un bot (Googlebot), Remix utilise `onAllReady` — il attend que tout le HTML soit prêt avant de flusher. Pour les utilisateurs humains, `onShellReady` flushe le shell dès que possible.\n\nMais voici le piège : **Remix ne `await` pas la fonction `meta()` quand elle retourne une Promise**. Le framework appelle `meta()`, reçoit une Promise au lieu d'un tableau, et l'interprète comme une valeur falsy ou un objet non itérable. Le tableau de métadonnées n'est jamais injecté dans le `\u003Chead>`.\n\nLe résultat en HTML brut :\n\n```html\n\u003C!-- Ce que reçoit Googlebot (curl ou fetch brut) -->\n\u003C!DOCTYPE html>\n\u003Chtml lang=\"fr\">\n\u003Chead>\n  \u003Cmeta charset=\"utf-8\" />\n  \u003Cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n  \u003Clink rel=\"stylesheet\" href=\"/build/css/app-DK4X2.css\" />\n  \u003C!-- ❌ Pas de \u003Ctitle> -->\n  \u003C!-- ❌ Pas de \u003Cmeta name=\"description\"> -->\n  \u003C!-- ❌ Pas de og:title, og:image -->\n\u003C/head>\n\u003Cbody>\n  \u003Cdiv id=\"root\">\n    \u003C!-- Le contenu complet du produit est bien rendu -->\n    \u003Ch1>Assurance Auto Premium — Comparaison détaillée\u003C/h1>\n    \u003Cp>Découvrez notre analyse complète de l'offre Assurance Auto Premium...\u003C/p>\n  \u003C/div>\n  \u003Cscript src=\"/build/js/entry.client-H7YK3.js\">\u003C/script>\n\u003C/body>\n\u003C/html>\n```\n\n### Pourquoi le navigateur affiche les bonnes metas\n\nCôté client, React s'hydrate et le composant `\u003CMeta />` de Remix finit par se résoudre. La Promise retournée par `meta()` se résout dans le contexte React côté client. Le navigateur exécute JavaScript, la Promise se resolve, React injecte les balises dans le DOM via `document.head`. Chrome DevTools montre un head complet — **après hydratation**.\n\nC'est la divergence classique entre ce que voit un humain avec un navigateur et ce que voit Googlebot dans sa phase de crawl initiale. Même si le rendering de Google exécute JavaScript, le délai et le comportement de streaming font que les metas ne sont jamais rattachées au bon moment dans le pipeline d'indexation.\n\n### Pourquoi les tests n'ont rien détecté\n\nLes tests Playwright de l'équipe vérifiaient les metas via `page.locator('meta[name=\"description\"]')`. Playwright exécute JavaScript. Il attend l'hydratation. Les metas apparaissent. Test vert.\n\nPersonne n'avait écrit de test qui vérifie le HTML brut de la réponse HTTP — le contenu avant exécution JavaScript. C'est exactement le test qui aurait attrapé la régression.\n\nUn `curl` suffisait. Mais `curl` ne faisait pas partie de la CI.\n\nScreaming Frog en mode \"JavaScript rendering\" aussi passait à côté, parce qu'il exécute le JavaScript comme un navigateur. Seul le mode \"HTML brut\" révélait le head vide — et personne ne crawlait en HTML brut de manière systématique.\n\nCette divergence SSR/CSR est un pattern récurrent. L'équipe avait déjà eu un souci similaire sur un autre projet Next.js, où [une fonction metadata async qui throw servait le fallback par défaut](/blog/next-js-metadata-async-qui-throw-la-page-sert-le-fallback-default-next-js). Le symptôme diffère, la cause racine est la même : une Promise non gérée dans le pipeline de rendu des métadonnées.\n\n## Le fix : remettre les données dans le loader, pas dans meta()\n\n### Le patch\n\nLa solution est architecturale, pas cosmétique. La fonction `meta()` de Remix doit rester synchrone. Toute donnée nécessaire aux métadonnées doit être résolue dans le `loader`, qui est correctement awaited par le framework.\n\n```typescript\n// app/routes/produit.$slug.tsx — version corrigée\nimport type { MetaFunction, LoaderFunctionArgs } from \"@remix-run/node\";\nimport { json } from \"@remix-run/node\";\n\nexport const loader = async ({ params }: LoaderFunctionArgs) => {\n  const produit = await db.produit.findUnique({\n    where: { slug: params.slug },\n  });\n  if (!produit) throw new Response(\"Not Found\", { status: 404 });\n\n  // ✅ Le fetch CMS est fait dans le loader, pas dans meta()\n  let seoData = null;\n  try {\n    const enriched = await fetch(\n      `https://cms.exemple-comparateur.fr/api/seo-meta/${produit.slug}`,\n      { signal: AbortSignal.timeout(2000) } // timeout de sécurité\n    );\n    if (enriched.ok) {\n      seoData = await enriched.json();\n    }\n  } catch {\n    // Fallback silencieux sur les données produit\n  }\n\n  return json({ produit, seoData });\n};\n\n// ✅ meta() est synchrone, reçoit les données du loader\nexport const meta: MetaFunction\u003Ctypeof loader> = ({ data }) => {\n  if (!data) return [];\n  const { produit, seoData } = data;\n\n  return [\n    { title: seoData?.title || `${produit.nom} — Comparateur Assurance` },\n    {\n      name: \"description\",\n      content: seoData?.description || produit.metaDescription,\n    },\n    { property: \"og:title\", content: seoData?.ogTitle || produit.nom },\n    { property: \"og:image\", content: seoData?.ogImage || produit.image },\n  ];\n};\n```\n\nPoints clés du correctif :\n\n1. **Le fetch CMS est déplacé dans le `loader`**. Le loader est toujours awaited par Remix avant le rendu.\n2. **Un timeout de 2 secondes** protège contre un CMS lent. Sans réponse, les données produit en base servent de fallback.\n3. **`meta()` redevient synchrone**. Elle reçoit `data` déjà résolu et retourne un tableau immédiatement.\n\n### Vérification avant redéploiement\n\nAvant de push en production, l'équipe ajoute un test CI qui vérifie le HTML brut :\n\n```bash\n# test-meta-ssr.sh — ajouté au pipeline CI\n#!/bin/bash\nset -e\n\nURL=\"http://localhost:3000/produit/assurance-auto-premium\"\n\n# Démarre le serveur Remix en mode production\nnpm run build && npm run start &\nSERVER_PID=$!\nsleep 5\n\n# Vérifie la présence des metas dans le HTML brut (pas de JS)\nHTML=$(curl -s \"$URL\")\n\necho \"$HTML\" | grep -q '\u003Ctitle>' || { echo \"❌ FAIL: \u003Ctitle> absent du HTML SSR\"; kill $SERVER_PID; exit 1; }\necho \"$HTML\" | grep -q 'meta name=\"description\"' || { echo \"❌ FAIL: meta description absente du HTML SSR\"; kill $SERVER_PID; exit 1; }\necho \"$HTML\" | grep -q 'og:title' || { echo \"❌ FAIL: og:title absent du HTML SSR\"; kill $SERVER_PID; exit 1; }\n\necho \"✅ PASS: toutes les metas présentes dans le HTML SSR\"\nkill $SERVER_PID\n```\n\n### Invalidation et relance de crawl\n\nLe fix est déployé un mardi à 11h. L'équipe :\n\n1. Purge le cache CDN (Cloudflare) sur le pattern `/produit/*`.\n2. Soumet les 3 200 URLs produit via l'API d'indexation de Search Console (batch de 200 URLs/jour via le quota).\n3. Met à jour le `sitemap.xml` avec un `\u003Clastmod>` frais pour forcer un recrawl prioritaire.\n\n### Chronologie de récupération\n\n- **J+2** : Google recrawle les 800 premières pages. L'inspection d'URL montre un head complet.\n- **J+7** : 2 400 pages réindexées avec les bonnes metas. Les positions commencent à remonter.\n- **J+14** : 3 100 pages sur 3 200 récupèrent leur position antérieure (±0.5 position).\n- **J+21** : trafic organique revenu à 95% du niveau pré-incident. Les 5% restants correspondent à des requêtes saisonnières en déclin naturel.\n- **J+30** : situation normalisée.\n\nAu total, l'incident aura coûté environ 180K clics sur 39 jours (18 jours de régression + 21 jours de récupération).\n\n### Mesures de prévention ajoutées\n\nL'équipe met en place trois garde-fous :\n\n1. **Lint ESLint custom** : une règle qui interdit `async` sur les exports `meta` dans les fichiers de route Remix. Toute tentative d'ajouter `async` à `meta()` fait échouer le lint.\n\n2. **Test CI curl** : le script ci-dessus tourne sur chaque PR qui touche un fichier `routes/`. Temps d'exécution : 8 secondes.\n\n3. **Crawl HTML brut hebdomadaire** : un job Screaming Frog en mode \"HTML only\" sur les 500 pages les plus importantes, avec alerte Slack si une meta title ou description est absente. L'équipe avait déjà constaté des divergences similaires entre rendu client et crawl brut sur d'autres stacks — un pattern documenté dans leur post-mortem sur [un composant heading mal configuré qui rendait un div au lieu d'un h1](/blog/design-system-composant-heading-qui-rend-div-selon-la-prop-as-mal-configuree).\n\nLa documentation officielle de Remix sur la fonction [`meta`](https://remix.run/docs/en/main/route/meta) ne mentionne pas explicitement que la fonction doit être synchrone. Le type TypeScript `MetaFunction` n'interdit pas le retour d'une Promise. C'est un piège silencieux. L'équipe a ouvert une issue sur le repo Remix pour demander soit un warning runtime, soit un type plus strict.\n\n## Ce qu'on en retient\n\nLe streaming SSR est un gain de performance réel. Mais il déplace le contrat de rendu : tout ce qui doit apparaître dans le premier chunk — les métadonnées SEO — doit être résolu avant le flush. Une Promise non awaited dans `meta()` ne lève aucune erreur. Elle produit un head vide, silencieusement, pendant des semaines.\n\nLe pattern est simple : les données SEO vivent dans le `loader`, jamais dans un fetch async à l'intérieur de `meta()`. Et la seule façon de détecter cette classe de bugs, c'est de tester le HTML brut — pas le DOM hydraté.\n\nUn monitoring continu qui compare le HTML SSR au DOM post-hydratation, comme ce que propose Seogard, détecte ce type de divergence en quelques minutes après déploiement. Pas en 18 jours.\n\nLe `curl` le plus basique du monde aurait suffi. Personne ne l'a lancé.\n```","https://seogard.io/blog/remix-meta-async-non-awaited-metas-vides-en-streaming","Framework","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.","\u003Ch1>Remix meta() async non awaited : quand le streaming SSR envoie un head vide à Googlebot\u003C/h1>\n\u003Cp>Mercredi 14h20. Le déploiement passe en production sans alerte. L'application Remix v2.8 d'un comparateur financier français — 3 200 pages produit, 410K clics organiques mensuels — vient de recevoir une mise à jour du système de métadonnées. L'équipe a migré les anciennes fonctions \u003Ccode>meta\u003C/code> synchrones vers un pattern async qui fetch des données enrichies depuis un CMS headless. Le navigateur affiche tout. Les tests Playwright sont verts. Dix-huit jours plus tard, Search Console affiche un effondrement : −128K clics. Les pages produit ont perdu leurs title et description dans l'index Google.\u003C/p>\n\u003Ch2>T+18 jours — Jeudi 9h12, l'alerte Search Console\u003C/h2>\n\u003Cp>Le Lead SEO ouvre son rapport hebdomadaire dans Google Search Console. La courbe de clics plonge. Pas un segment isolé — la quasi-totalité des pages \u003Ccode>/produit/*\u003C/code> est touchée. Il filtre par type de page. Les 3 200 URLs produit affichent une chute moyenne de position de 4.2 à 11.7 sur les requêtes principales.\u003C/p>\n\u003Cp>Premier réflexe : vérifier le déploiement récent. Le changelog Git ne montre rien d'alarmant. Pas de modification du \u003Ccode>robots.txt\u003C/code>. Pas de \u003Ccode>noindex\u003C/code> ajouté. Le développeur fullstack vérifie dans Chrome : les balises meta sont bien présentes dans le DOM. Title correct, description correcte, Open Graph complet.\u003C/p>\n\u003Cp>L'hypothèse initiale : un problème d'indexation côté Google. Peut-être un crawl budget temporairement réduit. L'équipe attend 48 heures.\u003C/p>\n\u003Cp>Samedi, le Lead SEO lance un crawl Screaming Frog en mode \"JavaScript rendering\". Surprise : les 3 200 pages produit remontent avec un title et une description. Tout semble normal. Il switch en mode \"HTML brut\" — sans exécution JavaScript. Les balises \u003Ccode>&#x3C;title>\u003C/code> et \u003Ccode>&#x3C;meta name=\"description\">\u003C/code> sont absentes du \u003Ccode>&#x3C;head>\u003C/code>. Totalement absentes.\u003C/p>\n\u003Cp>Il ouvre l'outil d'inspection d'URL dans Search Console. Le HTML rendu par Google confirme : le \u003Ccode>&#x3C;head>\u003C/code> est vide de métadonnées SEO. Le \u003Ccode>&#x3C;body>\u003C/code> contient le contenu. Mais les balises meta critiques n'apparaissent nulle part dans le snapshot.\u003C/p>\n\u003Cp>Le développeur fronce les sourcils. \"Impossible. C'est du SSR. Remix rend côté serveur.\"\u003C/p>\n\u003Cp>Il ouvre \u003Ccode>curl\u003C/code> :\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-comparateur.fr/produit/assurance-auto-premium\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\"> -80\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Le \u003Ccode>&#x3C;head>\u003C/code> retourné contient le charset, le viewport, les liens CSS — mais aucun \u003Ccode>&#x3C;title>\u003C/code>, aucune \u003Ccode>&#x3C;meta name=\"description\">\u003C/code>. Les balises meta SEO ne sont pas dans la réponse HTTP initiale.\u003C/p>\n\u003Cp>L'équipe comprend que ce n'est pas un bug mineur. Le SSR streaming de Remix envoie le head avant que les métadonnées ne soient résolues. Depuis 18 jours, Googlebot reçoit un head vide sur chaque page produit.\u003C/p>\n\u003Cp>Impact mesuré à ce stade : −128K clics sur 18 jours, −31% de trafic organique sur le segment produit, 3 200 pages affectées. Les pages catégories et le blog, qui utilisent encore l'ancien pattern synchrone, sont intacts.\u003C/p>\n\u003Ch2>Le bug : meta() async dans un pipeline de streaming qui n'attend pas\u003C/h2>\n\u003Cp>Pour comprendre le problème, il faut comprendre comment Remix gère le streaming SSR et la fonction \u003Ccode>meta()\u003C/code>.\u003C/p>\n\u003Ch3>Le mécanisme normal\u003C/h3>\n\u003Cp>Dans Remix v2, chaque route peut exporter une fonction \u003Ccode>meta()\u003C/code> qui retourne un tableau de descripteurs de métadonnées. En fonctionnement classique, Remix résout le \u003Ccode>loader\u003C/code> de la route, puis passe les données au \u003Ccode>meta()\u003C/code>, qui retourne les balises de manière synchrone :\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/routes/produit.$slug.tsx — version synchrone (avant la régression)\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#F97583\"> type\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { MetaFunction } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> \"@remix-run/node\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\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\"> const\u003C/span>\u003Cspan style=\"color:#B392F0\"> loader\u003C/span>\u003Cspan style=\"color:#F97583\"> =\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:#B392F0\"> LoaderFunctionArgs\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\"> produit\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#F97583\"> await\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> db.produit.\u003C/span>\u003Cspan style=\"color:#B392F0\">findUnique\u003C/span>\u003Cspan style=\"color:#E1E4E8\">({\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    where: { slug: params.slug },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  });\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  if\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> (\u003C/span>\u003Cspan style=\"color:#F97583\">!\u003C/span>\u003Cspan style=\"color:#E1E4E8\">produit) \u003C/span>\u003Cspan style=\"color:#F97583\">throw\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\">\"Not Found\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, { status: \u003C/span>\u003Cspan style=\"color:#79B8FF\">404\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> });\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  return\u003C/span>\u003Cspan style=\"color:#B392F0\"> json\u003C/span>\u003Cspan style=\"color:#E1E4E8\">({ produit });\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:#F97583\">export\u003C/span>\u003Cspan style=\"color:#F97583\"> const\u003C/span>\u003Cspan style=\"color:#B392F0\"> meta\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#B392F0\"> MetaFunction\u003C/span>\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#F97583\">typeof\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> loader> \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> ({ \u003C/span>\u003Cspan style=\"color:#FFAB70\">data\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\">  if\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> (\u003C/span>\u003Cspan style=\"color:#F97583\">!\u003C/span>\u003Cspan style=\"color:#E1E4E8\">data) \u003C/span>\u003Cspan style=\"color:#F97583\">return\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> [];\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  return\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> [\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\">data\u003C/span>\u003Cspan style=\"color:#9ECBFF\">.\u003C/span>\u003Cspan style=\"color:#E1E4E8\">produit\u003C/span>\u003Cspan style=\"color:#9ECBFF\">.\u003C/span>\u003Cspan style=\"color:#E1E4E8\">nom\u003C/span>\u003Cspan style=\"color:#9ECBFF\">} — Comparateur Assurance`\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: data.produit.metaDescription },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    { property: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"og:title\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, content: data.produit.nom },\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>Ici, \u003Ccode>meta()\u003C/code> est synchrone. Elle reçoit \u003Ccode>data\u003C/code> depuis le loader déjà résolu. Le head est complet avant l'envoi du premier chunk HTML.\u003C/p>\n\u003Ch3>La modification fatale\u003C/h3>\n\u003Cp>L'équipe voulait enrichir les métadonnées avec des données provenant d'un CMS headless (Strapi). Le développeur a transformé \u003Ccode>meta()\u003C/code> en fonction async pour fetch des données SEO supplémentaires — des meta descriptions A/B testées, des titres enrichis par un rédacteur :\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/routes/produit.$slug.tsx — version async (la régression)\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#F97583\"> type\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { MetaFunction } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> \"@remix-run/node\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\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\"> const\u003C/span>\u003Cspan style=\"color:#B392F0\"> loader\u003C/span>\u003Cspan style=\"color:#F97583\"> =\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:#B392F0\"> LoaderFunctionArgs\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\"> produit\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#F97583\"> await\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> db.produit.\u003C/span>\u003Cspan style=\"color:#B392F0\">findUnique\u003C/span>\u003Cspan style=\"color:#E1E4E8\">({\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    where: { slug: params.slug },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  });\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  if\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> (\u003C/span>\u003Cspan style=\"color:#F97583\">!\u003C/span>\u003Cspan style=\"color:#E1E4E8\">produit) \u003C/span>\u003Cspan style=\"color:#F97583\">throw\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\">\"Not Found\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, { status: \u003C/span>\u003Cspan style=\"color:#79B8FF\">404\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> });\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  return\u003C/span>\u003Cspan style=\"color:#B392F0\"> json\u003C/span>\u003Cspan style=\"color:#E1E4E8\">({ produit });\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:#6A737D\">// ⚠️ meta() est maintenant async\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:#B392F0\"> meta\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#B392F0\"> MetaFunction\u003C/span>\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#F97583\">typeof\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> loader> \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#F97583\"> async\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> ({ \u003C/span>\u003Cspan style=\"color:#FFAB70\">data\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\">  if\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> (\u003C/span>\u003Cspan style=\"color:#F97583\">!\u003C/span>\u003Cspan style=\"color:#E1E4E8\">data) \u003C/span>\u003Cspan style=\"color:#F97583\">return\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> [];\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">  // Fetch des métadonnées enrichies depuis Strapi\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> enriched\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>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">    `https://cms.exemple-comparateur.fr/api/seo-meta/${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">data\u003C/span>\u003Cspan style=\"color:#9ECBFF\">.\u003C/span>\u003Cspan style=\"color:#E1E4E8\">produit\u003C/span>\u003Cspan style=\"color:#9ECBFF\">.\u003C/span>\u003Cspan style=\"color:#E1E4E8\">slug\u003C/span>\u003Cspan style=\"color:#9ECBFF\">}`\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  );\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> seoData\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#F97583\"> await\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> enriched.\u003C/span>\u003Cspan style=\"color:#B392F0\">json\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:#E1E4E8\"> [\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    { title: seoData.title \u003C/span>\u003Cspan style=\"color:#F97583\">||\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> `${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">data\u003C/span>\u003Cspan style=\"color:#9ECBFF\">.\u003C/span>\u003Cspan style=\"color:#E1E4E8\">produit\u003C/span>\u003Cspan style=\"color:#9ECBFF\">.\u003C/span>\u003Cspan style=\"color:#E1E4E8\">nom\u003C/span>\u003Cspan style=\"color:#9ECBFF\">} — Comparateur`\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: seoData.description \u003C/span>\u003Cspan style=\"color:#F97583\">||\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> data.produit.metaDescription },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    { property: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"og:title\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, content: seoData.ogTitle \u003C/span>\u003Cspan style=\"color:#F97583\">||\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> data.produit.nom },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    { property: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"og:image\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, content: seoData.ogImage \u003C/span>\u003Cspan style=\"color:#F97583\">||\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> data.produit.image },\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>TypeScript ne proteste pas. L'application compile. Les tests passent.\u003C/p>\n\u003Ch3>Pourquoi ça casse en streaming\u003C/h3>\n\u003Cp>Le problème réside dans l'architecture de streaming SSR de Remix. Quand \u003Ccode>entry.server.tsx\u003C/code> utilise \u003Ccode>renderToPipeableStream\u003C/code> (React 18), le flux HTML est envoyé progressivement au client. Le \u003Ccode>&#x3C;head>\u003C/code> part en premier, dans le chunk initial.\u003C/p>\n\u003Cp>Voici le \u003Ccode>entry.server.tsx\u003C/code> du projet :\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/entry.server.tsx\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { PassThrough } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> \"node:stream\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { renderToPipeableStream } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> \"react-dom/server\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { RemixServer } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> \"@remix-run/react\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { createReadableStreamFromReadable } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> \"@remix-run/node\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { isbot } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> \"isbot\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\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:#F97583\"> function\u003C/span>\u003Cspan style=\"color:#B392F0\"> handleRequest\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">  request\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#B392F0\"> Request\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">  responseStatusCode\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#79B8FF\"> number\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">  responseHeaders\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#B392F0\"> Headers\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">  remixContext\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#B392F0\"> EntryContext\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> userAgent\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> request.headers.\u003C/span>\u003Cspan style=\"color:#B392F0\">get\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"user-agent\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">) \u003C/span>\u003Cspan style=\"color:#F97583\">||\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\"> callbackName\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#B392F0\"> isbot\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(userAgent) \u003C/span>\u003Cspan style=\"color:#F97583\">?\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> \"onAllReady\"\u003C/span>\u003Cspan style=\"color:#F97583\"> :\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> \"onShellReady\"\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:#79B8FF\"> Promise\u003C/span>\u003Cspan style=\"color:#E1E4E8\">((\u003C/span>\u003Cspan style=\"color:#FFAB70\">resolve\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#FFAB70\">reject\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:#E1E4E8\"> { \u003C/span>\u003Cspan style=\"color:#79B8FF\">pipe\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> } \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#B392F0\"> renderToPipeableStream\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">      &#x3C;\u003C/span>\u003Cspan style=\"color:#E1E4E8\">RemixServer context\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\">{remixContext} url\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\">{request.url} \u003C/span>\u003Cspan style=\"color:#F97583\">/>\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\">        [callbackName]: () \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\"> body\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#F97583\"> new\u003C/span>\u003Cspan style=\"color:#B392F0\"> PassThrough\u003C/span>\u003Cspan style=\"color:#E1E4E8\">();\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">          responseHeaders.\u003C/span>\u003Cspan style=\"color:#B392F0\">set\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"Content-Type\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"text/html\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">          resolve\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">            new\u003C/span>\u003Cspan style=\"color:#B392F0\"> Response\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#B392F0\">createReadableStreamFromReadable\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(body), {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">              headers: responseHeaders,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">              status: responseStatusCode,\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:#B392F0\">          pipe\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(body);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">        },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">        onError\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#FFAB70\">error\u003C/span>\u003Cspan style=\"color:#E1E4E8\">) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">          responseStatusCode \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#79B8FF\"> 500\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">          console.\u003C/span>\u003Cspan style=\"color:#B392F0\">error\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(error);\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>\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>La ligne critique : \u003Ccode>const callbackName = isbot(userAgent) ? \"onAllReady\" : \"onShellReady\"\u003C/code>.\u003C/p>\n\u003Cp>En théorie, quand le user-agent est un bot (Googlebot), Remix utilise \u003Ccode>onAllReady\u003C/code> — il attend que tout le HTML soit prêt avant de flusher. Pour les utilisateurs humains, \u003Ccode>onShellReady\u003C/code> flushe le shell dès que possible.\u003C/p>\n\u003Cp>Mais voici le piège : \u003Cstrong>Remix ne \u003Ccode>await\u003C/code> pas la fonction \u003Ccode>meta()\u003C/code> quand elle retourne une Promise\u003C/strong>. Le framework appelle \u003Ccode>meta()\u003C/code>, reçoit une Promise au lieu d'un tableau, et l'interprète comme une valeur falsy ou un objet non itérable. Le tableau de métadonnées n'est jamais injecté dans le \u003Ccode>&#x3C;head>\u003C/code>.\u003C/p>\n\u003Cp>Le résultat en HTML brut :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">&#x3C;!-- Ce que reçoit Googlebot (curl ou fetch brut) -->\u003C/span>\u003C/span>\n\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:#B392F0\"> lang\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"fr\"\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\">meta\u003C/span>\u003Cspan style=\"color:#B392F0\"> charset\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"utf-8\"\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\">\"viewport\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> content\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"width=device-width, initial-scale=1\"\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\">link\u003C/span>\u003Cspan style=\"color:#B392F0\"> rel\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"stylesheet\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> href\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"/build/css/app-DK4X2.css\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> />\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">  &#x3C;!-- ❌ Pas de &#x3C;title> -->\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">  &#x3C;!-- ❌ Pas de &#x3C;meta name=\"description\"> -->\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">  &#x3C;!-- ❌ Pas de og:title, og:image -->\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;!-- Le contenu complet du produit est bien rendu -->\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\">>Assurance Auto Premium — Comparaison détaillée&#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\">>Découvrez notre analyse complète de l'offre Assurance Auto Premium...&#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:#B392F0\"> src\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"/build/js/entry.client-H7YK3.js\"\u003C/span>\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\u003Ch3>Pourquoi le navigateur affiche les bonnes metas\u003C/h3>\n\u003Cp>Côté client, React s'hydrate et le composant \u003Ccode>&#x3C;Meta />\u003C/code> de Remix finit par se résoudre. La Promise retournée par \u003Ccode>meta()\u003C/code> se résout dans le contexte React côté client. Le navigateur exécute JavaScript, la Promise se resolve, React injecte les balises dans le DOM via \u003Ccode>document.head\u003C/code>. Chrome DevTools montre un head complet — \u003Cstrong>après hydratation\u003C/strong>.\u003C/p>\n\u003Cp>C'est la divergence classique entre ce que voit un humain avec un navigateur et ce que voit Googlebot dans sa phase de crawl initiale. Même si le rendering de Google exécute JavaScript, le délai et le comportement de streaming font que les metas ne sont jamais rattachées au bon moment dans le pipeline d'indexation.\u003C/p>\n\u003Ch3>Pourquoi les tests n'ont rien détecté\u003C/h3>\n\u003Cp>Les tests Playwright de l'équipe vérifiaient les metas via \u003Ccode>page.locator('meta[name=\"description\"]')\u003C/code>. Playwright exécute JavaScript. Il attend l'hydratation. Les metas apparaissent. Test vert.\u003C/p>\n\u003Cp>Personne n'avait écrit de test qui vérifie le HTML brut de la réponse HTTP — le contenu avant exécution JavaScript. C'est exactement le test qui aurait attrapé la régression.\u003C/p>\n\u003Cp>Un \u003Ccode>curl\u003C/code> suffisait. Mais \u003Ccode>curl\u003C/code> ne faisait pas partie de la CI.\u003C/p>\n\u003Cp>Screaming Frog en mode \"JavaScript rendering\" aussi passait à côté, parce qu'il exécute le JavaScript comme un navigateur. Seul le mode \"HTML brut\" révélait le head vide — et personne ne crawlait en HTML brut de manière systématique.\u003C/p>\n\u003Cp>Cette divergence SSR/CSR est un pattern récurrent. L'équipe avait déjà eu un souci similaire sur un autre projet Next.js, où \u003Ca href=\"/blog/next-js-metadata-async-qui-throw-la-page-sert-le-fallback-default-next-js\">une fonction metadata async qui throw servait le fallback par défaut\u003C/a>. Le symptôme diffère, la cause racine est la même : une Promise non gérée dans le pipeline de rendu des métadonnées.\u003C/p>\n\u003Ch2>Le fix : remettre les données dans le loader, pas dans meta()\u003C/h2>\n\u003Ch3>Le patch\u003C/h3>\n\u003Cp>La solution est architecturale, pas cosmétique. La fonction \u003Ccode>meta()\u003C/code> de Remix doit rester synchrone. Toute donnée nécessaire aux métadonnées doit être résolue dans le \u003Ccode>loader\u003C/code>, qui est correctement awaited par le framework.\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/routes/produit.$slug.tsx — version corrigée\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#F97583\"> type\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { MetaFunction, LoaderFunctionArgs } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> \"@remix-run/node\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { json } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> \"@remix-run/node\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\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\"> const\u003C/span>\u003Cspan style=\"color:#B392F0\"> loader\u003C/span>\u003Cspan style=\"color:#F97583\"> =\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:#B392F0\"> LoaderFunctionArgs\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\"> produit\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#F97583\"> await\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> db.produit.\u003C/span>\u003Cspan style=\"color:#B392F0\">findUnique\u003C/span>\u003Cspan style=\"color:#E1E4E8\">({\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    where: { slug: params.slug },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  });\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  if\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> (\u003C/span>\u003Cspan style=\"color:#F97583\">!\u003C/span>\u003Cspan style=\"color:#E1E4E8\">produit) \u003C/span>\u003Cspan style=\"color:#F97583\">throw\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\">\"Not Found\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, { status: \u003C/span>\u003Cspan style=\"color:#79B8FF\">404\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> });\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">  // ✅ Le fetch CMS est fait dans le loader, pas dans meta()\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  let\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> seoData \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#79B8FF\"> null\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  try\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\"> enriched\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>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">      `https://cms.exemple-comparateur.fr/api/seo-meta/${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">produit\u003C/span>\u003Cspan style=\"color:#9ECBFF\">.\u003C/span>\u003Cspan style=\"color:#E1E4E8\">slug\u003C/span>\u003Cspan style=\"color:#9ECBFF\">}`\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      { signal: AbortSignal.\u003C/span>\u003Cspan style=\"color:#B392F0\">timeout\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#79B8FF\">2000\u003C/span>\u003Cspan style=\"color:#E1E4E8\">) } \u003C/span>\u003Cspan style=\"color:#6A737D\">// timeout de sécurité\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    );\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">    if\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> (enriched.ok) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      seoData \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#F97583\"> await\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> enriched.\u003C/span>\u003Cspan style=\"color:#B392F0\">json\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>\u003Cspan style=\"color:#F97583\">catch\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">    // Fallback silencieux sur les données produit\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:#F97583\">  return\u003C/span>\u003Cspan style=\"color:#B392F0\"> json\u003C/span>\u003Cspan style=\"color:#E1E4E8\">({ produit, seoData });\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:#6A737D\">// ✅ meta() est synchrone, reçoit les données du loader\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:#B392F0\"> meta\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#B392F0\"> MetaFunction\u003C/span>\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#F97583\">typeof\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> loader> \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> ({ \u003C/span>\u003Cspan style=\"color:#FFAB70\">data\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\">  if\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> (\u003C/span>\u003Cspan style=\"color:#F97583\">!\u003C/span>\u003Cspan style=\"color:#E1E4E8\">data) \u003C/span>\u003Cspan style=\"color:#F97583\">return\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> [];\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  const\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { \u003C/span>\u003Cspan style=\"color:#79B8FF\">produit\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#79B8FF\">seoData\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> } \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> data;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  return\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> [\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    { title: seoData?.title \u003C/span>\u003Cspan style=\"color:#F97583\">||\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> `${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">produit\u003C/span>\u003Cspan style=\"color:#9ECBFF\">.\u003C/span>\u003Cspan style=\"color:#E1E4E8\">nom\u003C/span>\u003Cspan style=\"color:#9ECBFF\">} — Comparateur Assurance`\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\">      name: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"description\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      content: seoData?.description \u003C/span>\u003Cspan style=\"color:#F97583\">||\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> produit.metaDescription,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    { property: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"og:title\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, content: seoData?.ogTitle \u003C/span>\u003Cspan style=\"color:#F97583\">||\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> produit.nom },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    { property: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"og:image\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, content: seoData?.ogImage \u003C/span>\u003Cspan style=\"color:#F97583\">||\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> produit.image },\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>Points clés du correctif :\u003C/p>\n\u003Col>\n\u003Cli>\u003Cstrong>Le fetch CMS est déplacé dans le \u003Ccode>loader\u003C/code>\u003C/strong>. Le loader est toujours awaited par Remix avant le rendu.\u003C/li>\n\u003Cli>\u003Cstrong>Un timeout de 2 secondes\u003C/strong> protège contre un CMS lent. Sans réponse, les données produit en base servent de fallback.\u003C/li>\n\u003Cli>\u003Cstrong>\u003Ccode>meta()\u003C/code> redevient synchrone\u003C/strong>. Elle reçoit \u003Ccode>data\u003C/code> déjà résolu et retourne un tableau immédiatement.\u003C/li>\n\u003C/ol>\n\u003Ch3>Vérification avant redéploiement\u003C/h3>\n\u003Cp>Avant de push en production, l'équipe ajoute un test CI qui vérifie le HTML brut :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\"># test-meta-ssr.sh — ajouté au pipeline CI\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">#!/bin/bash\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">set\u003C/span>\u003Cspan style=\"color:#79B8FF\"> -e\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">URL\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"http://localhost:3000/produit/assurance-auto-premium\"\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\"># Démarre le serveur Remix en mode production\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">npm\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> run\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> build\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> &#x26;&#x26; \u003C/span>\u003Cspan style=\"color:#B392F0\">npm\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> run\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> start\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> &#x26;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">SERVER_PID\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#79B8FF\">$!\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">sleep\u003C/span>\u003Cspan style=\"color:#79B8FF\"> 5\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\"># Vérifie la présence des metas dans le HTML brut (pas de JS)\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">HTML\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\">$(\u003C/span>\u003Cspan style=\"color:#B392F0\">curl\u003C/span>\u003Cspan style=\"color:#79B8FF\"> -s\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\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">echo\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> \"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">$HTML\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"\u003C/span>\u003Cspan style=\"color:#F97583\"> |\u003C/span>\u003Cspan style=\"color:#B392F0\"> grep\u003C/span>\u003Cspan style=\"color:#79B8FF\"> -q\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> '&#x3C;title>'\u003C/span>\u003Cspan style=\"color:#F97583\"> ||\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { \u003C/span>\u003Cspan style=\"color:#79B8FF\">echo\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> \"❌ FAIL: &#x3C;title> absent du HTML SSR\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">; \u003C/span>\u003Cspan style=\"color:#79B8FF\">kill\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> $SERVER_PID; \u003C/span>\u003Cspan style=\"color:#79B8FF\">exit\u003C/span>\u003Cspan style=\"color:#79B8FF\"> 1\u003C/span>\u003Cspan style=\"color:#E1E4E8\">; }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">echo\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> \"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">$HTML\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"\u003C/span>\u003Cspan style=\"color:#F97583\"> |\u003C/span>\u003Cspan style=\"color:#B392F0\"> grep\u003C/span>\u003Cspan style=\"color:#79B8FF\"> -q\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'meta name=\"description\"'\u003C/span>\u003Cspan style=\"color:#F97583\"> ||\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { \u003C/span>\u003Cspan style=\"color:#79B8FF\">echo\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> \"❌ FAIL: meta description absente du HTML SSR\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">; \u003C/span>\u003Cspan style=\"color:#79B8FF\">kill\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> $SERVER_PID; \u003C/span>\u003Cspan style=\"color:#79B8FF\">exit\u003C/span>\u003Cspan style=\"color:#79B8FF\"> 1\u003C/span>\u003Cspan style=\"color:#E1E4E8\">; }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">echo\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> \"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">$HTML\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"\u003C/span>\u003Cspan style=\"color:#F97583\"> |\u003C/span>\u003Cspan style=\"color:#B392F0\"> grep\u003C/span>\u003Cspan style=\"color:#79B8FF\"> -q\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'og:title'\u003C/span>\u003Cspan style=\"color:#F97583\"> ||\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { \u003C/span>\u003Cspan style=\"color:#79B8FF\">echo\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> \"❌ FAIL: og:title absent du HTML SSR\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">; \u003C/span>\u003Cspan style=\"color:#79B8FF\">kill\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> $SERVER_PID; \u003C/span>\u003Cspan style=\"color:#79B8FF\">exit\u003C/span>\u003Cspan style=\"color:#79B8FF\"> 1\u003C/span>\u003Cspan style=\"color:#E1E4E8\">; }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">echo\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> \"✅ PASS: toutes les metas présentes dans le HTML SSR\"\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">kill\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> $SERVER_PID\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Ch3>Invalidation et relance de crawl\u003C/h3>\n\u003Cp>Le fix est déployé un mardi à 11h. L'équipe :\u003C/p>\n\u003Col>\n\u003Cli>Purge le cache CDN (Cloudflare) sur le pattern \u003Ccode>/produit/*\u003C/code>.\u003C/li>\n\u003Cli>Soumet les 3 200 URLs produit via l'API d'indexation de Search Console (batch de 200 URLs/jour via le quota).\u003C/li>\n\u003Cli>Met à jour le \u003Ccode>sitemap.xml\u003C/code> avec un \u003Ccode>&#x3C;lastmod>\u003C/code> frais pour forcer un recrawl prioritaire.\u003C/li>\n\u003C/ol>\n\u003Ch3>Chronologie de récupération\u003C/h3>\n\u003Cul>\n\u003Cli>\u003Cstrong>J+2\u003C/strong> : Google recrawle les 800 premières pages. L'inspection d'URL montre un head complet.\u003C/li>\n\u003Cli>\u003Cstrong>J+7\u003C/strong> : 2 400 pages réindexées avec les bonnes metas. Les positions commencent à remonter.\u003C/li>\n\u003Cli>\u003Cstrong>J+14\u003C/strong> : 3 100 pages sur 3 200 récupèrent leur position antérieure (±0.5 position).\u003C/li>\n\u003Cli>\u003Cstrong>J+21\u003C/strong> : trafic organique revenu à 95% du niveau pré-incident. Les 5% restants correspondent à des requêtes saisonnières en déclin naturel.\u003C/li>\n\u003Cli>\u003Cstrong>J+30\u003C/strong> : situation normalisée.\u003C/li>\n\u003C/ul>\n\u003Cp>Au total, l'incident aura coûté environ 180K clics sur 39 jours (18 jours de régression + 21 jours de récupération).\u003C/p>\n\u003Ch3>Mesures de prévention ajoutées\u003C/h3>\n\u003Cp>L'équipe met en place trois garde-fous :\u003C/p>\n\u003Col>\n\u003Cli>\n\u003Cp>\u003Cstrong>Lint ESLint custom\u003C/strong> : une règle qui interdit \u003Ccode>async\u003C/code> sur les exports \u003Ccode>meta\u003C/code> dans les fichiers de route Remix. Toute tentative d'ajouter \u003Ccode>async\u003C/code> à \u003Ccode>meta()\u003C/code> fait échouer le lint.\u003C/p>\n\u003C/li>\n\u003Cli>\n\u003Cp>\u003Cstrong>Test CI curl\u003C/strong> : le script ci-dessus tourne sur chaque PR qui touche un fichier \u003Ccode>routes/\u003C/code>. Temps d'exécution : 8 secondes.\u003C/p>\n\u003C/li>\n\u003Cli>\n\u003Cp>\u003Cstrong>Crawl HTML brut hebdomadaire\u003C/strong> : un job Screaming Frog en mode \"HTML only\" sur les 500 pages les plus importantes, avec alerte Slack si une meta title ou description est absente. L'équipe avait déjà constaté des divergences similaires entre rendu client et crawl brut sur d'autres stacks — un pattern documenté dans leur post-mortem sur \u003Ca href=\"/blog/design-system-composant-heading-qui-rend-div-selon-la-prop-as-mal-configuree\">un composant heading mal configuré qui rendait un div au lieu d'un h1\u003C/a>.\u003C/p>\n\u003C/li>\n\u003C/ol>\n\u003Cp>La documentation officielle de Remix sur la fonction \u003Ca href=\"https://remix.run/docs/en/main/route/meta\">\u003Ccode>meta\u003C/code>\u003C/a> ne mentionne pas explicitement que la fonction doit être synchrone. Le type TypeScript \u003Ccode>MetaFunction\u003C/code> n'interdit pas le retour d'une Promise. C'est un piège silencieux. L'équipe a ouvert une issue sur le repo Remix pour demander soit un warning runtime, soit un type plus strict.\u003C/p>\n\u003Ch2>Ce qu'on en retient\u003C/h2>\n\u003Cp>Le streaming SSR est un gain de performance réel. Mais il déplace le contrat de rendu : tout ce qui doit apparaître dans le premier chunk — les métadonnées SEO — doit être résolu avant le flush. Une Promise non awaited dans \u003Ccode>meta()\u003C/code> ne lève aucune erreur. Elle produit un head vide, silencieusement, pendant des semaines.\u003C/p>\n\u003Cp>Le pattern est simple : les données SEO vivent dans le \u003Ccode>loader\u003C/code>, jamais dans un fetch async à l'intérieur de \u003Ccode>meta()\u003C/code>. Et la seule façon de détecter cette classe de bugs, c'est de tester le HTML brut — pas le DOM hydraté.\u003C/p>\n\u003Cp>Un monitoring continu qui compare le HTML SSR au DOM post-hydratation, comme ce que propose Seogard, détecte ce type de divergence en quelques minutes après déploiement. Pas en 18 jours.\u003C/p>\n\u003Cp>Le \u003Ccode>curl\u003C/code> le plus basique du monde aurait suffi. Personne ne l'a lancé.\u003C/p>\n\u003Cpre>\u003Ccode>\u003C/code>\u003C/pre>",null,12,[18,19,20,21],"remix","meta","streaming","ssr","Remix meta() async : metas vides en streaming SSR","Thu Jun 11 2026 16:01:48 GMT+0000 (Coordinated Universal Time)",[25,39,54,68,79,93],{"_id":26,"slug":27,"__v":6,"author":7,"canonical":28,"category":10,"createdAt":29,"date":30,"description":31,"image":15,"imageAlt":15,"readingTime":16,"tags":32,"title":37,"updatedAt":38},"6a2cf253aa6b273b0c0c9a5f","tanstack-router-ssr-title-pris-du-layout-au-lieu-de-la-leaf-route","https://seogard.io/blog/tanstack-router-ssr-title-pris-du-layout-au-lieu-de-la-leaf-route","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.",[33,34,21,35,36],"tanstack router","react","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)",{"_id":40,"slug":41,"__v":6,"author":7,"canonical":42,"category":10,"createdAt":43,"date":44,"description":45,"image":15,"imageAlt":15,"readingTime":46,"tags":47,"title":52,"updatedAt":53},"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,[48,49,50,51],"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":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,19],"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,35,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":46,"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.",[48,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},"SEO Technique","seo-technique",7,{"category":131,"slug":132,"count":129},"Crawl","crawl",{"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},"Outils","outils",4,{"category":155,"slug":156,"count":153},"E-commerce","e-commerce",{"category":158,"slug":159,"count":153},"Avancé","avance",{"category":161,"slug":162,"count":153},"Refonte","refonte",{"category":164,"slug":165,"count":153},"Redirections","redirections",{"category":167,"slug":168,"count":169},"IA & SEO","ia-seo",3,{"category":171,"slug":172,"count":169},"Contenu","contenu"]