Remix meta() async : metas vides en streaming SSR

Remix meta() async non awaited : quand le streaming SSR envoie un head vide à Googlebot

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 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.

T+18 jours — Jeudi 9h12, l'alerte Search Console

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 /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.

Premier 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.

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.

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 <title> et <meta name="description"> sont absentes du <head>. Totalement absentes.

Il ouvre l'outil d'inspection d'URL dans Search Console. Le HTML rendu par Google confirme : le <head> est vide de métadonnées SEO. Le <body> contient le contenu. Mais les balises meta critiques n'apparaissent nulle part dans le snapshot.

Le développeur fronce les sourcils. "Impossible. C'est du SSR. Remix rend côté serveur."

Il ouvre curl :

curl -s https://www.exemple-comparateur.fr/produit/assurance-auto-premium \
  | head -80

Le <head> retourné contient le charset, le viewport, les liens CSS — mais aucun <title>, aucune <meta name="description">. Les balises meta SEO ne sont pas dans la réponse HTTP initiale.

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.

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.

Le bug : meta() async dans un pipeline de streaming qui n'attend pas

Pour comprendre le problème, il faut comprendre comment Remix gère le streaming SSR et la fonction meta().

Le mécanisme normal

Dans 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 :

// app/routes/produit.$slug.tsx — version synchrone (avant la régression)
import type { MetaFunction } from "@remix-run/node";

export const loader = async ({ params }: LoaderFunctionArgs) => {
  const produit = await db.produit.findUnique({
    where: { slug: params.slug },
  });
  if (!produit) throw new Response("Not Found", { status: 404 });
  return json({ produit });
};

export const meta: MetaFunction<typeof loader> = ({ data }) => {
  if (!data) return [];
  return [
    { title: `${data.produit.nom} — Comparateur Assurance` },
    { name: "description", content: data.produit.metaDescription },
    { property: "og:title", content: data.produit.nom },
  ];
};

Ici, 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.

La modification fatale

L'é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 :

// app/routes/produit.$slug.tsx — version async (la régression)
import type { MetaFunction } from "@remix-run/node";

export const loader = async ({ params }: LoaderFunctionArgs) => {
  const produit = await db.produit.findUnique({
    where: { slug: params.slug },
  });
  if (!produit) throw new Response("Not Found", { status: 404 });
  return json({ produit });
};

// ⚠️ meta() est maintenant async
export const meta: MetaFunction<typeof loader> = async ({ data }) => {
  if (!data) return [];

  // Fetch des métadonnées enrichies depuis Strapi
  const enriched = await fetch(
    `https://cms.exemple-comparateur.fr/api/seo-meta/${data.produit.slug}`
  );
  const seoData = await enriched.json();

  return [
    { title: seoData.title || `${data.produit.nom} — Comparateur` },
    { name: "description", content: seoData.description || data.produit.metaDescription },
    { property: "og:title", content: seoData.ogTitle || data.produit.nom },
    { property: "og:image", content: seoData.ogImage || data.produit.image },
  ];
};

TypeScript ne proteste pas. L'application compile. Les tests passent.

Pourquoi ça casse en streaming

Le 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 <head> part en premier, dans le chunk initial.

Voici le entry.server.tsx du projet :

// app/entry.server.tsx
import { PassThrough } from "node:stream";
import { renderToPipeableStream } from "react-dom/server";
import { RemixServer } from "@remix-run/react";
import { createReadableStreamFromReadable } from "@remix-run/node";
import { isbot } from "isbot";

export default function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
) {
  const userAgent = request.headers.get("user-agent") || "";
  const callbackName = isbot(userAgent) ? "onAllReady" : "onShellReady";

  return new Promise((resolve, reject) => {
    const { pipe } = renderToPipeableStream(
      <RemixServer context={remixContext} url={request.url} />,
      {
        [callbackName]: () => {
          const body = new PassThrough();
          responseHeaders.set("Content-Type", "text/html");
          resolve(
            new Response(createReadableStreamFromReadable(body), {
              headers: responseHeaders,
              status: responseStatusCode,
            })
          );
          pipe(body);
        },
        onError(error) {
          responseStatusCode = 500;
          console.error(error);
        },
      }
    );
  });
}

La ligne critique : const callbackName = isbot(userAgent) ? "onAllReady" : "onShellReady".

En 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.

Mais 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 <head>.

Le résultat en HTML brut :

<!-- Ce que reçoit Googlebot (curl ou fetch brut) -->
<!DOCTYPE html>
<html lang="fr">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <link rel="stylesheet" href="/build/css/app-DK4X2.css" />
  <!-- ❌ Pas de <title> -->
  <!-- ❌ Pas de <meta name="description"> -->
  <!-- ❌ Pas de og:title, og:image -->
</head>
<body>
  <div id="root">
    <!-- Le contenu complet du produit est bien rendu -->
    <h1>Assurance Auto Premium — Comparaison détaillée</h1>
    <p>Découvrez notre analyse complète de l'offre Assurance Auto Premium...</p>
  </div>
  <script src="/build/js/entry.client-H7YK3.js"></script>
</body>
</html>

Pourquoi le navigateur affiche les bonnes metas

Côté client, React s'hydrate et le composant <Meta /> 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.

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.

Pourquoi les tests n'ont rien détecté

Les 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.

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.

