Guide Mar 25, 2026

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:

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:

// 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

  1. Sign up at pagebolt.dev — 100 free requests/month, no credit card
  2. Get your API key from the dashboard
  3. Pick your approach: on-publish hook, build script, or on-demand cached route
  4. Write your HTML template or use a built-in template
  5. 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