Les Single Page Applications offrent une UX fluide mais posent des défis SEO majeurs. Voici comment concilier performance côté client et visibilité moteurs de recherche.
Le problème: JavaScript invisible pour les bots
html
<!-- Ce que voit Googlebot sur une SPA mal optimisée -->
<html>
<head>
<title>Loading...</title>
</head>
<body>
<div id="root"></div>
<script src="/app.js"></script>
</body>
</html>Conséquences mesurées:
- 0% de pages indexées sur Google
- Aucun snippet enrichi
- CTR organique proche de zéro
Solutions architecturales
1. Server-Side Rendering (SSR)
Next.js App Router
jsx
// app/products/[slug]/page.tsx
export async function generateMetadata({ params }) {
const product = await fetchProduct(params.slug);
return {
title: `${product.name} - ${product.category}`,
description: product.description.slice(0, 155),
openGraph: {
title: product.name,
description: product.description,
images: [product.imageUrl],
},
jsonLd: {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
description: product.description,
offers: {
'@type': 'Offer',
price: product.price,
priceCurrency: 'EUR'
}
}
};
}
export default async function ProductPage({ params }) {
const product = await fetchProduct(params.slug);
return (
<article>
<h1>{product.name}</h1>
<div itemScope itemType="http://schema.org/Product">
<h2 itemProp="name">{product.name}</h2>
<p itemProp="description">{product.description}</p>
<span itemProp="price">{product.price}€</span>
</div>
{/* Composant client pour l'interactivité */}
<AddToCartButton productId={product.id} />
</article>
);
}2. Static Site Generation (SSG)
jsx
// Gatsby ou Next.js getStaticProps
export async function getStaticPaths() {
const products = await fetchAllProducts();
return {
paths: products.map(product => ({
params: { slug: product.slug }
})),
fallback: 'blocking' // ISR pour nouveaux produits
};
}
export async function getStaticProps({ params }) {
const product = await fetchProduct(params.slug);
return {
props: { product },
revalidate: 3600 // Re-build toutes les heures
};
}3. Prerendering avec React Snap
bash
npm install --save-dev react-snapjson
// package.json
{
"scripts": {
"postbuild": "react-snap"
},
"reactSnap": {
"puppeteerArgs": ["--no-sandbox"],
"include": [
"/",
"/products",
"/about",
"/contact"
],
"asyncScriptTags": true,
"inlineCss": true
}
}4. Stratégie hybride: Critical Path SSR
jsx
// Route critique SEO = SSR
// Route interactive = CSR
const routes = [
{ path: '/', component: HomePage, ssr: true },
{ path: '/products/:slug', component: ProductPage, ssr: true },
{ path: '/dashboard', component: Dashboard, ssr: false }, // App-like
{ path: '/checkout', component: Checkout, ssr: false } // Pas indexé
];
// Configuration Vite/Webpack
export default {
build: {
rollupOptions: {
input: {
// Bundle séparé pour les routes SSR
'entry-ssr': './src/entry-ssr.jsx',
'entry-client': './src/entry-client.jsx'
}
}
}
};Optimisations techniques avancées
Structured Data automatisé
jsx
// Hook pour générer JSON-LD
function useStructuredData(type, data) {
useEffect(() => {
const script = document.createElement('script');
script.type = 'application/ld+json';
const structuredData = {
'@context': 'https://schema.org',
'@type': type,
...data
};
script.textContent = JSON.stringify(structuredData);
document.head.appendChild(script);
return () => document.head.removeChild(script);
}, [type, data]);
}
// Usage dans un composant
function ArticlePage({ article }) {
useStructuredData('Article', {
headline: article.title,
author: { '@type': 'Person', name: article.author },
datePublished: article.date,
image: article.imageUrl
});
return <article>...</article>;
}Critical CSS extraction
js
// critical-css-plugin.js
const critical = require('critical');
export default function criticalCssPlugin() {
return {
name: 'critical-css',
generateBundle(options, bundle) {
// Extraire le CSS critique pour chaque route
const routes = ['/products/', '/about/', '/contact/'];
routes.forEach(async (route) => {
const { css } = await critical.generate({
base: 'dist/',
src: route,
width: 1300,
height: 900,
inline: true
});
// Injecter dans le HTML de la route
bundle[`${route}/index.html`].code =
bundle[`${route}/index.html`].code.replace(
'</head>',
`<style>${css}</style></head>`
);
});
}
};
}Monitoring SEO technique
Core Web Vitals en production
js
// web-vitals-reporter.js
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals';
function sendToAnalytics(metric) {
fetch('/api/analytics', {
method: 'POST',
body: JSON.stringify({
page: window.location.pathname,
metric: metric.name,
value: metric.value,
id: metric.id
})
});
}
// Mesurer tous les CWV
getCLS(sendToAnalytics);
getFID(sendToAnalytics);
getFCP(sendToAnalytics);
getLCP(sendToAnalytics);
getTTFB(sendToAnalytics);Indexation monitoring
js
// scripts/check-indexation.js
const puppeteer = require('puppeteer');
async function checkIndexation(urls) {
const browser = await puppeteer.launch();
const results = [];
for (const url of urls) {
const page = await browser.newPage();
// Simuler Googlebot
await page.setUserAgent('Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)');
await page.goto(url, { waitUntil: 'networkidle2' });
// Vérifier le contenu rendu
const title = await page.title();
const h1 = await page.$eval('h1', el => el.textContent).catch(() => null);
const metaDesc = await page.$eval('meta[name="description"]', el => el.content).catch(() => null);
results.push({
url,
title: title !== 'Loading...' ? title : '❌ No title',
h1: h1 || '❌ No H1',
metaDescription: metaDesc || '❌ No meta description',
isIndexable: title !== 'Loading...' && h1 && metaDesc
});
await page.close();
}
await browser.close();
return results;
}
// Usage
checkIndexation([
'https://monsite.com/',
'https://monsite.com/products/chaussures-sport',
'https://monsite.com/about'
]).then(results => {
console.table(results);
const issues = results.filter(r => !r.isIndexable);
if (issues.length > 0) {
console.error(`⚠️ ${issues.length} pages non-indexables détectées`);
process.exit(1);
}
});Cas d'usage par architecture
E-commerce (10k+ produits)
Solution: Next.js ISR + Edge caching
- Pages produits: SSG avec revalidation
- Catégories: SSR pour le SEO
- Panier/compte: CSR pour la fluidité
js
// next.config.js
module.exports = {
experimental: {
isrMemoryCacheSize: 0, // Disable default ISR cache
},
async rewrites() {
return [
{
source: '/products/:slug',
destination: '/api/products/:slug' // API route with caching
}
];
}
};Blog/contenu (1k articles)
Solution: Gatsby + Netlify
- Build time: 2-3 minutes
- SEO parfait out-of-the-box
- CDN global inclus
SaaS Dashboard
Solution: Stratégie hybride
- Landing pages: SSG (marketing)
- Documentation: SSG (SEO)
- App dashboard: CSR (UX)
Métriques de succès
Avant optimisation SEO
- Pages indexées: 12/500 (2.4%)
- Organic CTR: 0.8%
- Avg. position: 47
Après implémentation SSR
- Pages indexées: 487/500 (97.4%)
- Organic CTR: 4.2% (+425%)
- Avg. position: 12 (-74%)
- Trafic organique: +340% en 6 mois
Checklist SEO technique SPA
- [ ] HTML complet côté serveur
- [ ] Temps de réponse < 200ms
- [ ] Critical CSS inline
- [ ] Meta tags dynamiques
- [ ] Structured data JSON-LD
- [ ] URLs propres et crawlables
- [ ] Sitemap XML automatique
- [ ] Robots.txt optimisé
- [ ] Core Web Vitals < seuils Google
- [ ] Monitoring indexation en continu
- La clé du succès
- choisir la bonne architecture selon vos contraintes business et techniques, puis optimiser méthodiquement chaque élément.

