Générer des images Open Graph dynamiques et bilingues avec Next.js
Retour d'expérience sur 13 itérations pour concevoir une image OG locale-aware, lisible sur LinkedIn, avec next/og et l'Edge Runtime.
Lorsque nous avons lancé le site qaryon.io, l'image Open Graph était une simple composition statique : un dégradé violet, le nom de la marque, un slogan. Rendue côté serveur sans polices personnalisées, elle apparaissait floue et générique dans les previews LinkedIn. Treize commits plus tard, nous avions une image dynamique, bilingue, parfaitement lisible en miniature, générée à la volée sur l'Edge Runtime. Voici ce que nous avons appris.
Pourquoi les images OG comptent pour un intégrateur télécom
Dans l'écosystème B2B des communications unifiées, LinkedIn est le principal canal d'acquisition. Quand un architecte solutions partage un lien vers votre site, l'image OG est la première chose que voient ses pairs. Une image floue avec du texte illisible à 300x157 pixels communique un manque de rigueur technique — exactement le contraire du message d'un cabinet de conseil.
Les plateformes sociales redimensionnent systématiquement l'image OG 1200x630 en miniatures de tailles variables. Le texte qui semble correct à pleine résolution devient souvent illisible une fois compressé. Ce problème est amplifié dans un contexte bilingue : le contenu français et anglais n'ont pas la même longueur, et un layout qui fonctionne pour "Architecte Solutions." peut déborder avec "Solution Architect.".
L'architecture initiale : next/og et ses contraintes
Next.js expose l'API next/og (basée sur Satori) pour générer des images via un composant React rendu en SVG puis converti en PNG. La convention de fichier opengraph-image.tsx dans un segment de route suffit — Next.js génère automatiquement les balises <meta property="og:image">.
Notre première version utilisait sans-serif par défaut :
// src/app/opengraph-image.tsx — Version 1 (root-level, pas de locale)
export default async function Image() {
return new ImageResponse(
<div style={{
background: "linear-gradient(135deg, #1a1033 0%, #2d1b4e 50%, #1a1033 100%)",
width: "100%", height: "100%",
display: "flex", flexDirection: "column",
alignItems: "center", justifyContent: "center",
fontFamily: "sans-serif",
}}>
<div style={{ fontSize: 48, fontWeight: 300, letterSpacing: "8px", color: "#ffffff" }}>
qaryon
</div>
<div style={{ fontSize: 36, fontWeight: 700, color: "#ffffff" }}>
Expert Télécom.
</div>
<div style={{ fontSize: 36, fontWeight: 700, color: "#a855f7" }}>
Solutions Concrètes.
</div>
</div>,
{ width: 1200, height: 630 }
)
}
Trois problèmes immédiats :
- Pas de polices custom — Satori utilise un fallback système qui ne correspond pas à la charte graphique (Raleway pour le logo, Inter pour les titres).
- Fichier au mauvais niveau — Placé dans
src/app/, l'image n'héritait pas du segment[locale]et ne pouvait donc pas adapter le texte FR/EN. - Texte trop petit — À 36px, le slogan disparaissait dans les miniatures LinkedIn.
Le chargement des polices sur l'Edge Runtime
La contrainte principale de next/og en Edge Runtime : pas d'accès au système de fichiers. Les polices doivent être fetchées depuis une URL publique. Nous avons opté pour les CDN Google Fonts, en chargeant les variantes en parallèle :
export const runtime = "edge"
const ralewayLight = fetch(
"https://fonts.gstatic.com/s/raleway/v37/1Ptxg8zYS_SKggPN4iEgvnHyvveLxVuEooCP.ttf"
).then((res) => res.arrayBuffer())
const interBold = fetch(
"https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYMZg.ttf"
).then((res) => res.arrayBuffer())
const [ralewayData, interBoldData] = await Promise.all([ralewayLight, interBold])
Attention : les URL Google Fonts pointent vers des fichiers .ttf spécifiques. Satori ne gère ni WOFF2 ni les variable fonts. Il faut identifier l'URL exacte du poids souhaité — ici Raleway 300 (Light) et Inter 700 (Bold).
Déplacer l'image dans le segment [locale]
Le premier refactoring critique a été de migrer src/app/opengraph-image.tsx vers src/app/[locale]/opengraph-image.tsx. Ce déplacement a deux effets :
- Accès au paramètre
locale— le composant reçoitparamsavec la locale active. - Balise OG émise correctement — Next.js ne génère la balise
<meta property="og:image">que si le fichier est dans le même segment (ou un parent) de la page.
type Props = {
params: Promise<{ locale: string }>
}
const copy = {
fr: { headline: ["Architecte", "Solutions."], cta: "Discutons de votre projet" },
en: { headline: ["Solution", "Architect."], cta: "Let's discuss your project" },
} as const
export default async function Image({ params }: Props) {
const { locale } = await params
const { headline, cta } = copy[locale as keyof typeof copy] ?? copy.fr
// ...
}
Note pour les projets Next.js 15+ : params est une Promise. L'oublier provoque une erreur silencieuse où locale vaut undefined et le fallback copy.fr masque le bug.
L'itération vers la lisibilité : de 52px à 108px
C'est la partie la plus instructive du projet. Voici la progression des tailles de titre à travers les commits :
| Itération | Taille titre | Éléments | Résultat en miniature | |-----------|-------------|----------|----------------------| | 1 | 52px | Logo + titre + sous-titre + domaine | Texte illisible | | 2 | 72px | Logo + titre + CTA + services | Titre OK, le reste illisible | | 3 | 88px | Logo + titre + CTA | Titre lisible, CTA limite | | 4 (finale) | 108px | Logo + titre 2 lignes + CTA | Tout lisible |
La leçon : pour les images OG, commencez par la taille de miniature et remontez. LinkedIn affiche ces images à environ 520x272 pixels dans le feed. À cette résolution, un texte sous 80px est difficilement lisible. Nous avons fini par supprimer tous les éléments secondaires (sous-titre, liste de services, lien du domaine, séparateur) pour ne garder que trois blocs : le nom de marque, le titre principal, et un CTA.
Le watermark : un détail qui compte
Un élément subtil qui apporte beaucoup de profondeur visuelle sans compromettre la lisibilité : un "q" décoratif géant en arrière-plan.
{/* Grand q décoratif en filigrane */}
<div style={{
position: "absolute",
right: -40,
bottom: -80,
fontSize: 520,
fontFamily: "Raleway",
fontWeight: 300,
color: "rgba(168, 85, 247, 0.06)",
lineHeight: 1,
display: "flex",
}}>
q
</div>
L'opacité de 6% est le fruit de plusieurs essais. À 4% (notre premier choix), le watermark était invisible après la compression JPEG de LinkedIn. À 10%, il interférait avec la lecture du titre. Le gradient radial en arrière-plan (rgba(168, 85, 247, 0.10) centré à 50% 55%) ajoute une lueur subtile qui évite le côté plat d'un fond uni #0c0a12.
Le piège du Revert : quand "mieux" est l'ennemi du "bien"
Un anti-pattern que nous avons rencontré : vouloir optimiser l'utilisation de l'espace vertical. Nous avons tenté de passer justifyContent de center à space-between pour étaler le contenu sur toute la hauteur. Le résultat en pleine résolution semblait effectivement plus aéré, mais en miniature le titre et le CTA se retrouvaient aux extrémités opposées, cassant la hiérarchie visuelle.
Le commit fdedaaf Revert "fix(seo): spread OG image content across full height" est notre rappel que les images OG doivent être testées en contexte — dans un partage LinkedIn simulé — pas en plein écran dans le navigateur.
La cohérence marque : "on" en couleur primaire
Le dernier raffinement a consisté à appliquer la couleur primaire (#a855f7) au suffixe "on" de "qaryon" dans l'image OG, cohérent avec le composant Logo du site :
<span style={{
fontFamily: "Raleway", fontWeight: 300,
fontSize: 32, letterSpacing: "6px", color: "#ffffff",
display: "flex",
}}>
qary<span style={{ color: "#a855f7" }}>on</span>
</span>
Ce détail garantit que l'image OG est immédiatement reconnaissable comme provenant du même écosystème visuel que le site. Pour un intégrateur qui partage régulièrement du contenu technique, cette cohérence renforce la crédibilité.
Checklist pour votre propre image OG bilingue
Si vous développez un site Next.js multilingue pour un contexte B2B :
- Placez
opengraph-image.tsxdanssrc/app/[locale]/pour hériter du paramètre de langue. - Chargez les polices via
fetch+Promise.all— pas de fichiers locaux en Edge Runtime. - Utilisez uniquement des fichiers
.ttf— Satori ne supporte ni WOFF2 ni les variable fonts. - Visez un titre d'au moins 80px pour rester lisible dans les miniatures LinkedIn (~520px de large).
- Limitez-vous à 3 blocs visuels : identité, message principal, call-to-action.
- Testez en miniature, pas en plein écran. Les outils comme opengraph.xyz ou le LinkedIn Post Inspector sont indispensables.
- Définissez un fallback par défaut (
?? copy.fr) pour éviter les crashes sur des locales inattendues. - N'oubliez pas
export const runtime = "edge"— sans cette ligne, le composant s'exécute en Node.js et lesfetchde polices peuvent échouer en production Vercel.
Conclusion
Treize itérations pour une image de 1200x630 pixels peuvent sembler excessives. Mais dans un contexte B2B télécom où chaque partage LinkedIn est une opportunité commerciale, la qualité de l'image OG est un signal de compétence technique. L'approche next/og avec l'Edge Runtime offre un excellent rapport effort/résultat : un seul fichier TypeScript, pas de pipeline de génération d'images externe, et un rendu dynamique qui s'adapte à la langue du visiteur.
Le code final fait 154 lignes. Chaque ligne a été gagnée par la suppression de quelque chose d'inutile.