min read

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:

  1. Renders OG images from HTML templates using Playwright
  2. Uploads them to Cloudflare R2 with immutable caching
  3. 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:

  1. At build time, read all markdown files and parse frontmatter
  2. Render each OG image using Playwright
  3. Output to static/og/ directory
  4. CI syncs to R2 with immutable cache headers
Content Change → CI Build → Playwright Renders → AWS CLI Sync to R2 → Deploy

The 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 }}:latest

Key points:

  • AWS CLI works with R2’s S3-compatible API
  • --cache-control sets immutable caching (1 year)
  • --delete removes 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

J
Written by

Jack O'Shea

DevOps Engineer and Software Developer building reliable systems with clean code.