Automate Open Graph Image Generation with a Screenshot API
Open Graph images — those 1200x630 preview cards that appear when you share a link on Twitter, LinkedIn, Slack, or iMessage — are the difference between a click and a scroll-past. A compelling preview image can increase click-through rates by 40-60%.
The problem: most developers design one static OG image for their entire site and call it done. Every blog post, product page, and documentation article gets the same generic image. This guide shows you how to generate unique, dynamic OG images for every page automatically using a screenshot API.
Why Dynamic OG Images Matter
Static OG images are set-and-forget, but they throw away the highest-value information: the actual content of the page being shared. Consider the difference between:
- A generic brand image with your logo (every link looks the same)
- A card showing the blog post title, author avatar, read time, and a relevant illustration (each link looks unique and informative)
Vercel, Linear, GitHub, and every developer-facing product with strong social presence generates unique OG images per page. The implementation is simpler than most developers assume.
The Core Pattern
The approach is straightforward:
- Create an HTML template for your OG image (a styled page at 1200x630px).
- Build a URL that renders this template with dynamic content injected via query parameters or a server-side route.
- Call the screenshot API with that URL, specifying a 1200x630 viewport.
- Cache the resulting PNG with an aggressive TTL (images rarely change).
- Return the cached image URL as the
og:imagemeta tag value.
Step 1: Create an OG Image Template
Build a simple HTML page that accepts query parameters and renders them in a visually appealing layout. Save it at a route like /og-template in your app.
// app/og-template/route.js (Next.js)
// Returns HTML page designed for 1200x630 screenshot
export async function GET(request) {
const { searchParams } = new URL(request.url);
const title = searchParams.get('title') || 'Untitled';
const author = searchParams.get('author') || 'Your Blog';
const tag = searchParams.get('tag') || 'Article';
const date = searchParams.get('date') || new Date().toLocaleDateString();
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700;900&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 1200px; height: 630px; overflow: hidden;
font-family: Inter, sans-serif;
background: linear-gradient(135deg, #0f0f1a 0%, #1a0f2e 50%, #0a1628 100%);
display: flex; align-items: center; justify-content: center;
}
.card {
width: 1100px;
padding: 64px;
position: relative;
}
.tag {
display: inline-block;
background: rgba(99,102,241,0.2);
border: 1px solid rgba(99,102,241,0.4);
color: #818cf8;
padding: 6px 18px;
border-radius: 9999px;
font-size: 18px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 32px;
}
h1 {
font-size: 62px;
font-weight: 900;
color: white;
line-height: 1.1;
margin-bottom: 40px;
max-width: 900px;
}
.meta {
display: flex;
align-items: center;
gap: 24px;
color: #94a3b8;
font-size: 22px;
}
.divider { opacity: 0.4; }
.brand {
position: absolute;
bottom: 64px;
right: 64px;
font-size: 24px;
font-weight: 700;
color: #6366f1;
}
.accent-bar {
position: absolute;
top: 0;
left: 64px;
width: 80px;
height: 6px;
background: linear-gradient(90deg, #6366f1, #8b5cf6);
border-radius: 0 0 6px 6px;
}
</style>
</head>
<body>
<div class="card">
<div class="accent-bar"></div>
<div class="tag">${escapeHtml(tag)}</div>
<h1>${escapeHtml(title)}</h1>
<div class="meta">
<span>${escapeHtml(author)}</span>
<span class="divider">·</span>
<span>${escapeHtml(date)}</span>
</div>
<div class="brand">yourdomain.com</div>
</div>
</body></html>`;
return new Response(html, {
headers: { 'Content-Type': 'text/html' }
});
}
function escapeHtml(str) {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
Step 2: Generate the Screenshot
import { SnapAPI } from 'snapapi-js';
const client = new SnapAPI(process.env.SNAPAPI_KEY);
async function generateOgImage({ title, author, tag, date }) {
// Build the template URL with dynamic parameters
const templateUrl = new URL('https://your-app.com/og-template');
templateUrl.searchParams.set('title', title);
templateUrl.searchParams.set('author', author || 'Your Blog');
templateUrl.searchParams.set('tag', tag || 'Article');
templateUrl.searchParams.set('date', date || new Date().toLocaleDateString());
const image = await client.screenshot({
url: templateUrl.toString(),
format: 'png',
viewport: { width: 1200, height: 630 },
full_page: false,
delay: 500 // Wait for Google Fonts to load
});
return image; // Buffer containing PNG bytes
}
// Usage
const ogImage = await generateOgImage({
title: 'How to Build a Screenshot API in Node.js',
author: 'Jane Smith',
tag: 'Tutorial',
date: 'March 17, 2026'
});
Step 3: Cache and Serve
OG image generation is expensive (1-3 seconds per image). Cache aggressively. The simplest approach is an on-demand API route with an in-memory or Redis cache keyed by the content hash.
// app/api/og/route.js — OG image endpoint with caching
import { SnapAPI } from 'snapapi-js';
import { createHash } from 'crypto';
import { NextResponse } from 'next/server';
const client = new SnapAPI(process.env.SNAPAPI_KEY);
const cache = new Map(); // Replace with Redis in production
export async function GET(request) {
const { searchParams } = new URL(request.url);
const title = searchParams.get('title') || 'Untitled';
const author = searchParams.get('author') || '';
const tag = searchParams.get('tag') || 'Article';
// Cache key from content
const cacheKey = createHash('md5')
.update(`${title}|${author}|${tag}`)
.digest('hex');
// Return cached image if available
if (cache.has(cacheKey)) {
return new NextResponse(cache.get(cacheKey), {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=604800, immutable', // 1 week
'X-Cache': 'HIT'
}
});
}
// Generate fresh image
const templateUrl = `https://${request.headers.get('host')}/og-template?` +
new URLSearchParams({ title, author, tag });
const image = await client.screenshot({
url: templateUrl,
format: 'png',
viewport: { width: 1200, height: 630 },
delay: 500
});
cache.set(cacheKey, image);
return new NextResponse(image, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=604800, immutable',
'X-Cache': 'MISS'
}
});
}
Step 4: Wire Up the Meta Tags
In your blog post or page template, generate the OG image URL dynamically.
// Next.js generateMetadata
export async function generateMetadata({ params }) {
const post = await getPost(params.slug);
const ogImageUrl = `/api/og?` + new URLSearchParams({
title: post.title,
author: post.author.name,
tag: post.category
});
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [
{
url: ogImageUrl,
width: 1200,
height: 630,
alt: post.title
}
]
},
twitter: {
card: 'summary_large_image',
images: [ogImageUrl]
}
};
}
Use Cases Beyond Blog Posts
E-Commerce: Product OG Images
Instead of a generic brand image, show the product name, price, and rating. When someone shares a product link on social media, the preview card becomes a mini-ad.
const productOgUrl = `/api/og?` + new URLSearchParams({
type: 'product',
name: product.name,
price: `$${product.price}`,
rating: `${product.rating}/5`,
category: product.category
});
SaaS: User Achievement Cards
Generate shareable milestone cards when users reach achievements, complete a course, or earn a badge. These naturally go viral because users want to share them.
Documentation: Section Cards
Every documentation page gets an OG image showing the section title, product name, and a relevant code snippet. Makes shared docs links look credible and specific.
Job Boards and Directories
Each listing gets a card with the job title, company, location, and salary range. Users sharing job listings on LinkedIn see a professional preview instead of a generic logo.
Performance Tips
- Pre-generate at build time. For static sites, generate all OG images during the build and store them in your CDN. No on-demand generation needed.
- Use Redis for caching in production. The in-memory
Mapexample above is only for illustration. Use Redis with aSET key value EX 604800(7-day TTL) in production. - Self-host your fonts. Using Google Fonts in the OG template adds a network round-trip that can fail. Bundle your fonts as base64 data URIs in the HTML or host them on your own CDN.
- Avoid complex animations. CSS animations add render complexity without any benefit in a static screenshot. Keep the template fast and lean.
- Test with the Facebook Sharing Debugger. facebook.com/tools/debug and the Twitter Card Validator let you preview exactly how your OG images appear before publishing.
curl -H "Authorization: Bearer YOUR_KEY" "https://api.snapapi.pics/v1/screenshot?url=https://your-app.com/og-template%3Ftitle%3DHello+World&width=1200&height=630" -o og-test.png
Try SnapAPI Free
200 free requests/month. Generate dynamic OG images, screenshots, PDFs, and more from one API. No credit card required.
Get Free API Key →Frequently Asked Questions
Do I need to build an HTML template, or is there a shortcut?
The template approach gives you full control over the design. If you want something even faster, you can screenshot an existing page directly — just set the viewport to 1200x630 and capture the hero section of any page. It is less polished but gets you a unique image per page immediately.
How do I handle special characters in the title?
URL-encode query parameters with encodeURIComponent() in JavaScript or urllib.parse.quote() in Python. Always HTML-escape the values before inserting them into your template to prevent XSS.
Can I use this for Twitter card images too?
Yes. Twitter uses the og:image tag if no Twitter-specific image is set. Alternatively, set twitter:image to the same URL. Twitter's summary_large_image card displays at 1200x630 — the same dimensions as OG images.
What about Slack and iMessage unfurls?
Both read og:image. Slack caches OG images aggressively — if you update an image, the old version may persist in Slack for hours. Use versioned URLs (append a hash or timestamp) when you need cache busting.