Generate Open Graph Images Automatically with an API
Automatically generate custom OG images for every blog post, product, or link. One API call per share.
When someone shares your blog post, product page, or documentation on Twitter, LinkedIn, or Slack, the OG image is what stops the scroll. A generic screenshot or, worse, no image at all means fewer clicks.
The problem: creating a custom OG image for every piece of content doesn't scale. You publish 3 blog posts a week. You have 500 product pages. You can't open Figma for each one.
Why Manual OG Images Don't Scale
Most teams start with one of these approaches — and hit a wall:
- Figma template, exported manually: Works for 10 posts. Breaks at 100. Impossible at 1,000.
- Generic site screenshot: Looks the same for every URL. Provides zero context. Gets ignored.
- Canvas/node-canvas in Node.js: Requires Cairo system libraries, complex font loading, no CSS. Results look hand-coded.
- Vercel's @vercel/og: JSX-only, Vercel-locked, limited CSS subset. No tables, no complex layouts.
The right solution is to define an HTML template for your OG images — full CSS, your brand fonts, whatever layout you want — and generate them on demand via API. That's what PageBolt does.
Example 1: Basic OG Image Generation
The simplest possible OG image: your post title and site name on a branded background.
async function generateOgImage(post: {
title: string;
category: string;
readTime: string;
}): Promise<Buffer> {
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700;800;900&display=swap');
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
width: 1200px;
height: 630px;
font-family: 'Inter', sans-serif;
background: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 50%, #0d0d1f 100%);
display: flex;
flex-direction: column;
justify-content: center;
padding: 80px;
position: relative;
overflow: hidden;
}
body::before {
content: '';
position: absolute;
top: -200px;
right: -200px;
width: 600px;
height: 600px;
background: radial-gradient(circle, rgba(99,102,241,0.15) 0%, transparent 70%);
}
.category {
font-size: 14px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #818cf8;
margin-bottom: 24px;
}
h1 {
font-size: 64px;
font-weight: 900;
line-height: 1.1;
color: #ffffff;
max-width: 900px;
margin-bottom: 40px;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
}
.site {
display: flex;
align-items: center;
gap: 12px;
}
.logo-icon {
width: 40px;
height: 40px;
background: linear-gradient(135deg, #6366f1, #4f46e5);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
}
.site-name { font-size: 20px; font-weight: 700; color: #ffffff; }
.meta { font-size: 16px; color: #6b7280; }
</style>
</head>
<body>
<div class="category">${post.category}</div>
<h1>${post.title}</h1>
<div class="footer">
<div class="site">
<div class="logo-icon">⚡</div>
<span class="site-name">PageBolt</span>
</div>
<div class="meta">${post.readTime} read · pagebolt.dev</div>
</div>
</body>
</html>
`;
const response = await fetch('https://pagebolt.dev/api/v1/screenshot', {
method: 'POST',
headers: {
'x-api-key': process.env.PAGEBOLT_API_KEY!,
'Content-Type': 'application/json',
},
body: JSON.stringify({
html,
viewport: { width: 1200, height: 630 },
format: 'png',
}),
});
if (!response.ok) throw new Error(`OG image failed: ${response.status}`);
return Buffer.from(await response.arrayBuffer());
}
Example 2: Auto-Generate on Publish
Hook into your CMS publish event or blog API route to generate the OG image as soon as content goes live:
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { db } from '../database.js';
const s3 = new S3Client({ region: 'us-east-1' });
// Called when a blog post is published
export async function onPostPublished(postId: string): Promise<void> {
const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId) as Post;
if (!post) throw new Error(`Post ${postId} not found`);
const imageBuffer = await generateOgImage({
title: post.title,
category: post.category,
readTime: `${Math.ceil(post.word_count / 200)} min`,
});
// Upload to S3
const key = `og-images/${post.slug}.png`;
await s3.send(new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
Body: imageBuffer,
ContentType: 'image/png',
CacheControl: 'public, max-age=31536000, immutable',
}));
const imageUrl = `https://${process.env.CDN_DOMAIN}/${key}`;
// Store URL in database
db.prepare('UPDATE posts SET og_image_url = ? WHERE id = ?')
.run(imageUrl, postId);
console.log(`OG image generated for "${post.title}": ${imageUrl}`);
}
// In your blog post route, include the stored URL
app.get('/blog/:slug', (req, res) => {
const post = db.prepare('SELECT * FROM posts WHERE slug = ?').get(req.params.slug) as Post;
if (!post) return res.status(404).send('Not found');
res.send(`
<!DOCTYPE html>
<html>
<head>
<meta property="og:image" content="${post.og_image_url}">
<meta name="twitter:image" content="${post.og_image_url}">
<meta property="og:title" content="${post.title}">
<!-- ... rest of head -->
</head>
<body>
<!-- ... post content -->
</body>
</html>
`);
});
Example 3: Gatsby / Next.js Build Integration
Generate OG images at build time for all posts, so they're ready at first deploy:
// scripts/generate-og-images.ts
// Run with: npx tsx scripts/generate-og-images.ts
import fs from 'fs/promises';
import path from 'path';
import matter from 'gray-matter';
import pLimit from 'p-limit';
const POSTS_DIR = path.join(process.cwd(), 'content/blog');
const OG_OUTPUT_DIR = path.join(process.cwd(), 'public/og');
async function main() {
await fs.mkdir(OG_OUTPUT_DIR, { recursive: true });
const files = await fs.readdir(POSTS_DIR);
const mdFiles = files.filter((f) => f.endsWith('.md') || f.endsWith('.mdx'));
const limit = pLimit(5); // 5 concurrent requests
let generated = 0;
let skipped = 0;
await Promise.all(
mdFiles.map((file) =>
limit(async () => {
const slug = file.replace(/\.mdx?$/, '');
const outputPath = path.join(OG_OUTPUT_DIR, `${slug}.png`);
// Skip if already generated (use --force to regenerate all)
if (!process.argv.includes('--force')) {
try {
await fs.access(outputPath);
skipped++;
return;
} catch { /* doesn't exist, generate it */ }
}
const content = await fs.readFile(path.join(POSTS_DIR, file), 'utf-8');
const { data: frontmatter } = matter(content);
const imageBuffer = await generateOgImage({
title: frontmatter.title,
category: frontmatter.category || 'Article',
readTime: frontmatter.readTime || '5 min',
});
await fs.writeFile(outputPath, imageBuffer);
generated++;
console.log(`Generated OG image: ${slug}.png`);
})
)
);
console.log(`\nDone: ${generated} generated, ${skipped} skipped`);
}
main().catch(console.error);
Add it to your build pipeline:
// package.json
{
"scripts": {
"build": "npx tsx scripts/generate-og-images.ts && next build",
"og:regenerate": "npx tsx scripts/generate-og-images.ts --force"
}
}
Example 4: Scheduled Regeneration Cron
If your OG image template changes (new branding, updated layout), regenerate all images on a schedule rather than manually:
import cron from 'node-cron';
import pLimit from 'p-limit';
// Regenerate all OG images every Sunday at 2 AM (after deploys settle)
cron.schedule('0 2 * * 0', async () => {
console.log('Starting weekly OG image regeneration...');
const posts = db.prepare('SELECT id, title, category, slug, word_count FROM posts WHERE published = 1').all() as Post[];
const limit = pLimit(10);
let success = 0;
let failed = 0;
await Promise.all(
posts.map((post) =>
limit(async () => {
try {
const imageBuffer = await generateOgImage({
title: post.title,
category: post.category,
readTime: `${Math.ceil(post.word_count / 200)} min`,
});
const key = `og-images/${post.slug}.png`;
await s3.send(new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
Body: imageBuffer,
ContentType: 'image/png',
CacheControl: 'public, max-age=31536000, immutable',
}));
success++;
} catch (err) {
console.error(`Failed for post ${post.slug}:`, err);
failed++;
}
})
)
);
console.log(`OG image regeneration complete: ${success} success, ${failed} failed`);
});
Example 5: Dynamic OG Images Per Page
For user-generated content or product pages where you can't pre-generate, serve OG images on demand via a dedicated route:
import express from 'express';
import NodeCache from 'node-cache';
const app = express();
// Cache for 24 hours to avoid regenerating on every share
const ogCache = new NodeCache({ stdTTL: 86400 });
app.get('/og/:type/:id', async (req, res) => {
const { type, id } = req.params;
const cacheKey = `${type}:${id}`;
// Return cached image if available
const cached = ogCache.get<Buffer>(cacheKey);
if (cached) {
res.setHeader('Content-Type', 'image/png');
res.setHeader('Cache-Control', 'public, max-age=86400');
return res.send(cached);
}
let imageData: { title: string; subtitle: string; category: string };
switch (type) {
case 'post': {
const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(id) as Post;
if (!post) return res.status(404).send('Not found');
imageData = {
title: post.title,
subtitle: `${Math.ceil(post.word_count / 200)} min read`,
category: post.category,
};
break;
}
case 'product': {
const product = db.prepare('SELECT * FROM products WHERE id = ?').get(id) as Product;
if (!product) return res.status(404).send('Not found');
imageData = {
title: product.name,
subtitle: `From $${(product.price_cents / 100).toFixed(2)}`,
category: product.category,
};
break;
}
case 'user': {
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(id) as User;
if (!user) return res.status(404).send('Not found');
imageData = {
title: user.display_name,
subtitle: `${user.post_count} posts · Joined ${new Date(user.created_at).getFullYear()}`,
category: 'Profile',
};
break;
}
default:
return res.status(400).send('Unknown type');
}
const imageBuffer = await generateOgImage({
title: imageData.title,
category: imageData.category,
readTime: imageData.subtitle,
});
ogCache.set(cacheKey, imageBuffer);
res.setHeader('Content-Type', 'image/png');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.send(imageBuffer);
});
// Reference in your page HTML:
// <meta property="og:image" content="https://yoursite.com/og/post/123">
Example 6: Template Customization
Different content types deserve different OG image designs. Here's a product-focused template with a price badge:
function buildProductOgHtml(product: {
name: string;
description: string;
price: string;
rating: number;
reviewCount: number;
category: string;
}): string {
const stars = '★'.repeat(Math.floor(product.rating)) + '☆'.repeat(5 - Math.floor(product.rating));
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800;900&display=swap');
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
width: 1200px;
height: 630px;
font-family: 'Inter', sans-serif;
background: #ffffff;
display: grid;
grid-template-columns: 1fr 380px;
}
.left {
background: #f8fafc;
padding: 72px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 20px;
}
.category {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #6366f1;
}
h1 {
font-size: 52px;
font-weight: 900;
line-height: 1.1;
color: #0f172a;
}
.description { font-size: 18px; color: #64748b; line-height: 1.6; }
.rating { display: flex; align-items: center; gap: 8px; }
.stars { color: #f59e0b; font-size: 18px; letter-spacing: 1px; }
.review-count { font-size: 14px; color: #94a3b8; }
.site { display: flex; align-items: center; gap: 10px; margin-top: auto; }
.site-name { font-size: 16px; font-weight: 700; color: #94a3b8; }
.right {
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 48px;
}
.price-label { font-size: 14px; font-weight: 600; color: rgba(255,255,255,0.7); text-transform: uppercase; letter-spacing: 0.1em; }
.price { font-size: 72px; font-weight: 900; color: #ffffff; line-height: 1; }
.cta {
background: #ffffff;
color: #4f46e5;
font-size: 16px;
font-weight: 700;
padding: 14px 32px;
border-radius: 12px;
margin-top: 8px;
}
</style>
</head>
<body>
<div class="left">
<div class="category">${product.category}</div>
<h1>${product.name}</h1>
<p class="description">${product.description}</p>
<div class="rating">
<span class="stars">${stars}</span>
<span class="review-count">${product.reviewCount} reviews</span>
</div>
<div class="site">
<span class="site-name">YourStore.com</span>
</div>
</div>
<div class="right">
<div class="price-label">Starting from</div>
<div class="price">${product.price}</div>
<div class="cta">View Product →</div>
</div>
</body>
</html>
`;
}
Scale: How Many OG Images Do You Need?
| Content type | Volume | Generation strategy | Monthly API cost |
|---|---|---|---|
| Blog posts | 4/week = ~200/year | On publish | $0–$1.20 |
| Product pages | 500 products | Build time, once | $3.00 one-time |
| User profiles | On-demand | Cached route | Depends on traffic |
| Documentation | Entire docs on deploy | Build script | $1–$10 one-time |
SEO Impact
| Metric | No OG image | Generic screenshot | Custom OG image |
|---|---|---|---|
| Twitter click-through rate | Baseline | +8% | +40–60% |
| LinkedIn engagement | Baseline | +12% | +50–80% |
| Slack/Discord unfurl quality | Text only | Poor | High |
Templates Available
PageBolt also offers pre-built OG image templates via the create_og_image endpoint, so you don't need to write HTML at all:
- blog — Title, author, date, category tag on gradient background
- product — Product name, price badge, star rating
- profile — Avatar, name, stats, follower count
- announcement — Bold headline, subtext, brand colors
- docs — Page title, breadcrumb, dark/light variants
- event — Event name, date, location, ticket CTA
// Using a built-in template — no HTML required
const response = await fetch('https://pagebolt.dev/api/v1/og', {
method: 'POST',
headers: {
'x-api-key': process.env.PAGEBOLT_API_KEY!,
'Content-Type': 'application/json',
},
body: JSON.stringify({
template: 'blog',
data: {
title: 'How to Generate OG Images at Scale',
category: 'Engineering',
readTime: '6 min read',
siteName: 'YourBlog',
date: 'Mar 25, 2026',
},
}),
});
const imageBuffer = Buffer.from(await response.arrayBuffer());
Getting Started
- Sign up at pagebolt.dev — 100 free requests/month, no credit card
- Get your API key from the dashboard
- Pick your approach: on-publish hook, build script, or on-demand cached route
- Write your HTML template or use a built-in template
- Deploy and verify with the Twitter Card Validator and LinkedIn Post Inspector
Frequently Asked Questions
What size should OG images be?
1200×630 pixels is the standard. Twitter, LinkedIn, Facebook, and Slack all support this size. The aspect ratio is 1.91:1. Use a viewport of { width: 1200, height: 630 } in your PageBolt request.
Can I use custom fonts?
Yes. Reference any Google Fonts URL in your HTML template's <style> block. PageBolt's browser loads the fonts before capturing the image. For self-hosted fonts, either host them publicly or embed them as base64 data URIs in the HTML.
How do I bust the cache after regenerating?
Add a version query parameter to your og:image URL: https://yoursite.com/og/post/123?v=2. Social platforms cache OG images aggressively — changing the URL forces a re-fetch. Alternatively, use a CDN with a surrogate key that you can purge.
Can I generate OG images for pages behind authentication?
Yes. Pass cookies or auth headers in the PageBolt request. Or pass the rendered HTML directly instead of a URL — that's the recommended approach for authenticated content.
How fast is generation?
Typically 600ms–1.2s per image. For on-publish generation, this is imperceptible. For on-demand routes, cache the result in memory or on a CDN so subsequent requests are instant.
Generate OG images for every page automatically
Full HTML/CSS templates. Custom fonts. Built-in template library. Works on Vercel, Lambda, and anywhere else. 100 free images/month — no credit card.
Get your free API key