jguillaumesio
webdevreact

Aperçus sociaux en React : détection du User-Agent

Comment gérer les balises Open Graph pour les aperçus LinkedIn, Google et Discord dans une SPA React via la détection du user-agent côté serveur avec nginx/.htaccess.

Aperçu de lien Discord : cassé contre corrigé

Vous partagez un lien vers votre appli React sur Discord. Au lieu d’un bel aperçu avec titre, description et image, vous n’obtenez rien. Ou pire, une carte générique “React App” sans aucune information utile.

Le problème : les SPA React rendent tout côté client. Les crawlers des réseaux sociaux n’exécutent pas le JavaScript. Ils interrogent votre serveur, reçoivent un <head> vide, et abandonnent.

Pourquoi les aperçus sociaux cassent en React

Quand Discord, LinkedIn, Slack ou Google crawlent une URL, ils :

  1. Envoient une requête HTTP avec un User-Agent spécifique
  2. Parsent le <head> HTML à la recherche des balises OG/meta
  3. Affichent l’aperçu

Ils n’exécutent pas le JavaScript. Donc votre appli React superbement rendue avec ses balises meta react-helmet ? Invisible pour eux.

Le crawler voit :

<!DOCTYPE html>
<html>
<head>
  <title>React App</title>
  <!-- les balises react-helmet ne sont pas encore rendues -->
</head>
<body>
  <div id="root"></div>
  <script src="/static/js/main.js"></script>
</body>
</html>

La solution : détection du User-Agent côté serveur

L’idée est simple : quand un crawler demande une page, servez du HTML pré-rendu avec les bonnes balises meta. Quand un vrai utilisateur la demande, servez la SPA normale.

Les User-Agents de crawlers à détecter

PlateformeLe User-Agent contient
LinkedInLinkedInBot
DiscordDiscordbot
Twitter/XTwitterbot
SlackSlackbot
GoogleGooglebot
Facebookfacebookexternalhit

Option 1 : reverse proxy nginx

# /etc/nginx/conf.d/preview.conf

# Mapper les user-agents vers une variable
map $http_user_agent $is_crawler {
    default                 0;
    "~*LinkedInBot"         1;
    "~*Discordbot"          1;
    "~*Twitterbot"          1;
    "~*Slackbot"            1;
    "~*Googlebot"           1;
    "~*facebookexternalhit" 1;
    "~*WhatsApp"            1;
}

server {
    listen 80;
    server_name myapp.com;
    root /var/www/myapp/build;

    location / {
        if ($is_crawler) {
            # Rediriger vers un endpoint serveur qui renvoie du HTML avec les balises meta
            rewrite ^ /preview$uri last;
        }

        # Service normal de la SPA
        try_files $uri /index.html;
    }

    location /preview {
        internal;
        proxy_pass http://localhost:3001;
        proxy_set_header Host $host;
        proxy_set_header X-Real-URI $request_uri;
    }
}

Le serveur d’aperçu (Node/Express) :

// preview-server.js
const express = require('express');
const { getMetaForPath } = require('./meta-registry');

const app = express();

app.get('*', (req, res) => {
    const path = req.headers['x-real-uri'] || req.path;
    const meta = getMetaForPath(path);

    const html = `<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />

    <!-- Balises meta principales -->
    <title>${meta.title}</title>
    <meta name="title" content="${meta.title}" />
    <meta name="description" content="${meta.description}" />

    <!-- Open Graph / Facebook -->
    <meta property="og:type" content="website" />
    <meta property="og:url" content="https://myapp.com${path}" />
    <meta property="og:title" content="${meta.title}" />
    <meta property="og:description" content="${meta.description}" />
    <meta property="og:image" content="${meta.image}" />

    <!-- Twitter -->
    <meta property="twitter:card" content="summary_large_image" />
    <meta property="twitter:url" content="https://myapp.com${path}" />
    <meta property="twitter:title" content="${meta.title}" />
    <meta property="twitter:description" content="${meta.description}" />
    <meta property="twitter:image" content="${meta.image}" />
</head>
<body>
    <p>${meta.description}</p>
</body>
</html>`;

    res.setHeader('Content-Type', 'text/html');
    res.send(html);
});

app.listen(3001, () => console.log('Serveur d\'aperçu sur :3001'));

Option 2 : Apache .htaccess

# .htaccess
RewriteEngine On

# Détecter les crawlers
RewriteCond %{HTTP_USER_AGENT} (LinkedInBot|Discordbot|Twitterbot|Slackbot|Googlebot|facebookexternalhit|WhatsApp) [NC]

# Ne pas réécrire si on va déjà vers preview
RewriteCond %{REQUEST_URI} !^/preview/

