
Quando scrivo uno di questi appunti e lo pubblico, nel condividerlo su LinkedIn... l'anteprima è quella generica col mio bel faccione. Niente di male, per carità, ma dopo cinque post tutti uguali nella timeline diventa invisibile.
Il problema è che creare un'immagine OG fatta bene richiede tempo: apri Adobe, cerchi l'ispirazione, allinei il testo, esporti in 1200×630, la metti nella cartella giusta con il nome corretto, ecc... Per un blog personale che aggiorno quando riesco, è un processo che mi porta a saltare il passaggio perchè richiede tempo e ispirazione.
Così ho deciso di automatizzare il tutto.
Volevo uno script che, dato lo slug di un articolo, generasse un'immagine coerente col design del sito — sfondo scuro, palette verde, testo leggibile — senza dover aprire nessun editor grafico. E che fosse abbastanza intelligente da estrarre le keyword dal contenuto per rendere ogni immagine unica.
Il requisito fondamentale: usare un template di background (bg_template.png) come base fissa, e sovrapporre dinamicamente titolo, descrizione e tag. Niente AI che "disegna" testo nell'immagine (perchè lo sappiamo tutti che viene fuori male, con allinemanti poco allineati😁), ma AI che pensa il brief creativo, e poi il rendering è deterministico.

npm run generate-images -- --blog geo-optimizationNote: geo-optimization
Lo script:
public/blog/og-{slug}.pngIl bello è che Next.js poi lo usa automaticamente come og:image — ho modificato la funzione generateMeta per cercare prima se esiste un'immagine generata, e solo in caso contrario usare il fallback di default (con il mio faccione).
Il passo 5 è dove succede tutta la magia: sharp carica il template PNG, ridimensiona, e compone l'SVG sopra come layer trasparente.
await sharp(templatePath)
.resize(config.format.width, config.format.height, { fit: "cover" })
.composite([
{
input: renderOverlay({ brief, frontmatter, options, variant }),
left: 0,
top: 0,
},
])
.png({ compressionLevel: 9, quality: 90 })
.toFile(outputPath);renderOverlay restituisce un Buffer SVG che sharp tratta come qualsiasi altra immagine da sovrapporre. Non serve nessun renderer esterno.
Se passo --api gpt o --api gemini, lo script manda titolo e contenuto all'API e chiede un JSON strutturato con keyword ottimizzate, un sottotitolo accattivante e una "metafora visiva" — una frase che descrive il mood dell'immagine.
npm run generate-images -- --blog geo-optimization --api gpt --tone techLa cosa importante è che funziona anche senza API (--api local). E qui c'è un dettaglio utile: con local la frase principale non la "inventa" nessun modello — il sottotitolo viene preso dal description nel frontmatter del .mdx.
In pratica, i tre provider fanno questo:
Questa parte è guidata da config/image-generation.json: se non passi --api, usa providers.default (nel mio caso local); per gpt e gemini legge anche i model name (gpt-4o-mini, gemini-2.5-flash), quindi cambiare modello è solo una modifica di configurazione.
Sulla base del modello scelto, l'output potrebbe differire per poco. Infatti lo stesso file di configurazione gestisce anche il rendering finale: templatePath, format (1200x630), outputDir, palette colori, tipografia, tone disponibili e naming file (generatedImage.prefix/extension). Quindi puoi cambiare look e output senza toccare la logica core dello script.
{
"templatePath": "public/bg_template.png",
"format": {
"width": 1200,
"height": 630
},
"outputDir": {
"blog": "public/blog",
"projects": "public/projects"
},
"defaultTone": "minimal",
"tones": ["minimal", "tech", "business"],
"palette": {
"background": "#0c0a09",
"foreground": "#dcfce7",
"muted": "#bbf7d0",
"accent": "#22c55e",
"accentSoft": "#14532d",
"surface": "#052e16"
},
"typography": {
"fontFamily": "Arial, Helvetica, sans-serif",
"titleSize": 74,
"subtitleSize": 28,
"metaSize": 24
},
"defaults": {
"brand": "Marco De Carlo",
"role": "Web Developer",
"badgeText": {
"blog": "Blog",
"projects": "Projects"
}
},
"providers": {
"default": "local",
"gpt": {
"model": "gpt-4o-mini"
},
"gemini": {
"model": "gemini-2.5-flash"
}
},
"generatedImage": {
"prefix": "og-",
"extension": "png"
}
}Se il risultato è quasi simile, è normale perchè il rendering grafico è sempre lo stesso (template + SVG + sharp), e i modelli spesso partono dagli stessi input (title, description, contenuto), quindi convergono.
Ho deciso di configurare tutto su GitHub Actions con un workflow semplice:
node --env-file=.env non fallisce in CI--forceEsiste anche workflow_dispatch, quindi posso rilanciarlo a mano passando input del tipo blog/slug o projects/slug, scegliendo provider, tone e variante dall'interfaccia di GitHub.


In conclusione ho integrato il check del filesystem (existsSync) nel sistema di metadata di Next.js. Il primo tentativo l'avevo messo in lib/utils.ts, che però viene importato anche da componenti client — e Turbopack giustamente si è lamentato che fs non esiste nel browser.
La soluzione è stata isolare tutta la logica OG in un modulo separato (lib/metadata.ts) importato solo dalle pagine server-side. Un refactor piccolo ma necessario.