SvelteKit : quand un +page.svelte vide efface le title pour Googlebot
Jeudi 14h. Un studio web français livre la refonte complète d'un site vitrine B2B SaaS — 320 pages, 18 000 visites organiques par mois. Stack : SvelteKit 2.5, adapter-node, déployé sur Fly.io. Le title de chaque page est centralisé dans +layout.ts. Propre, DRY, élégant. Le navigateur affiche les bons titres partout. Lighthouse passe au vert. Personne ne regarde le HTML brut servi en SSR. Trois semaines plus tard, la home a perdu 73 % de son trafic organique. Le <title> que voit Googlebot est une chaîne vide.
Lundi, T+18 jours — "On a un problème avec la home"
9h12. La responsable marketing ouvre Search Console pour préparer le reporting mensuel. Le rapport de performance montre un effondrement net de la query principale — le nom de marque. De 420 clics par jour à 110. La courbe a décroché le lendemain du déploiement, il y a dix-huit jours.
9h31. Elle envoie un screenshot au lead dev. Première hypothèse : un problème d'indexation post-migration, peut-être un canonical cassé. L'équipe vérifie. Le canonical est correct. Le sitemap est présent. Le robots.txt autorise tout.
9h58. Un dev ouvre l'inspecteur Chrome sur la home. L'onglet du navigateur affiche bien "Acme — Plateforme de gestion d'équipe". Rien d'anormal. Il ouvre le code source (Ctrl+U). Le <title> est là, dans le <head>. Hypothèse écartée — le title semble correct.
10h14. La responsable marketing insiste. Elle montre le rapport "Pages" de Search Console. La home est listée avec le titre affiché : rien. Littéralement une cellule vide dans la colonne "Title". Le dev relance une inspection d'URL dans Search Console. Le HTML rendu par Googlebot contient :
<title></title>
Vide. Pas de fallback, pas de texte partiel. Une balise title ouverte et fermée sans contenu.
10h33. L'équipe lance un crawl Screaming Frog en mode "JavaScript rendering" sur les 320 URLs du site. Résultat : la home et 11 pages "hub" (celles sans +page.svelte dédié, qui héritent du layout) ont un <title> vide. Les 308 autres pages — celles avec un composant +page.svelte explicite qui définit leur propre title via <svelte:head> — sont correctes.
11h02. Le pattern est clair. Le problème touche exclusivement les pages dont le title provient du layout parent et qui n'ont pas de <svelte:head> dans leur propre +page.svelte. Le lead dev comprend que ce n'est pas un bug mineur. Les 12 pages affectées représentent 41 % du trafic organique total — la home à elle seule en représente 22 %.
Les chiffres consolidés sur les 18 jours écoulés : −4 200 clics sur la home, −1 800 clics sur les pages hub. Impressions en chute de 38 %. Le CTR de la home est passé de 12 % à 3,4 % — Google affiche l'URL brute à la place du title manquant, ou un extrait incohérent tiré du body.
Le bug : comment +layout.ts perd la bataille contre +page.svelte
Pour comprendre ce qui s'est passé, il faut remonter à l'architecture initiale du projet.
La stratégie de titles centralisée
L'équipe avait choisi de gérer tous les titles dans +layout.ts à la racine, via la fonction load :
// src/routes/+layout.ts
export const load = async ({ url }) => {
const titles: Record<string, string> = {
'/': 'Acme — Plateforme de gestion d\'équipe',
'/produit': 'Produit — Fonctionnalités Acme',
'/tarifs': 'Tarifs — Acme',
'/blog': 'Blog — Acme',
'/a-propos': 'À propos — Acme',
// ... 7 autres hubs
};
return {
title: titles[url.pathname] ?? 'Acme — Gestion d\'équipe',
};
};
Et dans le composant layout correspondant :
<!-- src/routes/+layout.svelte -->
<script>
export let data;
</script>
<svelte:head>
<title>{data.title}</title>
<meta name="description" content="Acme simplifie la gestion d'équipe." />
</svelte:head>
<slot />
À ce stade, tout fonctionne. Le layout définit le <title>, les pages enfants héritent. Dans le navigateur, le SSR produit le bon HTML. Googlebot voit le bon title.
L'arrivée du problème : le +page.svelte "vide"
Pendant la refonte, un développeur crée un +page.svelte pour la home afin d'y ajouter un composant hero. Le fichier est minimal :
<!-- src/routes/+page.svelte -->
<script>
export let data;
</script>
<Hero />
<Features />
<Testimonials />
Pas de <svelte:head>. Pas de title. Le développeur considère que le layout s'en charge — ce qui est vrai dans la plupart des frameworks. Mais SvelteKit a un comportement spécifique documenté dans une section discrète de la doc officielle : quand un +page.svelte existe pour une route, SvelteKit considère que cette page "possède" son propre <head>.
Le mécanisme de résolution du <svelte:head>
Voici ce que SvelteKit fait réellement lors du rendu SSR :
- Le layout rend son
<svelte:head>, qui injecte<title>Acme — Plateforme de gestion d'équipe</title>. - Le
+page.svelteest rendu à l'intérieur du<slot />. - SvelteKit détecte que la page enfant a été rendue. Même sans
<svelte:head>explicite, le framework applique un mécanisme de "reset" des balises head gérées par le parent si la page enfant est considérée comme active.
Le résultat en SSR — le HTML brut envoyé au client (et à Googlebot) :
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<title></title>
<meta name="description" content="Acme simplifie la gestion d'équipe." />
</head>
<body>
<!-- contenu du hero, features, etc. -->
</body>
</html>
Le <title> est vide. La meta description, elle, survit — parce qu'elle est définie dans le layout et n'entre pas en collision avec un composant enfant.
Le piège est subtil : côté client, après hydratation, le title s'affiche correctement dans l'onglet du navigateur. Le JavaScript du layout s'exécute, met à jour le DOM, et le title apparaît. Mais Googlebot, même s'il exécute du JavaScript, reçoit d'abord le HTML SSR. Et dans de nombreux cas de pages légères comme celle-ci, le renderer de Google s'appuie sur le HTML initial sans ré-exécuter le cycle complet de hydratation côté client.
La preuve par curl
Une simple commande suffit à reproduire le problème :
curl -s https://acme.example.com/ | grep -oP '<title>.*?</title>'
Résultat :
<title></title>
La même commande sur une page avec un <svelte:head> explicite, par exemple /blog/premiers-pas :
<title>Premiers pas avec Acme — Blog</title>
Pourquoi les tests n'ont rien vu
Trois raisons convergentes :
1. Les tests E2E vérifient le DOM après hydratation. L'équipe utilise Playwright. Leurs assertions portent sur page.title(), qui renvoie le title visible dans l'onglet — donc le title post-hydratation, correct. Aucun test ne vérifie le HTML brut de la réponse SSR.
2. Lighthouse et les audits navigateur voient le rendu final. Lighthouse exécute la page dans un Chromium complet. Le JavaScript tourne, le title est injecté côté client. Score SEO : 100.
3. Le preview Vercel/Fly.io est identique. L'environnement de staging sert le même comportement. Le QA visuel ne détecte rien parce que l'onglet du navigateur affiche le bon title.
Le problème n'est visible que dans deux contextes : le HTML brut (curl, View Source) et le rendu Googlebot (Search Console > Inspection d'URL). L'équipe ne vérifie ni l'un ni l'autre avant de déployer.
Ce pattern — une divergence entre le rendu navigateur et le rendu SSR/HTTP — est un classique des frameworks modernes. On l'a documenté dans des contextes similaires avec Nuxt et le useSeometa écrasé par un composant enfant et avec Next.js et les metadata async qui servent un fallback vide.
Le fix : trois niveaux de correction
Niveau 1 — Le patch immédiat
Ajouter un <svelte:head> explicite dans chaque +page.svelte qui n'en a pas :
<!-- src/routes/+page.svelte (corrigé) -->
<script>
export let data;
</script>
<svelte:head>
<title>{data.title}</title>
</svelte:head>
<Hero />
<Features />
<Testimonials />
Le data.title provient toujours du +layout.ts parent — il est accessible dans data car SvelteKit merge les données du layout et de la page. Le <svelte:head> de la page prend explicitement le relais.
L'équipe patche les 12 fichiers concernés en 20 minutes. Commit, déploiement.
Niveau 2 — La vérification post-déploiement
Après déploiement, validation immédiate :
# Vérifier les 12 URLs patchées
for url in "/" "/produit" "/tarifs" "/blog" "/a-propos" "/contact" \
"/cas-clients" "/integrations" "/securite" "/api" "/changelog" "/presse"; do
title=$(curl -s "https://acme.example.com${url}" | grep -oP '(?<=<title>).*?(?=</title>)')
echo "${url} → ${title}"
done
Résultat attendu :
/ → Acme — Plateforme de gestion d'équipe
/produit → Produit — Fonctionnalités Acme
/tarifs → Tarifs — Acme
...
Ensuite, demande de ré-indexation dans Search Console pour chacune des 12 URLs. L'équipe utilise l'API Indexing pour accélérer — mais celle-ci est réservée aux pages JobPosting et BroadcastEvent. Pour les autres, la demande manuelle via l'interface reste la seule option.
Niveau 3 — La protection contre la récurrence
L'équipe met en place trois garde-fous :
1. Un test Playwright qui vérifie le HTML SSR brut :
// tests/seo/title-ssr.spec.ts
import { test, expect } from '@playwright/test';
const criticalPages = [
{ url: '/', expected: 'Acme — Plateforme de gestion d\'équipe' },
{ url: '/produit', expected: 'Produit — Fonctionnalités Acme' },
{ url: '/tarifs', expected: 'Tarifs — Acme' },
];
for (const page of criticalPages) {
test(`SSR title for ${page.url}`, async ({ request }) => {
const response = await request.get(page.url);
const html = await response.text();
const match = html.match(/<title>(.*?)<\/title>/);
expect(match).not.toBeNull();
expect(match![1]).toBe(page.expected);
});
}
Ce test utilise request.get — pas de navigateur, pas d'hydratation. Il vérifie le HTML brut, exactement comme curl ou Googlebot en première passe.
2. Un hook SvelteKit qui logge un warning en dev si le title SSR est vide :
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
const response = await resolve(event);
if (event.url.pathname !== '/__data.json' && response.headers.get('content-type')?.includes('text/html')) {
const body = await response.clone().text();
const titleMatch = body.match(/<title>(.*?)<\/title>/);
if (!titleMatch || titleMatch[1].trim() === '') {
console.warn(`[SEO] Empty <title> detected on ${event.url.pathname}`);
}
}
return response;
};
En développement, ce warning apparaît dans la console du terminal. Impossible de le rater.
3. Un crawl Screaming Frog hebdomadaire en mode JavaScript rendering, avec une alerte custom sur les pages dont le title SSR diffère du title JS. L'export CSV est comparé via un script CI qui échoue si une divergence apparaît.
Temps de récupération
Le déploiement du fix a lieu un mardi à 11h. Voici la timeline de récupération observée dans Search Console :
- J+2 : Googlebot recrawle la home. Le nouveau title apparaît dans l'inspection d'URL.
- J+5 : Le snippet de la home dans les SERPs affiche de nouveau le vrai title.
- J+8 : Les impressions de la home remontent à 80 % du niveau pré-incident.
- J+14 : Le CTR de la home revient à 11,6 % (vs 12 % avant l'incident). Quasi-retour à la normale.
- J+23 : Les 12 pages affectées ont toutes retrouvé leur niveau de trafic antérieur, à ±5 %.
Total des pertes estimées sur la période de 23 jours d'incident + 14 jours de récupération : environ 8 400 clics organiques. Pour un site B2B SaaS dont le coût d'acquisition payant est à 4,20 € le clic, l'équivalent en budget ads perdu dépasse 35 000 €.
Le parallèle est direct avec d'autres incidents de refonte où un élément SEO critique disparaît sans que personne ne le voie — comme un H1 remplacé par un div lors d'une refonte header ou un composant heading du design system qui rend un div au lieu d'un Hn.
Ce qu'on en retient
Le modèle mental "le layout gère le title, les pages héritent" ne tient pas dans SvelteKit. Dès qu'un +page.svelte existe, il doit déclarer son propre <svelte:head> — même si le contenu vient du layout. C'est un contrat implicite du framework que ni la documentation ni les outils de dev ne rendent évident.
Le vrai problème n'est pas le bug. C'est le délai de détection : 18 jours. Un test SSR en CI aurait détecté la régression en 30 secondes. Un monitoring continu type Seogard aurait alerté sur la divergence SSR/navigateur dans les minutes suivant le déploiement — pas trois semaines après.
Chaque framework a ses pièges de résolution <head>. Les connaître ne suffit pas. Il faut les tester comme du code métier.