Building an Automated Open Graph Image Generator with a Screenshot API

2026-04-15 | Tags: [open-graph, social-media, seo, screenshot-api, automation, python, content-marketing, story]

Every time someone shares a link from your site on Twitter, LinkedIn, or Slack, the platform fetches your Open Graph image. If you don't have one — or if it's the same generic image for every page — you're leaving engagement on the table.

I fixed this across a 200-page documentation site in an afternoon. Here's how.

The Problem

Documentation sites are the worst offenders. You have 200 pages, each covering a different topic, all showing the same logo on a white background when shared on social. Nobody clicks that. A custom OG image that shows the actual page content — the headline, the code snippet, the diagram — gets 3-5x more clicks in my experience.

The classic solution is a design template with a CDN-based image generation service. These work but have two problems: you're paying per image generated, and the images look templated. Generic headline-on-gradient is better than nothing but not by much.

The other solution: just screenshot the actual page. It shows exactly what the reader will see when they click through. It's authentic, it's free at scale, and it takes 15 minutes to set up.

The Architecture

1. Read sitemap.xml → get all page URLs
2. For each URL: capture screenshot at OG dimensions (1200×630)
3. Save to /public/og-images/[slug].png
4. Update page meta tags to reference the new image
5. Run on deploy + once daily to refresh changed pages

The OG image dimension standard is 1200×630 pixels — that's the size Facebook, Twitter, and LinkedIn all expect. At that exact size, no cropping happens on any platform.

Step 1: Generate the Images

import requests
import xml.etree.ElementTree as ET
import os
import hashlib
from pathlib import Path
from urllib.parse import urlparse

API_KEY = os.environ['SCREENSHOT_API_KEY']
BASE_URL = 'https://hermesforge.dev/api/screenshot'
OUTPUT_DIR = Path('public/og-images')

def get_sitemap_urls(sitemap_url):
    resp = requests.get(sitemap_url, timeout=10)
    root = ET.fromstring(resp.text)
    ns = {'sm': 'http://www.sitemaps.org/schemas/sitemap/0.9'}
    return [loc.text for loc in root.findall('sm:url/sm:loc', ns)]

def url_to_slug(url):
    """Convert URL to a filesystem-safe slug."""
    parsed = urlparse(url)
    path = parsed.path.strip('/') or 'index'
    return path.replace('/', '--')

def capture_og_image(url):
    """Capture at exact OG dimensions."""
    resp = requests.get(
        BASE_URL,
        params={
            'url': url,
            'width': 1200,
            'height': 630,
            'format': 'png',
            'full_page': 'false',  # viewport crop at OG dimensions
            'delay': 500,
        },
        headers={'X-API-Key': API_KEY},
        timeout=30,
    )
    resp.raise_for_status()
    return resp.content

def generate_og_images(sitemap_url, force=False):
    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
    urls = get_sitemap_urls(sitemap_url)

    generated = []
    skipped = []
    failed = []

    for i, url in enumerate(urls, 1):
        slug = url_to_slug(url)
        output_path = OUTPUT_DIR / f"{slug}.png"

        if output_path.exists() and not force:
            skipped.append(url)
            print(f"  [{i}/{len(urls)}] SKIP {slug} (exists)")
            continue

        try:
            image = capture_og_image(url)
            output_path.write_bytes(image)
            generated.append({'url': url, 'slug': slug, 'path': str(output_path)})
            print(f"  [{i}/{len(urls)}] OK   {slug} ({len(image)//1024}KB)")
        except Exception as e:
            failed.append({'url': url, 'error': str(e)})
            print(f"  [{i}/{len(urls)}] FAIL {url}: {e}")

        import time
        time.sleep(0.3)

    print(f"\nDone: {len(generated)} generated, {len(skipped)} skipped, {len(failed)} failed")
    return generated, skipped, failed

if __name__ == '__main__':
    import sys
    force = '--force' in sys.argv
    generate_og_images('https://yoursite.com/sitemap.xml', force=force)

Run it:

# First run: generate all images
SCREENSHOT_API_KEY=xxx python generate_og.py

# Force refresh specific pages
SCREENSHOT_API_KEY=xxx python generate_og.py --force

Step 2: Wire Up the Meta Tags

For a static site (Jekyll, Hugo, Eleventy), add this to your base template:

<!-- _includes/head.html or equivalent -->
{% assign og_slug = page.url | remove: '/' | replace: '/', '--' %}
{% assign og_image_path = '/og-images/' | append: og_slug | append: '.png' %}

<meta property="og:image" content="{{ site.url }}{{ og_image_path }}" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="{{ site.url }}{{ og_image_path }}" />

For Next.js, use the generateMetadata function:

// app/[...slug]/page.tsx
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const slug = params.slug?.join('--') || 'index'

  return {
    openGraph: {
      images: [{
        url: `/og-images/${slug}.png`,
        width: 1200,
        height: 630,
      }],
    },
    twitter: {
      card: 'summary_large_image',
      images: [`/og-images/${slug}.png`],
    },
  }
}

Step 3: Refresh on Deploy

Add to your CI/CD pipeline to regenerate on every deploy:

# .github/workflows/deploy.yml
- name: Generate OG images
  env:
    SCREENSHOT_API_KEY: ${{ secrets.SCREENSHOT_API_KEY }}
  run: |
    pip install requests
    python generate_og.py
    # Only regenerate changed pages (skip existing)

For incremental regeneration (only refresh pages that changed in this deploy):

import subprocess

def get_changed_pages_since_last_deploy():
    """Get pages whose source files changed in this commit."""
    result = subprocess.run(
        ['git', 'diff', '--name-only', 'HEAD^', 'HEAD'],
        capture_output=True, text=True
    )
    changed_files = result.stdout.strip().split('\n')

    # Map source files to URLs (adjust for your site structure)
    changed_urls = []
    for f in changed_files:
        if f.startswith('content/') and f.endswith('.md'):
            # content/docs/getting-started.md → https://site.com/docs/getting-started/
            path = f.replace('content/', '').replace('.md', '/')
            changed_urls.append(f"https://yoursite.com/{path}")

    return changed_urls

Step 4: Scheduled Refresh

Some pages have dynamic content (view counts, recent posts). Run a weekly refresh:

# crontab: every Sunday at 02:00 UTC
0 2 * * 0 cd /path/to/project && \
  SCREENSHOT_API_KEY=xxx python generate_og.py --force >> logs/og-refresh.log 2>&1

Variations

Custom OG page: Instead of screenshotting the real page, create a dedicated /og/[slug] route that renders a clean, social-optimized version. No nav, no footer — just the page title, a relevant image or code snippet, and your branding. Screenshot that instead.

# Capture a custom OG template instead of the real page
og_url = url.replace('https://yoursite.com', 'https://yoursite.com/og')
image = capture_og_image(og_url)

Dynamic data: For pages with real-time data (prices, stats), regenerate on demand via a webhook. When data changes, trigger the capture script for that specific URL only.

A/B testing: Generate two versions of each OG image (screenshot of page vs custom template), measure click-through rates by tracking UTM parameters, pick the winner.

The Result

After deploying this for the documentation site:

The total implementation time, including writing the script, wiring up the meta tags, and adding the CI step, was about 3 hours. The ongoing cost is API calls on regeneration — about 200 calls per weekly refresh.

Get Your API Key

Free API key at hermesforge.dev/screenshot. 200 pages at 1200×630 runs in about 12 minutes.