Generate Open Graph Images Automatically with a Screenshot API

2026-04-18 | Tags: [tutorial, screenshot-api, og-images, seo, social-sharing]

Open Graph images — the preview images that appear when you share a link on Twitter, LinkedIn, or Slack — can increase click-through rates by 2-3x. Most content-heavy sites either skip them (relying on the default fallback) or spend significant engineering time on image generation infrastructure.

There's a simpler approach: use a screenshot API to capture a purpose-built preview page. This tutorial walks through the full implementation.

Why Screenshots Instead of Image Generation Libraries

Image generation libraries (Pillow, Sharp, canvas) require: - Server-side rendering or a build step - Font management (system fonts differ across environments) - Layout logic reimplemented outside your normal templating - Separate deployment for the image generation service

A screenshot API requires: - A URL to a page you already know how to build - One API call

The tradeoff: screenshot-based OG images take slightly longer to generate (1-3 seconds vs milliseconds for direct image generation). For most use cases, this is acceptable because OG images are generated once per piece of content and cached.

Step 1: Build the Preview Page

Create a dedicated page that renders your content as it should appear in the preview. This is just a normal web page — you have full control over the layout, fonts, and content.

<!-- /preview?title=...&author=...&date=... -->
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      width: 1200px;
      height: 630px;
      display: flex;
      flex-direction: column;
      justify-content: flex-end;
      padding: 80px;
      background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      color: white;
      overflow: hidden;
    }
    .site-name {
      font-size: 18px;
      font-weight: 500;
      color: rgba(255,255,255,0.6);
      margin-bottom: 24px;
      text-transform: uppercase;
      letter-spacing: 2px;
    }
    .title {
      font-size: 56px;
      font-weight: 700;
      line-height: 1.1;
      margin-bottom: 32px;
      max-width: 900px;
    }
    .meta {
      display: flex;
      align-items: center;
      gap: 24px;
      font-size: 20px;
      color: rgba(255,255,255,0.7);
    }
    .tag {
      background: rgba(255,255,255,0.15);
      padding: 6px 16px;
      border-radius: 20px;
      font-size: 16px;
    }
  </style>
</head>
<body>
  <div class="site-name">your-blog.com</div>
  <div class="title">{{ title }}</div>
  <div class="meta">
    <span>{{ author }}</span>
    <span>·</span>
    <span>{{ date }}</span>
    <span class="tag">{{ tag }}</span>
  </div>
</body>
</html>

The key constraint: 1200x630px. This is the standard OG image size. Design for exactly this viewport — no scrolling, no responsive layout needed.

Step 2: Generate the Screenshot

import requests
import hashlib
from pathlib import Path

SCREENSHOT_API_KEY = "your-api-key"
SCREENSHOT_API_URL = "https://hermesforge.dev/api/screenshot"
OG_IMAGE_DIR = Path("./static/og")
OG_IMAGE_DIR.mkdir(parents=True, exist_ok=True)

def generate_og_image(post_slug: str, title: str, author: str, date: str, tag: str) -> str:
    """Generate OG image for a post. Returns the local file path."""

    # Check cache first
    cache_key = hashlib.md5(f"{post_slug}{title}".encode()).hexdigest()[:12]
    output_path = OG_IMAGE_DIR / f"{post_slug}-{cache_key}.png"

    if output_path.exists():
        return str(output_path)

    # Build the preview URL
    import urllib.parse
    params = urllib.parse.urlencode({
        "title": title,
        "author": author,
        "date": date,
        "tag": tag,
    })
    preview_url = f"https://your-blog.com/preview?{params}"

    # Call screenshot API
    response = requests.get(
        SCREENSHOT_API_URL,
        params={
            "url": preview_url,
            "format": "png",
            "width": 1200,
            "height": 630,
            "wait": "networkidle",
            "full_page": "false",  # Fixed viewport, not full page
        },
        headers={"X-API-Key": SCREENSHOT_API_KEY},
        timeout=30,
    )

    if response.status_code == 200:
        output_path.write_bytes(response.content)
        return str(output_path)
    else:
        raise Exception(f"Screenshot failed: {response.status_code}")

Step 3: Wire It Into Your Build Process

Static site (build time):

import frontmatter

def build_og_images(posts_dir: str):
    for post_file in Path(posts_dir).glob("*.md"):
        post = frontmatter.load(post_file)
        slug = post_file.stem

        og_path = generate_og_image(
            post_slug=slug,
            title=post["title"],
            author=post.get("author", "Your Name"),
            date=post["date"].strftime("%B %d, %Y"),
            tag=post.get("tags", [""])[0],
        )
        print(f"Generated: {og_path}")

build_og_images("./content/posts")

Dynamic site (on-demand with caching):

from flask import Flask, redirect
from functools import lru_cache

app = Flask(__name__)

@app.route("/og/<slug>.png")
def og_image(slug):
    post = get_post(slug)
    if not post:
        return "Not found", 404

    try:
        image_path = generate_og_image(
            post_slug=slug,
            title=post.title,
            author=post.author,
            date=post.date.strftime("%B %d, %Y"),
            tag=post.tags[0] if post.tags else "",
        )
        return send_file(image_path, mimetype="image/png")
    except Exception as e:
        # Fall back to default OG image on failure
        return redirect("/static/og/default.png")

Step 4: Add the Meta Tags

<head>
  <!-- Open Graph -->
  <meta property="og:title" content="{{ post.title }}">
  <meta property="og:description" content="{{ post.description }}">
  <meta property="og:image" content="https://your-blog.com/og/{{ post.slug }}.png">
  <meta property="og:image:width" content="1200">
  <meta property="og:image:height" content="630">
  <meta property="og:type" content="article">

  <!-- Twitter Card -->
  <meta name="twitter:card" content="summary_large_image">
  <meta name="twitter:image" content="https://your-blog.com/og/{{ post.slug }}.png">
</head>

Rate Limit Considerations

If you're building OG images for an existing content library, you'll need to batch the generation. With a free tier API (10 calls/day), you can generate 10 images per day — enough to backfill a small blog in a week. With a Pro tier (1000 calls/day), you can generate your entire library in a single build.

Structure the batch generation to be resumable:

def generate_all_og_images(posts_dir: str):
    posts = list(Path(posts_dir).glob("*.md"))
    generated = 0
    skipped = 0

    for post_file in posts:
        post = frontmatter.load(post_file)
        slug = post_file.stem

        # Check if already generated
        existing = list(OG_IMAGE_DIR.glob(f"{slug}-*.png"))
        if existing:
            skipped += 1
            continue

        try:
            generate_og_image(slug, post["title"], ...)
            generated += 1
            print(f"[{generated}/{len(posts)}] Generated: {slug}")
        except Exception as e:
            print(f"Failed {slug}: {e}")
            # Don't fail the whole batch

    print(f"Done: {generated} generated, {skipped} skipped (already exist)")

Variations Worth Building

Dark/light mode: Detect the user's OS preference via JS in the preview page, or generate both variants and serve based on the prefers-color-scheme media query.

Author avatars: Include an <img> tag in the preview page pointing to the author's avatar. The screenshot API will fetch and render it as part of the page.

Dynamic gradients: Use the post's primary tag to select a background gradient, giving each category of content a distinct visual identity.


hermesforge.dev — screenshot API. Free tier: 10 calls/day, no signup required. Paid tiers from $4/30-day for higher limits.