SEO d'une SPA Vue.js : comment j'ai rendu mon app invisible pour Google (et comment j'ai corrigé)

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 avec title + description + og:url
  • Meta réactives : computed() pour toutes les vues dynamiques (données API)
  • titleTemplate dans App.vue pour éviter de répéter le nom du site partout
  • robots.txt : Allow pages publiques, Disallow pages privées, Sitemap en 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 NotFoundView avec noindex, pas une redirection vers login
  • URL canonique : og:url correct sur chaque page pour éviter le contenu dupliqué
  • Supprimer le document.title = ... manuel dans router.afterEach une fois useHead en place
  • Tester : Google Search Console → Inspection d'URL → "Tester l'URL en direct" (voit le HTML rendu après JS)

Commentaires (0)