# Réécrire vers l'endpoint preview
RewriteRule ^(.*)$ /preview/$1 [L]

# Repli normal de la SPA
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]

Option 3 : middleware Express (aucun changement nginx/Apache)

Si vous faites déjà tourner un serveur Node :

// server.js
const express = require('express');
const path = require('path');
const { getMetaForPath } = require('./meta-registry');

const CRAWLER_AGENTS = [
    'LinkedInBot',
    'Discordbot',
    'Twitterbot',
    'Slackbot',
    'Googlebot',
    'facebookexternalhit',
    'WhatsApp',
];

function isCrawler(userAgent) {
    return CRAWLER_AGENTS.some(agent =>
        userAgent.toLowerCase().includes(agent.toLowerCase())
    );
}

const app = express();

// Middleware de détection des crawlers, doit venir avant le service statique
app.use((req, res, next) => {
    if (isCrawler(req.get('User-Agent') || '')) {
        const meta = getMetaForPath(req.path);

        return res.send(`<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <title>${meta.title}</title>
    <meta name="description" content="${meta.description}" />
    <meta property="og:type" content="website" />
    <meta property="og:url" content="https://myapp.com${req.path}" />
    <meta property="og:title" content="${meta.title}" />
    <meta property="og:description" content="${meta.description}" />
    <meta property="og:image" content="${meta.image}" />
    <meta property="twitter:card" content="summary_large_image" />
    <meta property="twitter:title" content="${meta.title}" />
    <meta property="twitter:description" content="${meta.description}" />
    <meta property="twitter:image" content="${meta.image}" />
</head>
<body><p>${meta.description}</p></body>
</html>`);
    }
    next();
});

// Service normal de la SPA
app.use(express.static(path.join(__dirname, 'build')));
app.get('*', (req, res) => {
    res.sendFile(path.join(__dirname, 'build', 'index.html'));
});

app.listen(3000);

Le registre de meta

La pièce manquante : associer les routes aux données meta.

// meta-registry.js
const metaMap = {
    '/': {
        title: 'MyApp Home',
        description: 'The best app for doing things.',
        image: 'https://myapp.com/og/home.png',
    },
    '/blog/:slug': {
        title: ({ slug }) => `${slug} | MyApp Blog`,
        description: ({ slug }) => `Read about ${slug}.`,
        image: 'https://myapp.com/og/blog.png',
    },
    '/product/:id': {
        title: ({ id }) => `Product ${id} | MyApp`,
        description: ({ id }) => `Check out product ${id}.`,
        image: ({ id }) => `https://myapp.com/og/products/${id}.png`,
    },
};

function getMetaForPath(path) {
    // Correspondance simple, utilisez un vrai routeur en production
    for (const [pattern, meta] of Object.entries(metaMap)) {
        if (matchPath(pattern, path)) {
            const params = extractParams(pattern, path);
            return {
                title: typeof meta.title === 'function' ? meta.title(params) : meta.title,
                description: typeof meta.description === 'function' ? meta.description(params) : meta.description,
                image: typeof meta.image === 'function' ? meta.image(params) : meta.image,
            };
        }
    }

    return {
        title: 'MyApp',
        description: 'The best app for doing things.',
        image: 'https://myapp.com/og/default.png',
    };
}

Tester vos aperçus

# Tester en tant que Discord
curl -A "Discordbot/2.0" https://myapp.com/blog/my-post | grep -E "og:|twitter:"

# Tester en tant que LinkedIn
curl -A "LinkedInBot/1.0" https://myapp.com/product/42 | grep -E "og:|twitter:"

# Tester en tant que navigateur normal (doit recevoir la SPA)
curl -A "Mozilla/5.0" https://myapp.com/blog/my-post | grep "<div id=\"root\">"

Ou utilisez les débogueurs spécifiques à chaque plateforme :

Quelle option choisir vraiment

  • Vous faites déjà tourner nginx devant votre appli ? Utilisez l’Option 1, c’est la séparation la plus propre et elle survit aux réécritures de l’appli.
  • Déployé sur Apache ou un hébergement mutualisé sans accès à nginx ? L’Option 2 est votre seul choix, et elle marche bien.
  • Un seul serveur Node, pas de reverse proxy, envie de livrer aujourd’hui ? L’Option 3 (middleware Express) est la plus rapide à ajouter, mais elle couple la logique d’aperçu à votre appli : prévoyez de l’extraire plus tard si vous changez de framework.

Quel que soit votre choix, testez avec les commandes curl ci-dessus en utilisant la vraie chaîne User-Agent de chaque crawler. Un UA générique ne déclenchera pas le même chemin de code et vous donnera un faux négatif.