Why OG Images Matter
When a URL is shared on Twitter/X, LinkedIn, Slack, or iMessage, the platform fetches the og:image meta tag and displays a visual card. Without a custom OG image, your links look generic. With one, click-through rates improve 2–5×.
The standard OG image size is 1200×630px. Twitter/X prefers 1200×628px for summary cards. Both work with 1200×630.
Three Approaches
| Approach | Best for | Pros | Cons |
|---|---|---|---|
| Vercel OG (@vercel/og) | Next.js / Vercel apps | Fast, serverless, JSX templates | Vercel-only, limited CSS |
| Playwright screenshot | Self-hosted, full HTML | Any CSS/font/image | Needs Chromium binary |
| SnapAPI | Any framework, any host | Zero infra, 3 API lines | API key required |
Vercel OG: Next.js App Router
// app/og/route.tsx
import { ImageResponse } from 'next/og';
export const runtime = 'edge';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const title = searchParams.get('title') ?? 'My Blog Post';
const desc = searchParams.get('desc') ?? '';
return new ImageResponse(
(
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
width: '1200px',
height: '630px',
background: 'linear-gradient(135deg, #0a0a0f 0%, #1a1040 100%)',
padding: '60px',
fontFamily: 'Inter, sans-serif',
}}
>
<div style={{ fontSize: '64px', fontWeight: 700, color: '#fff', lineHeight: 1.1 }}>
{title}
</div>
<div style={{ fontSize: '28px', color: '#9090a8', marginTop: '20px' }}>
{desc}
</div>
<div style={{ fontSize: '22px', color: '#6c63ff', marginTop: '40px' }}>
myblog.com
</div>
</div>
),
{ width: 1200, height: 630 }
);
}
// Use in metadata
export async function generateMetadata({ params }) {
const post = await getPost(params.slug);
const ogUrl = `/og?title=${encodeURIComponent(post.title)}&desc=${encodeURIComponent(post.excerpt)}`;
return {
openGraph: {
images: [{ url: ogUrl, width: 1200, height: 630 }],
},
};
}
Playwright: HTML Template → Screenshot
For full CSS/font/image support beyond what Vercel OG's Satori handles:
import { chromium } from 'playwright';
async function generateOgImage({ title, description, author, tags = [] }) {
const html = `
<head>
<meta charset="UTF-8">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@700;400&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 1200px; height: 630px; overflow: hidden;
background: linear-gradient(135deg, #0a0a0f, #1a1040);
font-family: 'Inter', sans-serif;
display: flex; flex-direction: column; justify-content: flex-end;
padding: 60px;
}
.tags { display: flex; gap: 10px; margin-bottom: 24px; }
.tag { background: rgba(108,99,255,.2); color: #a78bfa; padding: 4px 12px; border-radius: 20px; font-size: 18px; }
h1 { font-size: 64px; font-weight: 700; color: #fff; line-height: 1.15; margin-bottom: 20px; }
.desc { font-size: 28px; color: #9090a8; }
.author { font-size: 22px; color: #6c63ff; margin-top: 32px; }
</style>
</head>
<body>
<div class="tags">${tags.map(t => `<span class="tag">${t}</span>`).join('')}</div>
<h1>${title}</h1>
<p class="desc">${description}</p>
<p class="author">${author}</p>
</body>
</html>`;
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setViewportSize({ width: 1200, height: 630 });
await page.setContent(html, { waitUntil: 'networkidle' });
const screenshot = await page.screenshot({ type: 'png' });
await browser.close();
return screenshot;
}