What I learned generating OG images for articles with Playwright and zero API cost
Batch-Rendering Open Graph Assets with Headless Chromium: A Zero-Cost CI Strategy
Current Situation Analysis
Static site generators have fundamentally changed how developers publish content, but they introduce a persistent gap in social sharing infrastructure: Open Graph (OG) image generation. When a platform lacks a runtime server, there is no request handler to dynamically compose preview cards on the fly. Developers are forced to choose between paying for cloud transformation APIs, wrestling with native canvas bindings, or accepting generic fallbacks that hurt click-through rates.
The core misunderstanding lies in conflating on-demand generation with build-time batch processing. Many teams assume OG images must be rendered per-request, which pushes them toward serverless functions or paid SaaS platforms. For fully static architectures deployed to edge networks like Cloudflare Pages or GitHub Pages, this is unnecessary overhead. The actual requirement is a deterministic, zero-cost pipeline that runs during the CI/CD build step, processes hundreds of articles, and outputs pixel-perfect social assets.
The trade-offs are well-documented but often misweighted. Cloudinary's remote transformations require paid tiers for custom font support and rely on brittle URL-encoding syntax. @vercel/og (Satori) delivers excellent JSX-to-image rendering but strictly requires a Vercel Edge runtime, making it incompatible with static deployments. Native canvas libraries like node-canvas offer zero cost and full control, but their C++ bindings frequently break across CI runner environments, introducing non-deterministic build failures. Python's Pillow operates at the pixel level, which makes CSS-like layouts (flexbox, gradients, responsive typography) manually tedious and error-prone.
Headless Chromium via Playwright bridges this gap. By treating the OG image as a standard web page, developers gain access to the full CSS rendering engine, native font loading, and hardware-accelerated compositing. The cost is latency: approximately 1.5 to 2.5 seconds per image. For batch workflows running in CI, this latency is negligible. For on-demand request paths, it is prohibitive. Understanding this boundary is what separates production-ready implementations from fragile prototypes.
WOW Moment: Key Findings
The decisive factor in choosing an OG generation strategy is not raw speed, but layout fidelity versus infrastructure complexity. When evaluated across static deployment constraints, headless browser rendering emerges as the only approach that delivers full CSS control without external billing or native compilation friction.
| Approach | API Cost | Layout Fidelity | CI Stability | On-Demand Viability | Font Handling |
|---|---|---|---|---|---|
| Playwright + HTML | $0 | Full CSS/HTML | High (slow) | No | Any web font |
| Cloudinary Transformations | ~$89/mo (custom fonts) | Template-only | High | Yes | Platform library |
| @vercel/og (Satori) | $0 | JSX subset | High (Vercel only) | Yes | Web fonts via fetch |
| node-canvas | $0 | Full (manual) | Low (native builds) | Yes | System + manual |
| Pillow (Python) | $0 | Pixel-level only | High | Yes | PIL-loaded fonts |
This comparison reveals a critical architectural truth: static sites do not need on-demand generation. They need deterministic, build-time batch processing. Playwright's ~2s/image latency becomes irrelevant when executed in parallel or sequentially during a CI pipeline that already spends minutes compiling assets. The trade-off shifts from "how fast can I render?" to "how reliably can I reproduce exact typography and spacing across every article?" Headless Chromium answers this by leveraging the same rendering pipeline that powers modern browsers, eliminating coordinate math, font fallback guesswork, and platform-specific canvas quirks.
Core Solution
The implementation revolves around three phases: template injection, headless rendering, and asset patching. Instead of treating OG images as static graphics, we treat them as ephemeral web documents that are compiled, captured, and discarded.
Phase 1: Template Architecture & Theme Engine
The foundation is a self-contained HTML string with embedded CSS. Rather than hardcoding colors, we implement a priority-based theme engine that maps article metadata to visual accents. This keeps the template generic while allowing per-article differentiation.
interface ThemeRule {
pattern: RegExp;
accent: string;
priority: number;
}
const THEME_RULES: ThemeRule[] = [
{ pattern: /\b(ai|llm|machinelearning|claude)\b/i, accent: '#8B5CF6', priority: 10 },
{ pattern: /\b(astro|webdev|tailwind|react|typescript)\b/i, accent: '#0EA5E9', priority: 8 },
{ pattern: /\b(gamedev|unity|godot|csharp)\b/i, accent: '#22C55E', priority: 6 },
{ pattern: /\b(opensource|tutorial|programming)\b/i, accent: '#F97316', priority: 4 },
{ pattern: /\b(showdev|productivity|indie)\b/i, accent: '#F59E0B', priority: 2 },
];
const DEFAULT_ACCENT = '#475569';
function resolveAccent(tags: string[]): string {
const matched = THEME_RULES
.filter(rule => tags.some(tag => rule.pattern.test(tag)))
.sort((a, b) => b.priority - a.priority);
return matched.length > 0 ? matched[0].accent : DEFAULT_ACCENT;
}
The accent color is injected into three CSS variables: background gradient opacity, brand accent block, and tag pill borders. This creates visual hierarchy without requiring manual design overrides.
Phase 2: Headless Renderer Setup
We use Playwright's Chromium instance to load the HTML, wait for network activity to settle, and capture a precise viewport. Reusing a single browser context across the batch eliminates cold-start overhead.
import { chromium, Browser, BrowserContext, Page } from 'playwright-chromium';
import fs from 'fs/promises';
import path from 'path';
interface RenderConfig {
width: number;
height: number;
outputDir: string;
}
class OgRenderer {
private browser: Browser | null = null;
private context: BrowserContext | null = null;
private page: Page | null = null;
private config: RenderConfig;
constructor(config: RenderConfig) {
this.config = config;
}
async initialize(): Promise<void> {
this.browser = await chromium.launch({ headless: true });
this.context = await this.browser.newContext({
viewport: { width: this.config.width, height: this.config.height },
bypassCSP: true,
});
this.page = await this.context.newPage();
}
async render(htmlContent: string, outputPath: string): Promise<void> {
if (!this.page) throw new Error('Renderer not initialized');
await this.page.setContent(htmlContent, {
waitUntil: 'networkidle',
timeout: 15000,
});
await this.page.screenshot({
path: outputPath,
fullPage: false,
clip: { x: 0, y: 0, width: this.config.width, height: this.config.height },
type: 'png',
});
}
async teardown(): Promise<void> {
await this.browser?.close();
this.browser = null;
this.context = null;
this.page = null;
}
}
Architecture Rationale:
waitUntil: 'networkidle'is mandatory. Without it, Chromium captures the DOM before external fonts finish downloading, resulting in fallbacksystem-uitypography that varies across CI runners.clipoverrides the viewport screenshot boundary. Chromium sometimes appends a 1px compositor artifact to the bottom edge; explicit clipping guarantees pixel-perfect dimensions.- Single instance reuse saves ~500ms per article. For a batch of 200, that's 100 seconds of eliminated overhead.
Phase 3: Batch Processing & Frontmatter Patching
The pipeline iterates through article metadata, generates the HTML, renders the image, and optionally updates the source frontmatter with the asset URL. This creates a self-documenting artifact chain.
async function processArticles(
articles: Array<{ slug: string; title: string; tags: string[]; date: string; filePath: string }>,
renderer: OgRenderer,
cdnBase: string
): Promise<void> {
const outputDir = path.resolve('public/og/articles');
await fs.mkdir(outputDir, { recursive: true });
for (const article of articles) {
const accent = resolveAccent(article.tags);
const fontSize = article.title.length > 70 ? '54px' : '64px';
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap" rel="stylesheet">
<style>
:root { --accent: ${accent}; --fs-title: ${fontSize}; }
body { margin: 0; font-family: 'Inter', sans-serif; background: #0f172a; color: #f8fafc; }
.card { width: 1200px; height: 630px; padding: 60px; box-sizing: border-box;
background: radial-gradient(circle at 20% 30%, var(--accent) 0%, transparent 50%),
radial-gradient(circle at 80% 70%, var(--accent) 0%, transparent 40%), #0f172a; }
.title { font-size: var(--fs-title); font-weight: 800; line-height: 1.15; margin: 0 0 24px; }
.meta { font-size: 24px; opacity: 0.7; }
.tags { display: flex; gap: 12px; margin-top: 32px; }
.tag { padding: 8px 16px; border: 2px solid var(--accent); border-radius: 999px; font-size: 18px; }
</style>
</head>
<body>
<div class="card">
<h1 class="title">${article.title}</h1>
<div class="meta">${article.date}</div>
<div class="tags">${article.tags.map(t => `<span class="tag">${t}</span>`).join('')}</div>
</div>
</body>
</html>
`;
const outPath = path.join(outputDir, `${article.slug}.png`);
await renderer.render(html, outPath);
// Auto-patch frontmatter if missing
const raw = await fs.readFile(article.filePath, 'utf-8');
if (!raw.includes('cover_image:')) {
const patched = raw.replace(
/^---\n([\s\S]*?)\n---\n/,
(_, frontmatter) => `---\n${frontmatter}\ncover_image: ${cdnBase}/og/articles/${article.slug}.png\n---\n`
);
await fs.writeFile(article.filePath, patched);
}
}
}
This approach decouples design from data. The HTML template remains static; only the injected variables change. The frontmatter patching ensures downstream platforms (Dev.to, Hashnode, Bluesky) automatically consume the generated asset without manual intervention.
Pitfall Guide
1. Premature Screenshot Capture
Explanation: Failing to wait for external resources causes Chromium to render fallback fonts or unstyled elements. The DOM ready state does not guarantee CSS/Font completion.
Fix: Always use waitUntil: 'networkidle' or explicitly wait for font loading events. Add a 500ms buffer if targeting strict CI environments.
2. Font CDN Dependency in Isolated CI
Explanation: Relying on Google Fonts or similar CDNs introduces network latency and failure points. If the CDN is throttled or blocked by runner firewall rules, builds fail silently with fallback typography.
Fix: Download and bundle .woff2 font files directly in the repository. Serve them via a local data URI or static path during CI. This eliminates network variance and reduces render time by ~300ms.
3. Viewport vs. Clip Mismatch
Explanation: Setting viewport alone does not guarantee exact output dimensions. Chromium's compositor sometimes adds a 1px border or scales the canvas based on device pixel ratio.
Fix: Always pair viewport with an explicit clip object matching the target dimensions. Disable deviceScaleFactor if consistent pixel output is required.
4. Sequential Bottlenecks at Scale
Explanation: Processing 200+ articles sequentially at ~2s/image results in 6-7 minute CI steps. This blocks other pipeline stages and increases runner costs.
Fix: Use asyncio (Python) or Promise.all() with multiple page contexts (Node.js). Limit concurrency to 4-6 instances to avoid memory exhaustion on standard CI runners.
5. Frontmatter Injection Collisions
Explanation: Regex-based frontmatter patching can corrupt files if the YAML structure contains nested objects or unusual delimiters.
Fix: Use a dedicated YAML parser (yaml npm package or pyyaml) to safely read, modify, and write frontmatter. Validate the output before committing.
6. Memory Leaks in Long-Running Contexts
Explanation: Reusing a single browser page across hundreds of setContent() calls can accumulate DOM nodes, event listeners, or cached resources, eventually triggering OOM kills.
Fix: Create a fresh page per batch chunk (e.g., 50 articles), or call page.close() and context.newPage() periodically. Monitor CI runner memory usage and set --max-old-space-size if using Node.js.
7. Ignoring Color Contrast & Accessibility
Explanation: Dynamic accent colors can produce poor contrast against dark backgrounds, violating WCAG guidelines and reducing readability on small screens. Fix: Implement a luminance check before applying accents. If contrast ratio falls below 4.5:1, automatically shift to a lighter variant or apply a text shadow.
Production Bundle
Action Checklist
- Bundle font files locally to eliminate CDN dependency during CI
- Implement
networkidlewith a 15s timeout fallback to prevent hanging builds - Use explicit
clipdimensions to avoid Chromium compositor artifacts - Parse frontmatter with a YAML library instead of regex substitution
- Limit concurrent Playwright instances to 4-6 to prevent runner OOM
- Add luminance validation for dynamic accent colors
- Cache Chromium binary in CI to skip repeated
playwright installsteps - Generate both landscape (1200×630) and portrait (1080×1350) formats in a single pass
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Static SSG site (Astro/Hugo/Next.js SSG) | Playwright batch render | Zero cost, full CSS control, CI-friendly | $0 |
| Dynamic blog with user-generated posts | @vercel/og or Cloudflare Workers | On-demand rendering required, low latency | $0-$10/mo |
| High-volume marketing site (10k+ pages) | Cloudinary transformations | CDN caching, parallel processing, scalable | ~$89+/mo |
| Native desktop/mobile app sharing | node-canvas or Pillow | No browser runtime available, pixel control | $0 (build friction) |
| Strict air-gapped CI environment | Playwright + bundled fonts | No external network access, deterministic builds | $0 |
Configuration Template
GitHub Actions Workflow (.github/workflows/generate-og.yml)
name: Generate OG Assets
on:
push:
branches: [main]
paths: ['content/articles/**']
jobs:
build-og:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run generate:og
- name: Commit generated assets
run: |
git config user.name "ci-bot"
git config user.email "ci@localhost"
git add public/og/ content/articles/
git diff --staged --quiet || git commit -m "chore: update OG images and frontmatter"
git push
Playwright Config (playwright.config.ts)
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
use: {
headless: true,
viewport: { width: 1200, height: 630 },
bypassCSP: true,
ignoreHTTPSErrors: true,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
timeout: 30000,
expect: { timeout: 5000 },
});
Quick Start Guide
- Initialize the project: Run
npm init -y && npm i playwright-chromium yamlto install dependencies. - Create the renderer module: Copy the
OgRendererclass and theme engine logic intosrc/og-renderer.ts. - Prepare article metadata: Export a JSON array containing
slug,title,tags,date, andfilePathfor each article. - Execute the pipeline: Run
npx ts-node src/generate-batch.tsto render assets and patch frontmatter. - Integrate with CI: Add the GitHub Actions workflow above, enable Chromium caching, and commit the generated
public/og/directory to your repository.
This strategy transforms OG image generation from a platform dependency into a deterministic build artifact. By leveraging headless Chromium's native CSS engine, you eliminate font fallbacks, coordinate math, and external billing while maintaining pixel-perfect consistency across thousands of static pages. The latency trade-off is negligible in CI, and the architectural simplicity scales cleanly as your content library grows.
