← Contextes /
seo-spa-vuejs.md 347 lignes · 10.5 KB
Personnaliser Télécharger
# CLAUDE.md — SEO pour SPA Vue.js

> Contexte spécialisé pour Claude Code. Coller ce fichier à la racine du projet pour guider le travail SEO sur une application Vue.js single-page (Vite, vue-router, Pinia).

---

## Quand utiliser ce contexte
- ✅ SPA Vue.js (Vite + vue-router) avec des pages publiques indexables par les moteurs de recherche
- ✅ Choix d'architecture CSR vs SSR/SSG : arbitrage SEO vs complexité de déploiement
- ✅ Audit SEO d'une app existante avec du contenu rendu côté client uniquement
- ❌ Application 100% authentifiée (dashboard, back-office) : Google ne crawle pas les pages derrière login, le SEO n'a pas d'importance
- ❌ Nuxt.js ou Vite SSR déjà configuré : ce contexte couvre le cas SPA CSR sans SSR

---

## Section 1 : Audit SEO initial

### Ce que Google voit par défaut

Une SPA Vue.js + Vite par défaut sert un `index.html` quasi vide :
- `<title>Vite App</title>` (le défaut Vite)
- `<html lang="">` (attribut lang vide)
- `<div id="app"></div>` (aucun contenu)
- Zéro meta description, zéro Open Graph

### Vérifier l'état actuel

1. **View Source** (pas Inspect) — c'est ce que les crawlers voient
2. **Google Search Console** → Inspection d'URL → "Tester l'URL en direct" → voir le HTML rendu
3. **curl** le site : `curl -s https://monsite.com | head -30`
4. Vérifier les pages clés : accueil, pages dynamiques (détail produit/service), pages légales

### Checklist audit

- [ ] `<html lang="fr">` (ou la bonne langue)
- [ ] `<title>` pertinent (pas "Vite App")
- [ ] `<meta name="description">` présent
- [ ] Open Graph complet (og:title, og:description, og:image, og:url, og:type, og:locale)
- [ ] Twitter Card (twitter:card, twitter:title, twitter:description)
- [ ] `<link rel="canonical">`
- [ ] robots.txt accessible
- [ ] sitemap.xml accessible et à jour
- [ ] Pas de `<meta name="robots" content="noindex">` sur les pages publiques
- [ ] 404 gérée proprement (pas redirection vers login)

---

## Section 2 : index.html — la base statique

### Template minimal

Le `index.html` à la racine doit contenir les meta statiques de fallback. C'est ce qui s'affiche avant que Vue.js ne s'initialise.

```html
<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />

    <!-- SEO de base -->
    <title>MonApp — Description courte</title>
    <meta name="description" content="Description de 150-160 caractères orientée action." />
    <link rel="canonical" href="https://monsite.com/" />

    <!-- Open Graph -->
    <meta property="og:type" content="website" />
    <meta property="og:title" content="MonApp — Description courte" />
    <meta property="og:description" content="Description pour les réseaux sociaux." />
    <meta property="og:image" content="https://monsite.com/og-image.jpg" />
    <meta property="og:url" content="https://monsite.com/" />
    <meta property="og:locale" content="fr_FR" />
    <meta property="og:site_name" content="MonApp" />

    <!-- Twitter Card -->
    <meta name="twitter:card" content="summary_large_image" />
    <meta name="twitter:title" content="MonApp — Description courte" />
    <meta name="twitter:description" content="Description pour Twitter." />
    <meta name="twitter:image" content="https://monsite.com/og-image.jpg" />

    <!-- Favicon + theme -->
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="theme-color" content="#3aaa64" />
</head>
<body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
</body>
</html>
```

### Règles

- **Toujours** mettre `lang="fr"` (ou la bonne langue) sur `<html>`
- L'image OG doit faire **1200×630px** minimum
- Le canonical doit pointer vers l'URL propre (pas de trailing slash en double, pas de paramètres)
- Ces meta statiques sont le filet de sécurité — elles s'affichent si @unhead/vue n'a pas encore pris le relais

---

## Section 3 : @unhead/vue — meta dynamiques

### Installation

```bash
npm install @unhead/vue
```

### Setup dans main.js

```javascript
import { createApp } from 'vue'
import { createHead } from '@unhead/vue'
import App from './App.vue'

const app = createApp(App)
const head = createHead()
app.use(head)
app.mount('#app')
```

### Template de titre dans App.vue

```javascript
import { useHead } from '@unhead/vue'

useHead({
  titleTemplate: '%s | MonApp',
})
```

### useHead dans chaque vue

**Page statique :**
```javascript
useHead({
  title: 'Accueil',
  meta: [
    { name: 'description', content: 'Page d\'accueil de MonApp.' },
    { property: 'og:title', content: 'MonApp — Accueil' },
  ],
})
```

**Page dynamique (données API) — utiliser computed() :**
```javascript
import { computed } from 'vue'
import { useHead } from '@unhead/vue'

const item = ref(null)

useHead({
  title: computed(() => item.value?.name ?? 'Chargement...'),
  meta: [
    {
      name: 'description',
      content: computed(() =>
        item.value
          ? `${item.value.name} — ${item.value.summary}`
          : 'Chargement...'
      ),
    },
  ],
})
```

### Règles

- **Toujours** `computed()` pour les meta qui dépendent de données asynchrones
- Ne PAS manipuler `document.title` manuellement — supprimer tout `router.afterEach` qui le fait
- Un `useHead()` par vue, pas de logique partagée sauf composable dédié
- Le titleTemplate évite la répétition du nom de l'app

