Automating OG Image Generation with Playwright
How I built a CI pipeline that generates Open Graph images for every blog post and project, caches them on Cloudflare R2, and keeps page loads fast.
Automating OG Image Generation with Playwright
Open Graph images are the preview cards that appear when you share a link on Twitter, LinkedIn, or Slack. They matter for click-through rates, but generating them manually is tedious.
For this portfolio, I built a pipeline that:
- Renders OG images from HTML templates using Playwright
- Uploads them to Cloudflare R2 with immutable caching
- Runs automatically in CI on every deploy
The Problem with Dynamic OG Images
Many sites generate OG images on-demand using services like Vercel’s @vercel/og or Cloudinary. This works, but has downsides:
- Runtime cost - Every unique URL triggers image generation
- Cold starts - First request is slow
- Service dependency - External service outages affect your site
- Cost - High-traffic pages can get expensive
For a static site with known content, pre-generating images makes more sense.
The Solution: Build-Time Generation
My approach:
- At build time, read all markdown files and parse frontmatter
- Render each OG image using Playwright
- Output to
static/og/directory - CI syncs to R2 with immutable cache headers
Content Change → CI Build → Playwright Renders → AWS CLI Sync to R2 → DeployThe Generator Script
The script reads markdown files with gray-matter, builds HTML templates, and screenshots them with Playwright:
// scripts/generate-og.mjs
import fs from 'node:fs/promises';
import path from 'node:path';
import matter from 'gray-matter';
import { chromium } from '@playwright/test';
const CONTENT_DIR = path.join(process.cwd(), 'src', 'content');
const OUTPUT_DIR = path.join(process.cwd(), 'static', 'og');
const readMarkdownEntries = async (dirPath) => {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
const files = entries
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
.map((entry) => entry.name);
const results = [];
for (const filename of files) {
const filepath = path.join(dirPath, filename);
const raw = await fs.readFile(filepath, 'utf-8');
const parsed = matter(raw);
results.push({
filename,
metadata: parsed.data ?? {},
content: parsed.content ?? ''
});
}
return results;
};Reading time is calculated from the raw markdown content:
const computeReadingTime = (content) => {
const stripped = content
.replace(/```[\s\S]*?```/g, ' ') // Remove code blocks
.replace(/<[^>]*>/g, ' ') // Remove HTML
.replace(/[^\w\s]/g, ' '); // Remove punctuation
const words = stripped.trim().split(/\s+/).filter(Boolean);
return Math.max(1, Math.ceil(words.length / 200));
};The HTML Template
The template is built inline with all styling embedded. This ensures fonts load correctly and the design is self-contained:
const buildOgHtml = ({ type, title, subtitle, description, tags }) => {
const tagMarkup = tags
.slice(0, 4)
.map((tag) => `<span class="og-tag">${escapeHtml(tag)}</span>`)
.join('');
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600&family=DM+Sans:wght@400;600&family=JetBrains+Mono:wght@500&display=swap" />
<style>
body {
margin: 0;
width: 1200px;
height: 630px;
font-family: 'DM Sans', sans-serif;
}
.og-card {
width: 1200px;
height: 630px;
background: linear-gradient(135deg, #0d1117 0%, #161b22 50%, #0d1117 100%);
color: #e6edf3;
}
.og-title {
font-family: 'Cormorant Garamond', Georgia, serif;
font-size: 72px;
font-weight: 500;
line-height: 1.1;
}
.og-tag {
padding: 8px 14px;
background: rgba(230, 237, 243, 0.05);
border: 1px solid rgba(230, 237, 243, 0.1);
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
}
/* ... more styles ... */
</style>
</head>
<body>
<div id="og-card" class="og-card">
<h1 class="og-title">${escapeHtml(title)}</h1>
<p class="og-description">${escapeHtml(description)}</p>
<div class="og-tags">${tagMarkup}</div>
</div>
</body>
</html>`;
};Rendering with Playwright
The generator creates a browser context at 2x scale for crisp images, waits for fonts to load, then screenshots:
const generateOgImages = async () => {
await fs.mkdir(path.join(OUTPUT_DIR, 'blog'), { recursive: true });
await fs.mkdir(path.join(OUTPUT_DIR, 'projects'), { recursive: true });
const [blogs, projects] = await Promise.all([
readMarkdownEntries(path.join(CONTENT_DIR, 'blog')),
readMarkdownEntries(path.join(CONTENT_DIR, 'projects'))
]);
const browser = await chromium.launch();
const context = await browser.newContext({
viewport: { width: 1200, height: 630 },
deviceScaleFactor: 2 // 2x for retina displays
});
const page = await context.newPage();
const renderCard = async ({ html, outputPath }) => {
await page.setContent(html, { waitUntil: 'networkidle' });
// Wait for web fonts to load
await page.evaluate(async () => {
if (document.fonts?.ready) {
await document.fonts.ready;
}
});
await page.locator('#og-card').screenshot({ path: outputPath, type: 'png' });
};
for (const entry of blogs) {
const { metadata, content } = entry;
if (metadata.published === false) continue;
const html = buildOgHtml({
type: 'blog',
title: metadata.title,
subtitle: `${computeReadingTime(content)} min read`,
description: metadata.description ?? '',
tags: metadata.tags ?? []
});
await renderCard({
html,
outputPath: path.join(OUTPUT_DIR, 'blog', `${metadata.slug}.png`)
});
}
await browser.close();
};GitHub Actions Workflow
The CI workflow generates images locally, then syncs them to R2 using the AWS CLI (R2 is S3-compatible):
# .github/workflows/ci-release.yml
name: Build, Upload OGs, and Publish Image
on:
push:
branches: [main]
jobs:
build-upload-publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
- run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Generate OG images
run: npm run og:generate
- name: Install AWS CLI
if: ${{ env.R2_BUCKET != '' }}
run: python -m pip install --upgrade pip awscli
- name: Upload OG images to R2
if: ${{ env.R2_BUCKET != '' }}
env:
R2_BUCKET: ${{ secrets.R2_BUCKET }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
AWS_EC2_METADATA_DISABLED: true
run: |
aws s3 sync static/og "s3://$R2_BUCKET/og" \
--endpoint-url "$R2_ENDPOINT" \
--cache-control "public,max-age=31536000,immutable" \
--delete
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:latestKey points:
- AWS CLI works with R2’s S3-compatible API
--cache-controlsets immutable caching (1 year)--deleteremoves old images no longer in source- Images are generated to
static/og/before upload
Referencing OG Images
The OG base URL switches between local static files and R2 based on environment:
// src/lib/utils/og.ts
import { PUBLIC_OG_BASE_URL, PUBLIC_OG_MODE, PUBLIC_SITE_URL } from '$env/static/public';
const SITE_URL = PUBLIC_SITE_URL || 'https://jackoshea.dev';
export const SITE_URL_RESOLVED = SITE_URL;
export const OG_BASE_URL =
PUBLIC_OG_MODE === 's3' && PUBLIC_OG_BASE_URL
? PUBLIC_OG_BASE_URL
: `${SITE_URL}/og`;In development, images serve from static/og/. In production, they’re served from R2’s edge network.
Usage in pages:
<script lang="ts">
import SEO from '$lib/components/SEO.svelte';
import { OG_BASE_URL, SITE_URL_RESOLVED } from '$lib/utils/og';
let { data } = $props();
</script>
<SEO
title="{data.post.title} | Blog"
description={data.post.description}
type="article"
url={`${SITE_URL_RESOLVED}/blog/${data.post.slug}`}
image={`${OG_BASE_URL}/blog/${data.post.slug}.png`}
publishedTime={data.post.date}
/>Results
- Build time: ~30 seconds for 20 images
- Image size: 50-100KB per PNG (2x resolution)
- Cache headers: 1 year immutable
- No runtime cost: Images served from R2’s edge
When to Use This Approach
Good for:
- Static sites with known content
- Sites where you control all content
- Avoiding runtime image generation costs
Consider alternatives for:
- User-generated content (too many images)
- Highly dynamic content (changes too frequently)
- Sites already on Vercel (use
@vercel/og)
Conclusion
Pre-generating OG images moves the work from runtime to build time. For a portfolio site, this means fast page loads and zero runtime costs.
The Playwright + R2 combo is straightforward: generate locally, sync with AWS CLI, serve from the edge. No SDKs or complex infrastructure needed.
Related
Jack O'Shea
DevOps Engineer and Software Developer building reliable systems with clean code.