DEV Community

Cover image for What I learned generating OG images for articles with Playwright and zero API cost
MORINAGA
MORINAGA

Posted on

What I learned generating OG images for articles with Playwright and zero API cost

The conclusion first: for a batch of under a few hundred static articles, generating OG images by screenshotting HTML templates with Playwright costs nothing, gives you full CSS control, and requires zero external API keys. The trade-offs are real — it's slow per image, it's not suitable for on-demand generation, and it has a hidden dependency on network availability during the build step. But for my use case, those trade-offs don't hurt.

Here's how the script works, what broke, and what I'd do differently.

Why I avoided image generation APIs

My three directory sites — aiappdex.com, findindiegame.com, ossfind.com — are fully static Astro 5 SSG builds. Articles publish automatically through a GitHub Actions pipeline. The pipeline already handles Dev.to, Hashnode, and Bluesky distribution, plus YouTube thumbnail generation with ffmpeg. I didn't want to add a billed API dependency to this stack.

The options I considered:

  • Cloudinary with remote transformations: works for on-demand, but requires a paid plan for custom fonts and the transformation URL syntax is brittle to URL-encode correctly.
  • @vercel/og (Satori-based): excellent for Next.js and Vercel serverless functions, but my sites are static pages on Cloudflare Pages — there's no Edge runtime to serve dynamic OG images from.
  • node-canvas: full control, zero cost, but native C++ binding compilation in GitHub Actions runners is a recurring pain point. It works, but it adds a non-trivial setup step to CI.
  • Pillow (Python image library): draws to a bitmap directly. Fine for simple layouts, but anything involving custom fonts, gradients, or CSS flexbox behavior is either impossible or requires dozens of manual coordinate calculations.

The Playwright approach: build an HTML string with CSS, pass it to a headless browser, screenshot it. The browser handles fonts, gradients, flexbox, and every other CSS feature I want to use. No API key. No external service. Just a 160-line Python script and Playwright installed in the runner.

How the HTML template and accent color system works

The script builds a full HTML document as a string, fills in the article title, date, and tags, and hands it to Playwright. The template has a dark card layout with an Inter typeface loaded from Google Fonts CDN.

The one non-obvious piece is the accent color selection. Each article has tags like ["webdev", "astro", "tutorial", "githubactions"]. The script matches these against five regex rules to pick an accent color:

ACCENT_RULES: list[tuple[str, str]] = [
    (r"\b(claude|anthropic|ai|llm|machinelearning)\b", "#8B5CF6"),  # purple
    (r"\b(astro|webdev|tailwindcss|react|nextjs|typescript|javascript)\b", "#0EA5E9"),  # blue
    (r"\b(godot|gamedev|csharp|game|unity)\b", "#22C55E"),  # green
    (r"\b(opensource|github|programming|tutorial)\b", "#F97316"),  # orange
    (r"\b(showdev|indiehackers|productivity)\b", "#F59E0B"),  # amber
]
DEFAULT_ACCENT = "#475569"  # slate fallback
Enter fullscreen mode Exit fullscreen mode

Rules are checked in order; the first match wins. An article tagged ["ai", "webdev"] would pick purple, because ai matches the first rule before webdev matches the second.

The accent color is inserted into the HTML at three points: the background radial gradient (at two different opacity levels: accent + "55" and accent + "33"), the brand mark block, and the tag pill borders. This gives each article a visually distinct color family without requiring any per-article design decision.

Font size also adjusts dynamically: titles over 70 characters render at 54px; shorter titles render at 64px. This is a heuristic that prevents long titles from overflowing the card boundary. It's not perfect for every title, but I haven't needed to manually override anything across 22 articles yet.

The key implementation: wait_until="networkidle"

The core Playwright call is:

with sync_playwright() as p:
    browser = p.chromium.launch()
    context = browser.new_context(viewport={"width": 1200, "height": 630})
    page = context.new_page()

    page.set_content(html, wait_until="networkidle")
    page.screenshot(
        path=str(out_path),
        full_page=False,
        clip={"x": 0, "y": 0, "width": 1200, "height": 630}
    )
Enter fullscreen mode Exit fullscreen mode

The wait_until="networkidle" argument was the critical discovery. Without it, Playwright fires the screenshot as soon as the DOM is ready — before Google Fonts has loaded and applied Inter. The result: the fallback system-ui font renders instead, which looks noticeably different and varies by the runner's OS default.

networkidle tells Playwright to wait until there are no more than 0 network connections for 500ms. In practice this means the Google Fonts CDN request completes and Inter loads before the screenshot fires. This adds roughly 300–500ms per image.

The template includes <link rel="preconnect" href="https://fonts.googleapis.com"> and the corresponding gstatic.com preconnect to minimize the latency. Without preconnect, I saw occasional timeouts where the font didn't load fast enough within the idle window.

The browser instance stays open across all articles

One implementation detail that matters for batch performance: the script opens a single browser instance and reuses the same page object across all articles, calling set_content() in a loop rather than navigating to a URL.

This is faster than opening a new browser per article because Playwright browser startup time is around 500ms. For 22 articles, that's ~11 seconds saved. For 200 articles, it would be ~100 seconds.

The clip parameter on screenshot() is necessary even though the viewport is already set to 1200x630. Without it, Playwright screenshots include a 1px bottom border artifact on some versions of Chromium. The clip forces the exact pixel region I want.

Two image formats from one pipeline

The same GitHub Actions job runs two separate scripts: generate-og.py for the standard 1200×630 OG image (used by Twitter/X, LinkedIn, Dev.to article cards), and generate-summary.py for a 1080×1350 portrait image optimized for Bluesky's visual post format.

