J'ai passé trois mois à développer CitoyenNote — une plateforme de notation des services publics en France.
Carte interactive avec Leaflet, système d'évaluations, filtres par catégorie, dark mode, Pinia pour le state.
Stack : Vue.js 3 + Vite + PrimeVue 4, backend Symfony 6.4, PostgreSQL. J'étais content du résultat.
Puis j'ai ouvert Google Search Console et regardé ce que Google voyait de mon site :
<title>Vite App</title>, <html lang="">, zéro contenu indexé.
Mon app était invisible.
Le constat : ce que Google voit d'une SPA
Quand vous créez un projet avec npm create vue@latest ou Vite directement,
le fichier index.html généré ressemble à ça :
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
Voilà ce que Google indexe. Pas votre belle interface, pas votre carte, pas vos données.
Un div#app vide et un titre "Vite App".
Le raisonnement classique : "Googlebot exécute JavaScript, donc il verra le contenu rendu."
C'est vrai en partie. Googlebot exécute bien le JS — mais avec un délai. La page peut être mise
en file d'attente pendant des jours avant le rendu JS. Et surtout, le HTML initial reste un signal
fort : lang="" dit à Google que votre site n'a pas de langue déclarée. Pas de meta
description, pas d'Open Graph — votre page ne sera jamais partagée correctement sur LinkedIn ou Slack,
car ces crawlers n'exécutent pas JavaScript. Les aperçus seront vides.
Sur CitoyenNote, j'avais en plus un autre problème : les 404 redirigeaient vers /login.
Google avait donc indexé des dizaines d'URLs cassées comme "page de connexion". Le signal envoyé
était catastrophique. On y reviendra.
Étape 1 : index.html — la base que tout le monde oublie
La première correction est la plus basique et probablement la plus négligée. index.html
est statique — il ne changera pas de page en page — mais il couvre votre page d'accueil, et il donne
les métadonnées par défaut pour tout le reste.
Avant :
<html lang="">
<title>Vite App</title>
</html>
Après :
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CitoyenNote — Évaluez les services publics de votre commune</title>
<meta name="description" content="Découvrez et évaluez les services publics de votre commune. Mairies, hôpitaux, transports, écoles — les avis citoyens en un coup d'oeil." />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:title" content="CitoyenNote — Évaluez les services publics de votre commune" />
<meta property="og:description" content="Découvrez et évaluez les services publics de votre commune." />
<meta property="og:image" content="https://citoyen.anime-sanctuary.net/og-image.jpg" />
<meta property="og:url" content="https://citoyen.anime-sanctuary.net/" />
<meta property="og:locale" content="fr_FR" />
<meta property="og:site_name" content="CitoyenNote" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="CitoyenNote — Évaluez les services publics de votre commune" />
<meta name="twitter:description" content="Découvrez et évaluez les services publics de votre commune." />
<meta name="twitter:image" content="https://citoyen.anime-sanctuary.net/og-image.jpg" />
<!-- Canonical -->
<link rel="canonical" href="https://citoyen.anime-sanctuary.net/" />
<!-- Favicon + theme -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="theme-color" content="#1e40af" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
C'est con mais ça couvre déjà 50 % du problème. Votre page d'accueil a maintenant une identité. Quand quelqu'un partage le lien racine sur Slack ou LinkedIn, l'aperçu sera correct — même sans JS.
Étape 2 : @unhead/vue — les meta dynamiques
index.html est statique. Mais sur CitoyenNote, chaque page de service a des méta
différentes : le nom du service, la ville, la note moyenne, le nombre d'avis. La page de la mairie
de Lyon ne doit pas avoir le même titre que celle de l'hôpital de Bordeaux.
Avant, je gérais ça avec un router.afterEach qui faisait
document.title = .... Ça marchait pour le titre, mais pas pour les meta.
Et ça ne fonctionnait que côté client — les crawlers qui n'exécutent pas JS voyaient toujours
"Vite App".
La solution propre : @unhead/vue,
la bibliothèque de gestion de <head> utilisée par Nuxt sous le capot.
Installation :
npm install @unhead/vue
Setup dans main.js :
import { createApp } from 'vue'
import { createHead } from '@unhead/vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
const head = createHead()
app.use(router)
app.use(head)
app.mount('#app')
Template global dans App.vue :
// App.vue — <script setup>
import { useHead } from '@unhead/vue'
useHead({
titleTemplate: '%s | CitoyenNote',
})
Ça configure le pattern de titre : chaque vue définit sa partie, et | CitoyenNote
s'ajoute automatiquement.
Dans les vues statiques :
// HomeView.vue
import { useHead } from '@unhead/vue'
useHead({
title: 'Accueil',
meta: [
{
name: 'description',
content: 'Découvrez et évaluez les services publics de votre commune. Mairies, hôpitaux, transports, écoles.',
},
{ property: 'og:url', content: 'https://citoyen.anime-sanctuary.net/' },
],
})
Ce qui donne : <title>Accueil | CitoyenNote</title>.
Dans les vues dynamiques — le cas qui compte vraiment :
// ServiceDetailView.vue
import { ref, computed, onMounted } from 'vue'
import { useHead } from '@unhead/vue'
import { useRoute } from 'vue-router'
import { useServiceStore } from '@/stores/service'
const route = useRoute()
const serviceStore = useServiceStore()
const service = ref(null)
onMounted(async () => {
service.value = await serviceStore.fetchBySlug(route.params.slug)
})
useHead({
title: computed(() => service.value?.name ?? 'Service public'),
meta: [
{
name: 'description',
content: computed(() =>
service.value
? `${service.value.name} à ${service.value.city} — Note : ${service.value.averageRating}/5 (${service.value.reviewCount} avis). Consultez les évaluations citoyennes.`
: 'Consultez les évaluations des services publics.'
),
},
{
property: 'og:title',
content: computed(() => service.value?.name ?? 'Service public'),
},
{
property: 'og:description',
content: computed(() =>
service.value
? `Évaluations citoyennes à ${service.value.city} — ${service.value.averageRating}/5`
: 'Évaluations des services publics.'
),
},
{
property: 'og:url',
content: computed(() => `https://citoyen.anime-sanctuary.net/services/${route.params.slug}`),
},
],
})
Le computed() est crucial. Les meta se mettent à jour automatiquement quand
service.value est populé après le retour de l'API. Sans ça, vous auriez des meta
statiques qui ne correspondent à rien pendant le chargement — et qui resteraient statiques même
après.
J'ai aussi supprimé le router.afterEach qui faisait document.title = ...
manuellement. C'était du doublon, et ça entrait en conflit avec useHead.
Étape 3 : robots.txt
Avant la correction, CitoyenNote n'avait pas de robots.txt. Google crawlait donc
tout — y compris /profile, /mes-evaluations, les pages admin.
Des pages privées qui n'ont rien à faire dans un index.
User-agent: *
Allow: /
Disallow: /admin/
Disallow: /profile
Disallow: /mes-evaluations
Sitemap: https://citoyen.anime-sanctuary.net/sitemap.xml
Simple. Les pages publiques sont crawlables, les pages privées sont exclues, et le sitemap
pointe Google vers ce qui compte. La ligne Sitemap: dans le robots.txt est souvent
oubliée — c'est pourtant l'une des façons les plus directes d'indiquer à Google où chercher.
Étape 4 : sitemap dynamique
CitoyenNote recense des milliers de services publics, chacun avec son URL de type
/services/mairie-de-lyon-1er. Écrire le sitemap à la main est impossible.
J'ai séparé le problème en deux :
- Routes statiques :
/,/services,/connexion,/inscription,/mentions-legales— un fichier XML écrit à la main, mis à jour manuellement quand une page est ajoutée. - Routes dynamiques : toutes les pages
/services/:slug— générées par une commande Symfony.
// src/Command/GenerateSitemapCommand.php (structure simplifiée)
#[AsCommand(name: 'app:generate-sitemap')]
class GenerateSitemapCommand extends Command
{
public function __construct(
private ServiceRepository $serviceRepository,
private string $projectDir,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$services = $this->serviceRepository->findAllPublished();
$xml = new \SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"/>');
foreach ($services as $service) {
$url = $xml->addChild('url');
$url->addChild('loc', 'https://citoyen.anime-sanctuary.net/services/' . $service->getSlug());
$url->addChild('lastmod', $service->getUpdatedAt()->format('Y-m-d'));
$url->addChild('changefreq', 'weekly');
$url->addChild('priority', '0.8');
}
file_put_contents(
$this->projectDir . '/public/sitemap-services.xml',
$xml->asXML()
);
$output->writeln(sprintf('Sitemap généré : %d services.', count($services)));
return Command::SUCCESS;
}
}
La commande tourne chaque nuit via un cron :
0 3 * * * /path/to/php bin/console app:generate-sitemap >> /var/log/sitemap.log 2>&1
Un sitemap-index.xml regroupe les deux fichiers :
<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap>
<loc>https://citoyen.anime-sanctuary.net/sitemap-main.xml</loc>
</sitemap>
<sitemap>
<loc>https://citoyen.anime-sanctuary.net/sitemap-services.xml</loc>
</sitemap>
</sitemapindex>
Et robots.txt pointe vers le sitemap index, pas vers un des deux fichiers directement.
Étape 5 : JSON-LD structuré (Schema.org)
Les meta tags disent ce qu'est une page. Les données structurées JSON-LD disent
ce que contient la page — d'une façon que Google comprend sémantiquement.
Pour les pages de services publics, le type GovernmentService de Schema.org
est parfait.
Avec @unhead/vue, on injecte du JSON-LD directement dans useHead() :
useHead({
title: computed(() => service.value?.name ?? 'Service public'),
meta: [
// ... meta tags définis avant ...
],
script: [
{
type: 'application/ld+json',
innerHTML: computed(() =>
service.value
? JSON.stringify({
'@context': 'https://schema.org',
'@type': 'GovernmentService',
'name': service.value.name,
'serviceType': service.value.category,
'areaServed': {
'@type': 'City',
'name': service.value.city,
},
'aggregateRating': {
'@type': 'AggregateRating',
'ratingValue': service.value.averageRating,
'reviewCount': service.value.reviewCount,
'bestRating': 5,
'worstRating': 1,
},
})
: '{}'
),
},
],
})
Le résultat dans le DOM, une fois les données chargées :
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "GovernmentService",
"name": "Mairie de Lyon 1er",
"serviceType": "Administration municipale",
"areaServed": {
"@type": "City",
"name": "Lyon"
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": 3.8,
"reviewCount": 47,
"bestRating": 5,
"worstRating": 1
}
}
</script>
Google comprend maintenant que cette page parle d'un service public, dans quelle ville, avec quelle note et combien d'avis. Les rich snippets avec les étoiles dans les résultats de recherche deviennent possibles. Même sans SSR, Googlebot qui exécute le JS voit ces données structurées dans le HTML rendu.
Étape 6 : la route 404
Petit détail qui avait un impact disproportionné. Dans le routeur Vue, j'avais ça :
// Avant — catastrophique pour le SEO
{
path: '/:pathMatch(.*)*',
redirect: '/login',
}
Rediriger les 404 vers le login, c'est dire à Google que toutes vos pages cassées sont des pages de connexion. Google interprète une redirection comme "cette URL existe, elle mène là". Résultat : des dizaines d'URLs inexistantes indexées comme pages de login.
// Après — correct
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('@/views/NotFoundView.vue'),
}
// NotFoundView.vue
import { useHead } from '@unhead/vue'
useHead({
title: 'Page introuvable',
meta: [
{ name: 'robots', content: 'noindex' },
],
})
La balise noindex sur la vue 404 dit explicitement à Google de ne pas indexer ces URLs.
Et le composant affiche un contenu utile — liens vers l'accueil, vers la liste des services —
plutôt qu'un formulaire de connexion qui n'a aucun sens dans ce contexte.
La limitation honnête
Soyons clairs sur ce que cette approche couvre et ce qu'elle ne couvre pas.
Ce qui fonctionne : Googlebot exécute JavaScript et verra les meta tags générés
par useHead(). Le délai de rendu existe mais Google l'absorbe. Pour un site qui n'est
pas en compétition directe sur des requêtes ultra-concurrentielles, cette approche fonctionne.
Ce qui ne fonctionne pas : Les crawlers qui n'exécutent pas JavaScript.
LinkedIn unfurl, Slack link preview, Discord embed, Twitter Cards — tous lisent le HTML initial
et s'arrêtent là. Pour les pages dynamiques (fiches de services), si quelqu'un partage le lien
sur Slack, l'aperçu sera celui de votre index.html statique, pas celui de la fiche
spécifique. C'est une vraie limitation.
La solution complète à ce problème, c'est le SSR — Nuxt en l'occurrence pour Vue.js. Le serveur rend le HTML complet avec les bonnes meta avant d'envoyer la réponse, quel que soit le crawler.
Pour CitoyenNote, j'ai fait le choix conscient de rester sur une SPA pour l'instant.
Le trafic ne justifie pas encore la complexité d'une migration vers Nuxt.
Et ce que cette approche couvre — le crawl Google, les meta dynamiques, le sitemap, le JSON-LD —
représente 80 % de l'impact SEO pour 20 % de l'effort. Le rapport est bon.
Quand le passage à Nuxt sera justifié, tous les useHead() sont déjà en place.
La migration sera douce.
Checklist SEO pour SPA Vue.js
Ce que j'aurais voulu avoir avant de commencer :
index.html:lang,title, meta description, Open Graph complet, Twitter Card, canonical@unhead/vue:useHead()dans chaque vue avectitle+description+og:url- Meta réactives :
computed()pour toutes les vues dynamiques (données API) titleTemplatedansApp.vuepour éviter de répéter le nom du site partoutrobots.txt:Allowpages publiques,Disallowpages privées,Sitemapen bas- Sitemap statique pour les routes fixes + sitemap dynamique (script/commande) pour les routes paramétrées
- JSON-LD Schema.org sur les pages de contenu (service, article, produit, lieu...)
- Vue 404 : vraie page
NotFoundViewavecnoindex, pas une redirection vers login - URL canonique :
og:urlcorrect sur chaque page pour éviter le contenu dupliqué - Supprimer le
document.title = ...manuel dansrouter.afterEachune foisuseHeaden place - Tester : Google Search Console → Inspection d'URL → "Tester l'URL en direct" (voit le HTML rendu après JS)