February 19, 2026 · 9 min read
How to Build a Dynamic OG Image Service for Your SaaS
When someone shares a link to your SaaS on Twitter, Slack, or LinkedIn, what do they see? If every page shows the same generic preview image, you're leaving engagement on the table. Here's how to generate unique, dynamic OG images for every page, automatically.
Why Dynamic OG Images Matter
Static OG images are fine for your homepage. But SaaS products have hundreds or thousands of unique pages: user profiles, dashboards, reports, blog posts, documentation pages. Each one gets shared, and each one deserves a preview that reflects its content.
Companies like Vercel, GitHub, and Stripe all generate dynamic OG images. When you share a GitHub repo link, the preview shows the repo name, description, stars, and language breakdown. That's not a manually designed image. It's generated on the fly.
The results speak for themselves: tweets with rich previews get 2-3x more engagement than those with generic or missing images. For a SaaS, that means more signups from every share.
The Architecture
A dynamic OG image service has three components:
- HTML template - A styled template that accepts parameters (title, subtitle, avatar, stats)
- Screenshot API - Renders the HTML to a PNG image
- Caching layer - Stores generated images so you don't re-render on every request
The flow: when a crawler (Twitter, Slack, etc.) hits your og:image URL, your server checks the cache. If the image exists, serve it. If not, render the HTML template with the page's data, screenshot it, cache it, and return the PNG.
Step 1: Build the HTML Template
OG images are 1200x630 pixels. Your template should be a self-contained HTML page at that exact size. Here's a versatile starting point:
<!DOCTYPE html>
<html>
<head>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 1200px; height: 630px;
display: flex; flex-direction: column;
justify-content: center; padding: 60px 80px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
font-family: 'Inter', system-ui, sans-serif;
color: white;
}
.tag { font-size: 18px; text-transform: uppercase;
letter-spacing: 2px; opacity: 0.8; margin-bottom: 20px; }
.title { font-size: 56px; font-weight: 800;
line-height: 1.15; margin-bottom: 24px; }
.subtitle { font-size: 24px; opacity: 0.9; line-height: 1.5; }
.footer { margin-top: auto; display: flex;
align-items: center; gap: 16px; }
.logo { width: 40px; height: 40px; border-radius: 8px;
background: rgba(255,255,255,0.2); }
.brand { font-size: 20px; font-weight: 600; }
</style>
</head>
<body>
<div class="tag">{{tag}}</div>
<div class="title">{{title}}</div>
<div class="subtitle">{{subtitle}}</div>
<div class="footer">
<img class="logo" src="{{logo_url}}" />
<span class="brand">{{brand}}</span>
</div>
</body>
</html>
The {{placeholder}} values get replaced server-side before screenshotting. You can use any templating engine (Handlebars, EJS, or plain string replacement).
Step 2: Screenshot the Template
You need a way to render HTML to an image. You can run headless Chrome yourself, but managing browser instances at scale is painful. A screenshot API handles the rendering, scaling, and reliability for you.
Using curl
The simplest approach: send your rendered HTML directly to the API.
# Render HTML to a 1200x630 OG image
curl "https://grabshot.dev/api/screenshot" \
-H "X-API-Key: YOUR_API_KEY" \
-d '{
"html": "<!DOCTYPE html><html>...your template...</html>",
"width": 1200,
"height": 630,
"format": "png"
}' \
--output og-image.png
Node.js Implementation
Here's a complete Express middleware that generates and caches OG images:
const express = require('express');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const app = express();
const CACHE_DIR = './og-cache';
const API_KEY = process.env.GRABSHOT_API_KEY;
// Ensure cache directory exists
fs.mkdirSync(CACHE_DIR, { recursive: true });
// OG image template
function renderTemplate({ tag, title, subtitle, brand }) {
return `<!DOCTYPE html>
<html><head><style>
* { margin:0; padding:0; box-sizing:border-box; }
body { width:1200px; height:630px; display:flex;
flex-direction:column; justify-content:center;
padding:60px 80px;
background:linear-gradient(135deg,#667eea,#764ba2);
font-family:system-ui; color:white; }
.tag { font-size:18px; text-transform:uppercase;
letter-spacing:2px; opacity:.8; margin-bottom:20px; }
.title { font-size:52px; font-weight:800; line-height:1.15;
margin-bottom:24px; }
.subtitle { font-size:22px; opacity:.9; }
.brand { margin-top:auto; font-size:20px; font-weight:600; }
</style></head><body>
<div class="tag">${tag}</div>
<div class="title">${title}</div>
<div class="subtitle">${subtitle}</div>
<div class="brand">${brand}</div>
</body></html>`;
}
app.get('/og/:slug.png', async (req, res) => {
const { slug } = req.params;
// Check cache first
const hash = crypto.createHash('md5').update(slug).digest('hex');
const cachePath = path.join(CACHE_DIR, `${hash}.png`);
if (fs.existsSync(cachePath)) {
return res.sendFile(path.resolve(cachePath));
}
// Look up page data from your database
const page = await getPageData(slug);
if (!page) return res.status(404).send('Not found');
// Render template
const html = renderTemplate({
tag: page.category,
title: page.title,
subtitle: page.description,
brand: 'YourSaaS'
});
// Screenshot via GrabShot API
const response = await fetch('https://grabshot.dev/api/screenshot', {
method: 'POST',
headers: {
'X-API-Key': API_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify({
html, width: 1200, height: 630, format: 'png'
})
});
const buffer = Buffer.from(await response.arrayBuffer());
// Cache the result
fs.writeFileSync(cachePath, buffer);
res.set('Content-Type', 'image/png');
res.set('Cache-Control', 'public, max-age=86400');
res.send(buffer);
});
Python Implementation
Same concept with Flask:
import hashlib, os, requests
from flask import Flask, send_file, abort
app = Flask(__name__)
CACHE_DIR = "./og-cache"
API_KEY = os.environ["GRABSHOT_API_KEY"]
os.makedirs(CACHE_DIR, exist_ok=True)
def render_template(title, subtitle, tag="Article"):
return f"""<!DOCTYPE html>
<html><head><style>
body {{ width:1200px; height:630px; display:flex;
flex-direction:column; justify-content:center;
padding:60px 80px;
background:linear-gradient(135deg,#667eea,#764ba2);
font-family:system-ui; color:white; margin:0; }}
.tag {{ font-size:18px; text-transform:uppercase;
letter-spacing:2px; opacity:.8; margin-bottom:20px; }}
.title {{ font-size:52px; font-weight:800; line-height:1.15;
margin-bottom:24px; }}
.sub {{ font-size:22px; opacity:.9; }}
</style></head><body>
<div class="tag">{tag}</div>
<div class="title">{title}</div>
<div class="sub">{subtitle}</div>
</body></html>"""
@app.route("/og/<slug>.png")
def og_image(slug):
# Check cache
h = hashlib.md5(slug.encode()).hexdigest()
cache_path = os.path.join(CACHE_DIR, f"{h}.png")
if os.path.exists(cache_path):
return send_file(cache_path, mimetype="image/png")
# Fetch page data from your DB
page = get_page_data(slug)
if not page:
abort(404)
html = render_template(page["title"], page["description"])
# Screenshot via GrabShot
resp = requests.post(
"https://grabshot.dev/api/screenshot",
headers={"X-API-Key": API_KEY},
json={"html": html, "width": 1200, "height": 630, "format": "png"}
)
with open(cache_path, "wb") as f:
f.write(resp.content)
return send_file(cache_path, mimetype="image/png")
Step 3: Wire Up Your Meta Tags
Point your OG meta tags to your new image endpoint. In your page's <head>:
<meta property="og:image" content="https://yourapp.com/og/{{slug}}.png">
<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="https://yourapp.com/og/{{slug}}.png">
Always include both og:image and twitter:image. Twitter uses its own tags and won't fall back to OG tags for images.
Step 4: Add Edge Caching
Social crawlers hit your OG image URL frequently. You don't want to generate a new screenshot every time. Beyond the file cache in the examples above, add a CDN layer:
- Cloudflare - Set a Cache-Control header and it handles the rest. Free tier works fine.
- Vercel Edge - If you're on Vercel, edge caching is automatic for image responses.
- S3 + CloudFront - Upload generated images to S3, serve through CloudFront. Good for high-volume.
A typical cache strategy: serve cached images for 24 hours. When content updates, invalidate the cache by changing the URL (add a version parameter: /og/my-page.png?v=2).
Template Design Tips
After generating thousands of OG images, here's what works:
- Keep text under 60 characters for titles. Longer titles get cut off in previews on most platforms.
- Use high-contrast colors. OG images display as small thumbnails. Subtle gradients and light text disappear.
- Include your brand. Logo + name in the corner. Every share is a branding opportunity.
- Avoid photos in templates. They compress badly at small sizes. Solid colors and typography work better.
- Test on multiple platforms. Twitter crops differently than LinkedIn. Use GrabShot's preview tool to check before deploying.
Advanced: Multiple Templates
Most SaaS products need more than one template. A blog post looks different from a user profile or a pricing page. Structure your templates by content type:
// Template registry
const templates = {
blog: ({ title, author, readTime }) => `...`,
profile: ({ name, avatar, role, stats }) => `...`,
docs: ({ title, section, version }) => `...`,
changelog: ({ version, date, highlights }) => `...`
};
app.get('/og/:type/:slug.png', async (req, res) => {
const { type, slug } = req.params;
const template = templates[type];
if (!template) return res.status(404).send('Unknown type');
const data = await getPageData(type, slug);
const html = template(data);
// ... screenshot and cache as before
});
Cache Invalidation
The hardest part of any caching system. For OG images, here are practical strategies:
- Content hash in the URL:
/og/my-post-abc123.pngwhereabc123is a hash of the content. When content changes, the URL changes, and caches miss automatically. - Webhook on content update: When a page is edited, delete the cached image. Next request regenerates it.
- TTL-based: Set a reasonable TTL (24h) and accept slight staleness. Simplest approach.
Cost at Scale
The math on dynamic OG images is favorable. Consider:
- A blog with 200 posts generates 200 images total (cached indefinitely)
- A SaaS with 10,000 user profiles generates 10,000 images (one per profile)
- With caching, you only call the screenshot API once per unique page
On GrabShot's Starter plan ($9/month for 2,000 screenshots), you can generate OG images for a 2,000-page site. The Pro plan ($29/month) covers 10,000. For most SaaS products, that's more than enough, since you only regenerate when content changes.
Testing Your OG Images
Before deploying, validate that your images render correctly across platforms:
- Twitter Card Validator - cards-dev.twitter.com/validator
- Facebook Sharing Debugger - developers.facebook.com/tools/debug
- LinkedIn Post Inspector - linkedin.com/post-inspector
- MetaPeek - Use MetaPeek to extract and verify all meta tags at once
Wrapping Up
Dynamic OG images turn every shared link into a branded, content-rich preview. The setup is straightforward: an HTML template, a screenshot API to render it, and a caching layer to keep things fast.
The investment pays off quickly. Better social previews mean more clicks, more shares, and more signups. And once the system is in place, it runs itself: every new page automatically gets a unique OG image.
Ready to start? Try GrabShot free and generate your first OG image in under a minute. Check out the API docs for the full HTML-to-image reference.
Published by the GrabShot team. We build APIs for screenshots, PDFs, and meta extraction.