Why Dynamic OG Images?
Static OG images mean every page shares the same preview on social media. Dynamic generation creates unique images per page — with the actual title, author, date, and branding. This dramatically improves click-through rates from Twitter/X, LinkedIn, Slack, and Discord link previews.
The standard size is 1200×630 pixels. Twitter also supports a 1200×600 summary card. Always include the og:image meta tag pointing to your generation endpoint or cached image URL.
Method 1: Satori (JSX to SVG)
Satori (by Vercel) converts JSX to SVG — no browser needed. It runs in any JavaScript runtime including edge functions and Cloudflare Workers. The SVG output can be converted to PNG with @resvg/resvg-js.
const satori = require('satori');
const { Resvg } = require('@resvg/resvg-js');
const fs = require('fs');
// Load font (Satori needs explicit font data)
const interFont = fs.readFileSync('./fonts/Inter-Bold.ttf');
async function generateOG(title, subtitle) {
const svg = await satori(
{
type: 'div',
props: {
style: { width: 1200, height: 630, display: 'flex', flexDirection: 'column',
justifyContent: 'center', padding: '60px', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
children: [
{ type: 'h1', props: { style: { color: '#fff', fontSize: 64, margin: 0, lineHeight: 1.2 }, children: title } },
{ type: 'p', props: { style: { color: '#e0d4f7', fontSize: 28, marginTop: 20 }, children: subtitle } }
]
}
},
{ width: 1200, height: 630, fonts: [{ name: 'Inter', data: interFont, weight: 700 }] }
);
const resvg = new Resvg(svg, { fitTo: { mode: 'width', value: 1200 } });
return resvg.render().asPng(); // Buffer
}
Pros: fast (~50ms), no browser, edge-compatible, deterministic output. Cons: limited CSS (no grid, limited flexbox), must load fonts manually, JSX syntax only (no HTML strings).
Method 2: @vercel/og
Vercel's official OG image library wraps Satori with a nicer developer experience. Designed for Next.js API routes and Vercel Edge Functions but works standalone too.
// Next.js App Router: app/api/og/route.tsx
import { ImageResponse } from '@vercel/og';
export const runtime = 'edge';
export async function GET(request) {
const { searchParams } = new URL(request.url);
const title = searchParams.get('title') || 'Default Title';
return new ImageResponse(
(
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column',
justifyContent: 'center', padding: 60, background: '#0a0a0f' }}>
<h1 style={{ color: '#fff', fontSize: 60 }}>{title}</h1>
<p style={{ color: '#818cf8', fontSize: 24 }}>snapapi.pics</p>
</div>
),
{ width: 1200, height: 630 }
);
}
// Usage: <meta property="og:image" content="/api/og?title=My+Post" />
Pros: simplest DX, auto-deployed on Vercel edge, hot-reload in dev. Cons: Vercel-centric (works elsewhere but needs adaptation), same CSS limitations as Satori.
Method 3: Playwright (Pixel-Perfect)
When you need full CSS — gradients, backdrop filters, custom fonts, complex grid layouts — render an HTML template in Playwright and screenshot it.
const { chromium } = require('playwright');
async function generateOGPlaywright(title, author, date) {
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1200, height: 630 } });
const html = `<!DOCTYPE html>
<html><head><link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<style>
* { margin:0; box-sizing:border-box; }
body { width:1200px; height:630px; font-family:'Inter',sans-serif;
background:linear-gradient(135deg,#1e1b4b,#312e81);
display:flex; flex-direction:column; justify-content:center; padding:80px; }
h1 { color:#fff; font-size:56px; line-height:1.2; margin-bottom:24px; }
.meta { color:#a5b4fc; font-size:22px; }
.logo { position:absolute; bottom:40px; right:60px; color:#6366f1; font-size:28px; font-weight:700; }
</style></head>
<body>
<h1>${title}</h1>
<div class="meta">By ${author} · ${date}</div>
<div class="logo">snapapi.pics</div>
</body></html>`;
await page.setContent(html, { waitUntil: 'networkidle' });
await page.evaluateHandle('document.fonts.ready');
const png = await page.screenshot({ type: 'png' });
await browser.close();
return png;
}
Pros: any CSS works, pixel-perfect, real web fonts. Cons: ~300-500ms per image, needs Chromium installed, not edge-compatible.
Method 4: SnapAPI (API-Based)
Skip the infrastructure. Host your OG template as a page (or use any URL), and let SnapAPI capture it as a screenshot.
// Build-time: generate OG images for all blog posts
async function generateAllOGImages(posts) {
for (const post of posts) {
const res = await fetch('https://api.snapapi.pics/v1/screenshot', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Api-Key': 'sk_live_your_key' },
body: JSON.stringify({
url: `https://mysite.com/og-template?title=${encodeURIComponent(post.title)}&author=${post.author}`,
viewport: { width: 1200, height: 630 },
fullPage: false, format: 'png',
blockAds: true, blockCookieBanners: true
})
});
const png = Buffer.from(await res.arrayBuffer());
fs.writeFileSync(`./public/og/${post.slug}.png`, png);
console.log(`Generated OG: ${post.slug}`);
}
}
# Python — batch generate with async
import httpx, asyncio
async def generate_og(client, title, slug):
resp = await client.post('https://api.snapapi.pics/v1/screenshot',
headers={'X-Api-Key': 'sk_live_your_key'},
json={'url': f'https://mysite.com/og?title={title}',
'viewport': {'width': 1200, 'height': 630},
'format': 'png', 'blockAds': True})
with open(f'og/{slug}.png', 'wb') as f:
f.write(resp.content)
SnapAPI renders with real Chromium — full CSS support, web fonts, everything. No browser to install or manage. 200 free requests/month.
Method Comparison
| Method | Speed | CSS Support | Edge-Ready | Best For |
|---|---|---|---|---|
| Satori | ~50ms | Limited flexbox | Yes | Simple text layouts |
| @vercel/og | ~50ms | Limited flexbox | Yes (Vercel) | Next.js projects |
| Playwright | 300-500ms | Full | No | Complex designs |
| SnapAPI | 1-3s (network) | Full | Yes (HTTP) | Any stack, no infra |