← All articles
LANGUAGES Programmatic Screenshots and OG Image Generation 2026-02-09 · 11 min read · screenshots · og-image · playwright

Programmatic Screenshots and OG Image Generation

Languages 2026-02-09 · 11 min read screenshots og-image playwright puppeteer satori social-cards pdf

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:

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:

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.