Tutorial March 17, 2026 10 min read

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:

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:

  1. Create an HTML template for your OG image (a styled page at 1200x630px).
  2. Build a URL that renders this template with dynamic content injected via query parameters or a server-side route.
  3. Call the screenshot API with that URL, specifying a 1200x630 viewport.
  4. Cache the resulting PNG with an aggressive TTL (images rarely change).
  5. Return the cached image URL as the og:image meta 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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

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

cURL quickstart: Test your OG image endpoint from the terminal before integrating it into your app:

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.