The portrait image uses a structured layout with optional sections — card grids, pipeline diagrams, or stat blocks — depending on what summary_data YAML is present in the article frontmatter. Articles that don't have summary_data skip the portrait generation entirely and fall back to the URL card Bluesky generates natively.

This is the same pipeline that runs the post-deploy JSON-LD audit and Bluesky image upload. Adding image generation was a matter of adding two python3 scripts/... steps — no new runner setup beyond pip install playwright && playwright install chromium.

The cover_image auto-patch

One quality-of-life feature: the script writes cover_image: <url> back into the article's frontmatter automatically if it's missing.

def update_cover_image(article_path: Path, slug_base: str) -> bool:
    content = article_path.read_text(encoding="utf-8")
    meta, _ = parse_frontmatter(content)
    if meta.get("cover_image"):
        return False
    cover_url = f"{HOST}/og/articles/{slug_base}.png"
    new_content = re.sub(
        r"^(---\n)([\s\S]*?)(\n---\n)",
        lambda m: m.group(1) + m.group(2) + f"\ncover_image: {cover_url}" + m.group(3),
        content, count=1,
    )
    article_path.write_text(new_content, encoding="utf-8")
    return True
Enter fullscreen mode Exit fullscreen mode

The URL is deterministic — it's the slug plus .png on my CDN. The script generates the image first, then updates the frontmatter. Dev.to and Hashnode both read cover_image from the frontmatter when publishing, so the OG image shows up as the article cover automatically. No manual step.

Comparison: Playwright vs the alternatives

Approach API cost CSS control CI-friendly On-demand capable Font flexibility
Playwright + HTML $0 Full Yes (slow, ~2s/image) No Any web font
Cloudinary transformations $89/mo at scale Template only Yes Yes Cloudinary library
@vercel/og (Satori) $0 JSX subset Vercel only Yes Web fonts via fetch
node-canvas $0 Full Needs native build Yes System + manual
Pillow + Python $0 Pixel-level only Yes Yes PIL-loaded fonts

For static sites where every page is a flat HTML file, on-demand OG generation is irrelevant — there's no server to serve it from. Playwright is the only option on this list that gives full CSS control without either native compilation issues or a billed external service.

What I'd change

Bundle Inter locally instead of fetching from Google Fonts. The networkidle approach works, but it means a slow or blocked CDN during CI can cause font loading failures. Bunding the Inter woff2 file in the repo eliminates the network dependency entirely. I haven't done this yet because Google Fonts is convenient and the CDN has been reliable, but a CI failure at 07:00 JST because of a Google CDN blip would motivate the change immediately.

Run images in parallel with asyncio. The synchronous Playwright API processes articles sequentially. For 22 articles at ~2 seconds each, total time is around 45 seconds. For 200 articles, it would be ~7 minutes — too slow for a per-commit CI step. The async Playwright API supports asyncio.gather() for concurrent page instances. I'll need this before the article count gets much larger.

One Playwright instance per image format. Currently generate-og.py and generate-summary.py are separate scripts that each launch their own browser. A single script that generates both formats per article would halve browser launch overhead. Minor at current scale, relevant at 200+ articles.

The hard limit: not suitable for on-demand generation

If you need OG images generated per-request — for a blog where new posts are published dynamically, or for a user-facing tool — this approach doesn't work. Playwright takes 2+ seconds per image and requires a full Chromium binary. Serving that from a request path is impractical.

For on-demand generation at low volume, @vercel/og or a Cloudflare Worker with a canvas API is the right answer. For batch generation at build time in CI, where you control the timing and don't care about per-image latency, Playwright is simpler than any alternative I've found.

FAQ

Q: Why Python instead of Node.js Playwright?

Both Playwright SDKs are functionally equivalent for this use case. I chose Python because my other image-related scripts (generate-summary.py, polish.py) were already Python, and keeping them in one language simplifies the CI setup. The sync_playwright API is slightly more readable than the async Node.js version for sequential batch processing.

Q: Does wait_until="networkidle" always ensure fonts load?

Not guaranteed. networkidle fires when there are zero network connections for 500ms. If the Google Fonts CDN request hasn't started yet when the idle window begins — which can happen if Playwright is very fast at rendering the DOM — the font request comes after the screenshot. In practice, the <link rel="preconnect"> tags I added push the font request early enough that I haven't seen this failure mode. A more reliable approach is to wait for a specific CSS font to be applied using page.wait_for_function("document.fonts.ready").

Q: Can I use this for dynamically generated pages?

Yes, with caveats. You can pass a real URL instead of set_content() using page.goto(url, wait_until="networkidle"). This works well for screenshotting pages that already exist. The timing is less predictable than screenshotting a controlled HTML string because you don't control what JavaScript the page runs.

Q: Why not use Satori directly without @vercel/og?

Satori is an interesting option — it renders JSX to SVG, which you can then convert to PNG with sharp. It's faster per image than Playwright, doesn't require a browser binary, and works in any Node.js environment. The limitation is that it supports a subset of CSS: no background-image: radial-gradient(), no backdrop-filter, limited position support. For my template — which depends on radial gradients for the card background — Satori would require redesigning the layout.

Q: How does the cover image URL work if the PNG isn't published yet?

The cover_image URL points to https://aiappdex.com/og/articles/<slug>.png. The script generates the PNG first and commits it to the repository. Cloudflare Pages deploys the committed file, so by the time the article publishes to Dev.to and Hashnode, the OG image is already live at that URL. The sequence matters: image generation and commit happen before the publish step runs.

Related reading:

Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.

Top comments (0)