Un curl suffisait. Mais curl ne faisait pas partie de la CI.

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.

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ù une fonction metadata async qui throw servait le fallback par défaut. 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.

Le fix : remettre les données dans le loader, pas dans meta()

Le patch

La 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.

// app/routes/produit.$slug.tsx — version corrigée
import type { MetaFunction, LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";

export const loader = async ({ params }: LoaderFunctionArgs) => {
  const produit = await db.produit.findUnique({
    where: { slug: params.slug },
  });
  if (!produit) throw new Response("Not Found", { status: 404 });

  // ✅ Le fetch CMS est fait dans le loader, pas dans meta()
  let seoData = null;
  try {
    const enriched = await fetch(
      `https://cms.exemple-comparateur.fr/api/seo-meta/${produit.slug}`,
      { signal: AbortSignal.timeout(2000) } // timeout de sécurité
    );
    if (enriched.ok) {
      seoData = await enriched.json();
    }
  } catch {
    // Fallback silencieux sur les données produit
  }

  return json({ produit, seoData });
};

// ✅ meta() est synchrone, reçoit les données du loader
export const meta: MetaFunction<typeof loader> = ({ data }) => {
  if (!data) return [];
  const { produit, seoData } = data;

  return [
    { title: seoData?.title || `${produit.nom} — Comparateur Assurance` },
    {
      name: "description",
      content: seoData?.description || produit.metaDescription,
    },
    { property: "og:title", content: seoData?.ogTitle || produit.nom },
    { property: "og:image", content: seoData?.ogImage || produit.image },
  ];
};

Points clés du correctif :

  1. Le fetch CMS est déplacé dans le loader. Le loader est toujours awaited par Remix avant le rendu.
  2. Un timeout de 2 secondes protège contre un CMS lent. Sans réponse, les données produit en base servent de fallback.
  3. meta() redevient synchrone. Elle reçoit data déjà résolu et retourne un tableau immédiatement.

Vérification avant redéploiement

Avant de push en production, l'équipe ajoute un test CI qui vérifie le HTML brut :

# test-meta-ssr.sh — ajouté au pipeline CI
#!/bin/bash
set -e

URL="http://localhost:3000/produit/assurance-auto-premium"

# Démarre le serveur Remix en mode production
npm run build && npm run start &
SERVER_PID=$!
sleep 5

# Vérifie la présence des metas dans le HTML brut (pas de JS)
HTML=$(curl -s "$URL")

echo "$HTML" | grep -q '<title>' || { echo "❌ FAIL: <title> absent du HTML SSR"; kill $SERVER_PID; exit 1; }
echo "$HTML" | grep -q 'meta name="description"' || { echo "❌ FAIL: meta description absente du HTML SSR"; kill $SERVER_PID; exit 1; }
echo "$HTML" | grep -q 'og:title' || { echo "❌ FAIL: og:title absent du HTML SSR"; kill $SERVER_PID; exit 1; }

echo "✅ PASS: toutes les metas présentes dans le HTML SSR"
kill $SERVER_PID

Invalidation et relance de crawl

Le fix est déployé un mardi à 11h. L'équipe :

  1. Purge le cache CDN (Cloudflare) sur le pattern /produit/*.
  2. Soumet les 3 200 URLs produit via l'API d'indexation de Search Console (batch de 200 URLs/jour via le quota).
  3. Met à jour le sitemap.xml avec un <lastmod> frais pour forcer un recrawl prioritaire.

Chronologie de récupération

  • J+2 : Google recrawle les 800 premières pages. L'inspection d'URL montre un head complet.
  • J+7 : 2 400 pages réindexées avec les bonnes metas. Les positions commencent à remonter.
  • J+14 : 3 100 pages sur 3 200 récupèrent leur position antérieure (±0.5 position).
  • J+21 : trafic organique revenu à 95% du niveau pré-incident. Les 5% restants correspondent à des requêtes saisonnières en déclin naturel.
  • J+30 : situation normalisée.

Au total, l'incident aura coûté environ 180K clics sur 39 jours (18 jours de régression + 21 jours de récupération).

Mesures de prévention ajoutées

L'équipe met en place trois garde-fous :

  1. 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.

  2. Test CI curl : le script ci-dessus tourne sur chaque PR qui touche un fichier routes/. Temps d'exécution : 8 secondes.

  3. 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.

La documentation officielle de Remix sur la fonction 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.

Ce qu'on en retient

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 meta() ne lève aucune erreur. Elle produit un head vide, silencieusement, pendant des semaines.

Le 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é.

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.

Le curl le plus basique du monde aurait suffi. Personne ne l'a lancé.

Articles connexes

Framework13 juin 2026

TanStack Router SSR : le title vient du layout, pas de la page

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.

Framework12 juin 2026

Astro View Transitions : meta head figées après navigation

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.

Framework10 juin 2026

Nuxt useSeoMeta : le child override les meta du layout

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.

Framework10 juin 2026

SvelteKit : title vide en prod, 0 clic sur 3 semaines

Un +page.svelte sans title écrase le layout parent. Googlebot voit un <title> vide. Récit, diagnostic et fix complet en SvelteKit.

Framework9 juin 2026

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

Une promise non gérée dans generateMetadata fait tomber les titles sur 1 200 pages produit. Récit technique, diagnostic et fix complet.

Framework8 juin 2026

Astro Content Collections : 80 titles vides après refacto

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