# 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).*