---

## Section 4 : robots.txt et sitemap

### robots.txt

Placer dans `/public/robots.txt` (copié tel quel par Vite au build) :

```
User-agent: *
Allow: /
Disallow: /admin/
Disallow: /profile
Disallow: /mes-evaluations
Disallow: /login
Disallow: /register

Sitemap: https://monsite.com/sitemap.xml
```

### Règles robots.txt
- **Bloquer** toutes les pages privées / auth-only
- **Bloquer** login/register (pas de valeur SEO)
- **Toujours** pointer vers le sitemap
- Tester avec Google Search Console → robots.txt Tester

### Sitemap

**Routes statiques** — sitemap.xml manuel ou généré au build :
```xml
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url><loc>https://monsite.com/</loc><changefreq>weekly</changefreq><priority>1.0</priority></url>
  <url><loc>https://monsite.com/services</loc><changefreq>daily</changefreq><priority>0.9</priority></url>
</urlset>
```

**Routes dynamiques** (milliers de pages) — générer côté backend :
- Commande Symfony/Go/Node qui lit la BDD et génère le XML
- Cron nocturne pour régénérer
- Utiliser un sitemap index si > 50 000 URLs

---

## Section 5 : JSON-LD (Schema.org)

### Injection via useHead

```javascript
useHead({
  script: [
    {
      type: 'application/ld+json',
      innerHTML: computed(() => JSON.stringify({
        '@context': 'https://schema.org',
        '@type': 'Product', // ou Service, Article, LocalBusiness...
        'name': item.value?.name,
        'description': item.value?.description,
        'aggregateRating': {
          '@type': 'AggregateRating',
          'ratingValue': item.value?.rating,
          'reviewCount': item.value?.reviewCount,
          'bestRating': 5,
        },
      })),
    },
  ],
})
```

### Types Schema.org courants

| Type de page | Schema.org type |
|-------------|----------------|
| Page produit | Product |
| Service | Service, GovernmentService |
| Article de blog | BlogPosting, Article |
| Commerce local | LocalBusiness |
| Personne / CV | Person |
| FAQ | FAQPage |
| Événement | Event |

### Règles
- Tester avec https://search.google.com/test/rich-results
- Ne mettre que des données réelles (pas de placeholder)
- `computed()` obligatoire si les données viennent d'une API
- Un seul bloc JSON-LD principal par page

---

## Section 6 : Route 404 et erreurs

### Vue Router — catch-all

```javascript
{
  path: '/:pathMatch(.*)*',
  name: 'NotFound',
  component: () => import('@/views/NotFoundView.vue'),
}
```

### Ce qu'il ne faut PAS faire
- ❌ Rediriger les 404 vers `/login` ou `/` (mauvais signal SEO)
- ❌ Laisser le catch-all sans composant (page blanche)

### NotFoundView.vue

```javascript
useHead({
  title: 'Page introuvable',
  meta: [
    { name: 'robots', content: 'noindex' },
  ],
})
```

- `noindex` sur les 404 pour ne pas polluer l'index Google
- Proposer un lien retour vers l'accueil et une recherche

---

## Section 7 : Limitations CSR et alternatives

### Ce que le rendu client (CSR) ne couvre PAS

| Crawler | Exécute JS ? | Lit les meta @unhead ? |
|---------|-------------|----------------------|
| Googlebot | ✅ Oui | ✅ Oui |
| Bingbot | ✅ Oui (partiel) | ⚠️ Parfois |
| LinkedIn preview | ❌ Non | ❌ Non (lit index.html) |
| Twitter/X Card | ❌ Non | ❌ Non (lit index.html) |
| Slack unfurl | ❌ Non | ❌ Non (lit index.html) |
| Facebook share | ⚠️ Partiel | ⚠️ Partiel |

### Quand passer au SSR (Nuxt)

- Si le trafic justifie l'investissement
- Si les previews LinkedIn/Slack/Twitter sont critiques (B2B, content marketing)
- Si le site est principalement du contenu (blog, e-commerce catalogue)

### Quand rester en CSR

- App interne / dashboard
- MVP / side project
- Les meta statiques dans index.html suffisent pour la page d'accueil
- Les `useHead()` avec `computed()` sont compatibles Nuxt — migration future facile

---

## Section 8 : Checklist finale

```
□ index.html : lang, title, meta description, Open Graph, Twitter Card, canonical, theme-color
□ @unhead/vue installé et configuré dans main.js
□ titleTemplate dans App.vue
□ useHead() dans chaque vue avec title + description
□ computed() pour les meta dynamiques (données API)
□ Supprimé tout document.title manuel
□ robots.txt : Allow publiques, Disallow privées, Sitemap
□ Sitemap : statique + dynamique pour les routes paramétrées
□ JSON-LD Schema.org sur les pages de contenu
□ Route 404 : NotFoundView avec noindex, pas de redirection
□ Canonical URL sur chaque page
□ Image OG : 1200×630px minimum
□ Test : Google Search Console → Inspection d'URL
□ Test : https://search.google.com/test/rich-results
□ Test : partager l'URL sur LinkedIn/Twitter pour vérifier le preview
```

---

*Last updated: 2025-03 — Revoir si : Vue 3.5+ (changements SSR/hydration), Googlebot mise à jour du rendering pipeline, ou Vite 6+ (SSR API stable).*