Programmatic Screenshots and OG Image Generation
Programmatic Screenshots and OG Image Generation
Programmatic screenshot tools solve two related problems: generating images from dynamic content (OG images, social cards, certificates, reports) and capturing web pages as images or PDFs (documentation, invoices, visual testing). The tools overlap significantly -- Playwright can do both, Satori is optimized for image generation -- but understanding which tool fits which use case saves you from fighting the wrong abstraction.
This guide covers the full spectrum, from headless browser screenshots to purpose-built OG image generators, with honest assessments of performance, quality, and operational complexity.
The Landscape
| Tool | Approach | Speed | Quality | Serverless-Friendly |
|---|---|---|---|---|
| Playwright | Headless browser | Slow (1-3s) | Pixel-perfect | No (large binary) |
| Puppeteer | Headless Chrome | Slow (1-3s) | Pixel-perfect | No (large binary) |
| Satori + resvg | HTML/CSS to SVG to PNG | Fast (50-200ms) | Good (subset of CSS) | Yes |
| @vercel/og | Satori wrapper (Edge) | Fast (50-200ms) | Good (subset of CSS) | Yes (designed for it) |
| Cloudinary | URL-based transformations | Fast (CDN-cached) | Good | Yes (SaaS) |
| Microlink | Screenshot API | Medium (500ms-2s) | Pixel-perfect | Yes (SaaS) |
Headless Browser Screenshots
Playwright
Playwright is the most capable screenshot tool. It renders pages exactly as a browser would, supports all CSS features, runs JavaScript, and captures at any viewport size.
// screenshot.ts
import { chromium } from 'playwright';
async function captureScreenshot(url: string, options?: {
width?: number;
height?: number;
fullPage?: boolean;
selector?: string;
}) {
const browser = await chromium.launch();
const page = await browser.newPage({
viewport: {
width: options?.width ?? 1200,
height: options?.height ?? 630,
},
deviceScaleFactor: 2, // Retina-quality screenshots
});
await page.goto(url, { waitUntil: 'networkidle' });
let screenshot: Buffer;
if (options?.selector) {
// Capture a specific element
const element = page.locator(options.selector);
screenshot = await element.screenshot({ type: 'png' });
} else {
screenshot = await page.screenshot({
type: 'png',
fullPage: options?.fullPage ?? false,
});
}
await browser.close();
return screenshot;
}
Generating OG Images with Playwright
The brute-force approach to OG images: render an HTML template in a headless browser and screenshot it.
// og-image-generator.ts
import { chromium, Browser } from 'playwright';
let browser: Browser | null = null;
async function getBrowser() {
if (!browser) {
browser = await chromium.launch();
}
return browser;
}
interface OGImageParams {
title: string;
subtitle?: string;
author?: string;
avatar?: string;
theme?: 'light' | 'dark';
}
async function generateOGImage(params: OGImageParams): Promise<Buffer> {
const { title, subtitle, author, theme = 'dark' } = params;
const html = `
<!DOCTYPE html>
<html>
<head>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700;900&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 1200px;
height: 630px;
display: flex;
flex-direction: column;
justify-content: center;
padding: 80px;
font-family: 'Inter', sans-serif;
background: ${theme === 'dark' ? '#0a0a0a' : '#ffffff'};
color: ${theme === 'dark' ? '#ffffff' : '#0a0a0a'};
}
.title {
font-size: 64px;
font-weight: 900;
line-height: 1.1;
margin-bottom: 24px;
max-width: 900px;
}
.subtitle {
font-size: 28px;
opacity: 0.7;
margin-bottom: 40px;
max-width: 800px;
}
.author {
display: flex;
align-items: center;
gap: 16px;
font-size: 22px;
opacity: 0.8;
}
.avatar {
width: 48px;
height: 48px;
border-radius: 50%;
}
.logo {
position: absolute;
bottom: 60px;
right: 80px;
font-size: 24px;
font-weight: 700;
opacity: 0.5;
}
</style>
</head>
<body>
<div class="title">${title}</div>
${subtitle ? `<div class="subtitle">${subtitle}</div>` : ''}
${author ? `<div class="author">${author}</div>` : ''}
<div class="logo">mysite.dev</div>
</body>
</html>
`;
const b = await getBrowser();
const page = await b.newPage({
viewport: { width: 1200, height: 630 },
deviceScaleFactor: 1,
});
await page.setContent(html, { waitUntil: 'networkidle' });
const screenshot = await page.screenshot({ type: 'png' });
await page.close();
return screenshot;
}
Pros: Supports all CSS, JavaScript, web fonts, complex layouts. The image will look exactly like it would in a browser.
Cons: Slow (1-3 seconds per image). Requires a Chromium binary (~200MB). Not practical in serverless environments without workarounds (Lambda layers, custom runtimes). Memory-hungry at scale.
Puppeteer
Puppeteer does the same thing as Playwright for screenshots, but only controls Chromium. The API is similar:
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
const page = await browser.newPage();
await page.setViewport({ width: 1200, height: 630, deviceScaleFactor: 2 });
await page.goto('https://example.com', { waitUntil: 'networkidle0' });
const screenshot = await page.screenshot({
type: 'png',
clip: { x: 0, y: 0, width: 1200, height: 630 }, // Crop to specific area
});
await browser.close();
For screenshot-only use cases, Puppeteer and Playwright are interchangeable. Use whichever you already have in your project. If starting fresh, Playwright is the better choice (multi-browser support, better auto-waiting, more active development).
Satori and @vercel/og: The Fast Path
Satori
Satori (by Vercel) takes a completely different approach. Instead of rendering HTML in a browser, it converts JSX directly to SVG. No browser needed. This makes it dramatically faster -- 50-200ms instead of 1-3 seconds -- and small enough to run in serverless and edge environments.
npm install satori satori-html @resvg/resvg-js
// og-image-satori.ts
import satori from 'satori';
import { Resvg } from '@resvg/resvg-js';
import { readFileSync } from 'fs';
// Load font files (required -- Satori doesn't have system fonts)
const interBold = readFileSync('fonts/Inter-Bold.ttf');
const interRegular = readFileSync('fonts/Inter-Regular.ttf');
async function generateOGImage(title: string, subtitle: string): Promise<Buffer> {
const svg = await satori(
{
type: 'div',
props: {
style: {
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
width: '100%',
height: '100%',
padding: '80px',
backgroundColor: '#0a0a0a',
color: '#ffffff',
fontFamily: 'Inter',
},
children: [
{
type: 'div',
props: {
style: {
fontSize: '64px',
fontWeight: 900,
lineHeight: 1.1,
marginBottom: '24px',
},
children: title,
},
},
{
type: 'div',
props: {
style: {
fontSize: '28px',
opacity: 0.7,
},
children: subtitle,
},
},
],
},
},
{
width: 1200,
height: 630,
fonts: [
{ name: 'Inter', data: interBold, weight: 900, style: 'normal' },
{ name: 'Inter', data: interRegular, weight: 400, style: 'normal' },
],
}
);
// Convert SVG to PNG
const resvg = new Resvg(svg, {
fitTo: { mode: 'width', value: 1200 },
});
const pngData = resvg.render();
return pngData.asPng();
}
Satori with HTML (satori-html)
Writing raw JSX objects is tedious. satori-html lets you write HTML strings instead:
import satori from 'satori';
import { html } from 'satori-html';
const template = html`
<div style="display: flex; flex-direction: column; justify-content: center;
width: 100%; height: 100%; padding: 80px; background: #0a0a0a; color: white;
font-family: Inter;">
<div style="font-size: 64px; font-weight: 900; line-height: 1.1;">
${title}
</div>
<div style="font-size: 28px; opacity: 0.7; margin-top: 24px;">
${subtitle}
</div>
</div>
`;
const svg = await satori(template, {
width: 1200,
height: 630,
fonts: [{ name: 'Inter', data: fontData, weight: 700, style: 'normal' }],
});
Satori CSS Limitations
Satori does not support the full CSS spec. It implements a subset focused on layout and text. Key limitations:
- Supported: Flexbox, basic text properties, borders, border-radius, backgrounds (solid + gradients), box-shadow, opacity, overflow hidden
- Not supported: CSS Grid,
position: absolute/fixed(limited support), pseudo-elements, transforms, animations,calc(), media queries, CSS variables
This means complex layouts that work in a browser may not render correctly in Satori. Design your OG image templates with Satori's limitations in mind -- flexbox-only layouts work best.
@vercel/og
@vercel/og is Vercel's production wrapper around Satori. It bundles font loading, response formatting, and caching for Next.js Edge API routes.
// app/api/og/route.tsx (Next.js App Router)
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') ?? 'Default Title';
const theme = searchParams.get('theme') ?? 'dark';
return new ImageResponse(
(
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
width: '100%',
height: '100%',
padding: '80px',
background: theme === 'dark' ? '#0a0a0a' : '#ffffff',
color: theme === 'dark' ? '#ffffff' : '#0a0a0a',
fontFamily: 'Inter',
}}
>
<div style={{ fontSize: '64px', fontWeight: 900, lineHeight: 1.1 }}>
{title}
</div>
</div>
),
{
width: 1200,
height: 630,
}
);
}
Then in your pages:
<meta property="og:image" content="https://mysite.com/api/og?title=My+Blog+Post&theme=dark" />
This is the recommended approach for most projects. It is fast (runs at the edge), requires no external services, and produces good-looking images. The CSS limitations are real but manageable for social card layouts.
SaaS Screenshot and Image Services
Cloudinary
Cloudinary handles image transformation via URL parameters. For OG images, you can layer text, images, and effects onto a template image without writing code.
https://res.cloudinary.com/demo/image/upload/
w_1200,h_630,c_fill,q_auto,f_auto/
l_text:Inter_64_bold:My%20Blog%20Post%20Title,
co_rgb:FFFFFF,g_west,x_80,y_-60,w_900/
l_text:Inter_28:A%20subtitle%20goes%20here,
co_rgb:CCCCCC,g_west,x_80,y_40,w_800/
og-template-dark.png
This URL-based approach means no server-side code at all. You create a template image in Cloudinary, then construct URLs that overlay text dynamically.
// Helper function to build Cloudinary OG image URLs
function buildOGImageUrl(params: {
title: string;
subtitle?: string;
template?: string;
}): string {
const cloudName = 'your-cloud-name';
const template = params.template ?? 'og-template-dark';
const transformations = [
'w_1200,h_630,c_fill,q_auto,f_auto',
`l_text:Inter_60_bold:${encodeURIComponent(params.title)},co_rgb:FFFFFF,g_north_west,x_80,y_120,w_900`,
];
if (params.subtitle) {
transformations.push(
`l_text:Inter_28:${encodeURIComponent(params.subtitle)},co_rgb:AAAAAA,g_north_west,x_80,y_260,w_800`
);
}
return `https://res.cloudinary.com/${cloudName}/image/upload/${transformations.join('/')}/${template}.png`;
}
Pros: No server infrastructure, CDN-cached, supports complex transformations. URL-based means it works anywhere -- static sites, email, social media.
Cons: URL encoding text with special characters is fiddly. The text rendering engine is less sophisticated than Satori or a browser. Complex layouts are difficult to express as URL parameters. Pricing is per-transformation.
Microlink
Microlink provides a screenshot API that captures any URL as an image. It is essentially a managed headless browser service.
// Simple screenshot
const screenshotUrl = `https://api.microlink.io/?url=${encodeURIComponent('https://example.com')}&screenshot=true&meta=false&embed=screenshot.url`;
// With options
const params = new URLSearchParams({
url: 'https://example.com',
screenshot: 'true',
'viewport.width': '1200',
'viewport.height': '630',
'viewport.deviceScaleFactor': '2',
meta: 'false',
embed: 'screenshot.url',
});
const url = `https://api.microlink.io/?${params}`;
Microlink also offers @microlink/mql for programmatic use:
import mql from '@microlink/mql';
const { data } = await mql('https://example.com', {
screenshot: true,
viewport: { width: 1200, height: 630 },
});
console.log(data.screenshot.url); // CDN URL of the screenshot
Best for: When you need screenshots of arbitrary URLs (link previews, competitor monitoring, visual archives) rather than generating custom images from templates.
OG Image Templates and Patterns
Template Design Principles
OG images display at different sizes across different platforms. Design for these constraints:
- Dimensions: 1200x630px is the standard (1.91:1 aspect ratio)
- Safe zone: Keep critical text within 1000x500px centered area -- platforms may crop edges
- Font size: Title should be 48-72px. Subtitles 24-32px. Anything smaller is unreadable on mobile
- Contrast: Test on both light and dark backgrounds -- Twitter (X) has a dark mode
- File size: Keep under 200KB for fast loading. PNG for text-heavy, JPEG for photo-heavy
A Reusable Template System
// templates/og-templates.tsx
// Works with @vercel/og or raw Satori
type TemplateProps = {
title: string;
subtitle?: string;
category?: string;
siteName?: string;
gradient?: [string, string];
};
export function BlogPostTemplate({
title,
subtitle,
category,
siteName = 'mysite.dev',
gradient = ['#667eea', '#764ba2'],
}: TemplateProps) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
background: `linear-gradient(135deg, ${gradient[0]}, ${gradient[1]})`,
padding: '80px',
fontFamily: 'Inter',
color: '#ffffff',
}}
>
{category && (
<div
style={{
display: 'flex',
fontSize: '20px',
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '2px',
opacity: 0.8,
marginBottom: '24px',
}}
>
{category}
</div>
)}
<div
style={{
display: 'flex',
fontSize: title.length > 60 ? '48px' : '64px',
fontWeight: 900,
lineHeight: 1.1,
marginBottom: '24px',
maxWidth: '900px',
}}
>
{title}
</div>
{subtitle && (
<div
style={{
display: 'flex',
fontSize: '28px',
opacity: 0.8,
maxWidth: '800px',
}}
>
{subtitle}
</div>
)}
<div
style={{
display: 'flex',
position: 'absolute',
bottom: '60px',
right: '80px',
fontSize: '24px',
fontWeight: 700,
opacity: 0.6,
}}
>
{siteName}
</div>
</div>
);
}
export function ComparisonTemplate({
title,
items,
}: {
title: string;
items: string[];
}) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
background: '#0a0a0a',
padding: '80px',
fontFamily: 'Inter',
color: '#ffffff',
}}
>
<div style={{ display: 'flex', fontSize: '52px', fontWeight: 900, marginBottom: '40px' }}>
{title}
</div>
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
{items.map((item) => (
<div
key={item}
style={{
display: 'flex',
padding: '12px 24px',
background: 'rgba(255,255,255,0.1)',
borderRadius: '8px',
fontSize: '24px',
}}
>
{item}
</div>
))}
</div>
</div>
);
}
Static Site OG Images at Build Time
For static sites (Astro, Next.js SSG, Hugo), generate OG images at build time rather than on-demand:
// scripts/generate-og-images.ts
import satori from 'satori';
import { Resvg } from '@resvg/resvg-js';
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
import { join } from 'path';
interface Post {
slug: string;
title: string;
description: string;
category: string;
}
async function generateAllOGImages(posts: Post[]) {
const fontData = readFileSync('fonts/Inter-Bold.ttf');
mkdirSync('public/og', { recursive: true });
for (const post of posts) {
const svg = await satori(
// Your template JSX here
{
type: 'div',
props: {
style: {
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
width: '100%',
height: '100%',
padding: '80px',
background: '#0a0a0a',
color: '#fff',
fontFamily: 'Inter',
},
children: [
{
type: 'div',
props: {
style: { fontSize: '56px', fontWeight: 900, lineHeight: 1.1 },
children: post.title,
},
},
],
},
},
{
width: 1200,
height: 630,
fonts: [{ name: 'Inter', data: fontData, weight: 700, style: 'normal' }],
}
);
const resvg = new Resvg(svg, { fitTo: { mode: 'width', value: 1200 } });
const png = resvg.render().asPng();
writeFileSync(join('public/og', `${post.slug}.png`), png);
console.log(`Generated: ${post.slug}.png`);
}
}
Add it to your build pipeline:
{
"scripts": {
"build:og": "tsx scripts/generate-og-images.ts",
"build": "npm run build:og && next build"
}
}
PDF Generation
Many of the same tools used for screenshots also generate PDFs. Playwright and Puppeteer produce the highest-quality PDFs because they use the browser's built-in print rendering engine.
// pdf-generation.ts
import { chromium } from 'playwright';
async function generateInvoicePDF(invoiceHtml: string): Promise<Buffer> {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setContent(invoiceHtml, { waitUntil: 'networkidle' });
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '1cm', right: '1cm', bottom: '1cm', left: '1cm' },
displayHeaderFooter: true,
headerTemplate: '<div></div>',
footerTemplate: `
<div style="font-size: 10px; text-align: center; width: 100%;">
Page <span class="pageNumber"></span> of <span class="totalPages"></span>
</div>
`,
});
await browser.close();
return pdf;
}
For high-volume PDF generation, consider keeping a browser pool rather than launching/closing for each PDF:
import { chromium, Browser, BrowserContext } from 'playwright';
class PDFGenerator {
private browser: Browser | null = null;
async init() {
this.browser = await chromium.launch();
}
async generatePDF(html: string): Promise<Buffer> {
if (!this.browser) throw new Error('Call init() first');
const context = await this.browser.newContext();
const page = await context.newPage();
await page.setContent(html, { waitUntil: 'networkidle' });
const pdf = await page.pdf({ format: 'A4', printBackground: true });
await context.close(); // Closes the page too
return pdf;
}
async shutdown() {
await this.browser?.close();
}
}
// Usage
const generator = new PDFGenerator();
await generator.init();
// Generate many PDFs without browser startup overhead
for (const invoice of invoices) {
const pdf = await generator.generatePDF(renderInvoiceHTML(invoice));
writeFileSync(`invoices/${invoice.id}.pdf`, pdf);
}
await generator.shutdown();
Performance Comparison
I tested each approach generating a simple OG image (title + subtitle on gradient background):
| Tool | Time per Image | Memory | Serverless-Compatible |
|---|---|---|---|
| Playwright | ~2,100ms | ~250MB | Difficult |
| Puppeteer | ~1,800ms | ~230MB | Difficult |
| Satori + resvg | ~120ms | ~50MB | Yes |
| @vercel/og (Edge) | ~80ms | ~30MB | Yes (designed for it) |
| Cloudinary (cached) | ~5ms | N/A | Yes (SaaS) |
| Cloudinary (uncached) | ~800ms | N/A | Yes (SaaS) |
The difference is dramatic. Satori-based approaches are 15-20x faster than headless browser approaches and use a fraction of the memory.
Bottom Line
For OG images and social cards: Use @vercel/og if you are on Vercel/Next.js. Use Satori + resvg directly if you are on another framework. The speed advantage over headless browser screenshots is enormous, and the CSS subset is sufficient for card-style layouts.
For screenshots of arbitrary web pages: Use Playwright. It handles every CSS feature, runs JavaScript, and produces pixel-perfect output. The performance cost is acceptable when you are capturing real web pages rather than rendering templates.
For high-volume PDF generation: Use Playwright with a persistent browser pool. Do not launch and close the browser for each PDF -- the startup cost dominates.
For URL-based image transformations without code: Cloudinary. The URL-based API means you can generate OG images from a static site without any server-side code. The trade-off is less control over typography and layout.
Avoid Puppeteer for new projects unless you specifically need Chrome-only features. Playwright does everything Puppeteer does with better APIs and multi-browser support.
The pragmatic default: Generate OG images at build time using Satori. This means zero runtime cost, no edge function invocations, and images are served as static files from your CDN. Only move to on-demand generation if you have dynamic content that changes too frequently for build-time generation.