Generate Open Graph Images Automatically with a Screenshot API
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.