Auto-Generate Social Media Preview Images with a Screenshot API

S
ScreenshotAPIs Team April 18, 2026 · 12 min read

When someone shares your link on X, LinkedIn, Facebook, Slack, or Discord, the platform fetches your page, reads its meta tags, and renders a preview card. A compelling preview image makes people click. A missing or generic one gets scrolled past — the difference in click-through rate between a bare link and a well-designed card is routinely 2–3x on social feeds, and even higher in chat tools like Slack where the unfurled card is the message.

Generating a unique image for every blog post, product page, or user profile used to mean Canvas APIs, ImageMagick wizardry, or paying a designer per asset. There's a simpler system: design one card as an HTML/CSS template, then render it per page with a screenshot API that accepts raw HTML. This article is the full system design — the meta tags and platform rules, a production-ready template, working Node and Python code, and the caching, regeneration, and CMS integration patterns you need to run this at scale.

Why preview images are worth automating

Tired of the setup? Try the API free.

100 renders / month, no credit card. Or buy credits one-time and use them whenever — credits never expire.

Start free →

Every major platform builds link previews from the same source: Open Graph meta tags in your page's <head>. But each platform has its own crawler, its own cache, and its own debugging tool:

The canonical size is 1200×630 pixels (a 1.91:1 ratio) — it renders correctly everywhere, and only X benefits from a dedicated 1200×675 variant. Keep the file under 5 MB for X and under 8 MB for Facebook; in practice a well-compressed PNG of a designed card lands around 100–300 KB, so you'll never hit the limits unless you embed photography.

The meta tags that make it work

The image itself is only half the system. Your page needs these tags so crawlers find it:

<meta property="og:title" content="Your Post Title">
<meta property="og:image" content="https://cdn.yourbrand.com/og/posts/my-post-8f3a2c.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://cdn.yourbrand.com/og/posts/my-post-8f3a2c.png">

Two details people miss. First, og:image:width and og:image:height let Facebook render the full-size card the first time a link is shared — without them, the first share often shows a small thumbnail because the crawler hasn't fetched the image yet. Second, the image URL must be absolute and publicly reachable; crawlers won't follow redirects through auth walls or render localhost URLs.

The template approach: design once, render everywhere

Instead of drawing pixels with a Canvas library, design your card as a small standalone HTML page with placeholders. You already know HTML and CSS, fonts and emojis just work, and flexbox handles the layout math. Here's a complete, self-contained template — gradient background, title, author, and domain, using only system fonts so there's nothing to load:

<!DOCTYPE html>
<html>
<head>
<style>
  * { margin: 0; box-sizing: border-box; }
  body { font-family: system-ui, -apple-system, sans-serif; }
  .card {
    width: 1200px;
    height: 630px;
    padding: 72px 80px;
    background: linear-gradient(135deg, #0f172a 0%, #1e3a8a 55%, #2563eb 100%);
    color: #fff;
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    position: relative;
    overflow: hidden;
  }
  .card::after {          /* subtle glow in the corner */
    content: "";
    position: absolute;
    right: -160px; top: -160px;
    width: 480px; height: 480px;
    border-radius: 50%;
    background: radial-gradient(circle, rgba(96,165,250,.35), transparent 70%);
  }
  .kicker {
    font-size: 26px;
    font-weight: 600;
    letter-spacing: .06em;
    text-transform: uppercase;
    color: #93c5fd;
  }
  .title {
    font-size: 68px;
    font-weight: 800;
    line-height: 1.12;
    letter-spacing: -0.02em;
    max-width: 980px;
    display: -webkit-box;
    -webkit-line-clamp: 3;        /* never overflow the card */
    -webkit-box-orient: vertical;
    overflow: hidden;
  }
  .footer { display: flex; justify-content: space-between; align-items: center; }
  .author { font-size: 28px; font-weight: 600; }
  .domain { font-size: 24px; opacity: .75; }
</style>
</head>
<body>
<div class="card" id="card">
  <div class="kicker">{{kicker}}</div>
  <div class="title">{{title}}</div>
  <div class="footer">
    <div class="author">{{author}}</div>
    <div class="domain">yourbrand.com</div>
  </div>
</div>
</body>
</html>

The -webkit-line-clamp: 3 on the title is the detail that makes this production-safe: a 140-character title truncates with an ellipsis instead of pushing the footer off the card. Test the template with your longest real titles — never with "Lorem ipsum". You can iterate on the design interactively in the free screenshot generator before writing any code.

Rendering the template to a PNG

This is where raw HTML input matters. Route-based approaches (covered in our Next.js OG image guide) require you to deploy a public page for every card. With HTML input you skip that entirely: fill the placeholders, POST the HTML, get a PNG back. The selector parameter captures exactly the card element, and device_scale_factor: 2 renders at 2400×1260 for retina-sharp feeds.

Node.js

// npm install screenshotapis
import Client from "screenshotapis";
import { readFileSync } from "fs";

const client = new Client(process.env.SCREENSHOT_API_KEY);
const template = readFileSync("og-template.html", "utf8");

const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");

export async function renderOgImage(post) {
  const html = template
    .replace("{{kicker}}", esc(post.category))
    .replace("{{title}}", esc(post.title))
    .replace("{{author}}", esc(post.author));

  const { data } = await client.screenshot({
    html,                       // raw HTML — no URL to deploy
    selector: "#card",          // capture just the card element
    format: "png",
    viewport_width: 1200,
    viewport_height: 630,
    device_scale_factor: 2,     // retina output
  });

  return data;                  // Buffer, ready for S3 / disk
}

Python

# pip install requests  (or use the screenshotapis SDK on PyPI)
import os, requests

TEMPLATE = open("og-template.html").read()

def render_og_image(post: dict) -> bytes:
    html = (TEMPLATE
        .replace("{{kicker}}", post["category"])
        .replace("{{title}}", post["title"])
        .replace("{{author}}", post["author"]))

    resp = requests.post(
        "https://screenshotapis.org/v1/screenshot",
        headers={"X-Api-Key": os.environ["SCREENSHOT_API_KEY"]},
        json={
            "html": html,
            "selector": "#card",
            "format": "png",
            "viewport_width": 1200,
            "viewport_height": 630,
            "device_scale_factor": 2,
        },
        timeout=30,
    )
    resp.raise_for_status()
    return resp.content

The response is the binary image, with X-Render-Time-Ms and X-Credits-Remaining headers for observability. Typical renders complete in 0.9–1.6 seconds. Always escape user-controlled strings before injecting them into the template — a title containing <script> should render as text, not execute. The full parameter reference is in the API docs.

Production concerns (and how to solve each one)

Caching: render once per content hash

An OG image only changes when its inputs change, so key the cache on a hash of everything that affects the pixels — title, author, category, and a template version number:

import { createHash } from "crypto";

const TEMPLATE_VERSION = "v3";

function ogCacheKey(post) {
  const hash = createHash("sha256")
    .update([TEMPLATE_VERSION, post.title, post.author, post.category].join("|"))
    .digest("hex")
    .slice(0, 12);
  return `og/posts/${post.slug}-${hash}.png`;
}

async function getOgImageUrl(post) {
  const key = ogCacheKey(post);
  if (!(await s3Exists(key))) {
    const png = await renderOgImage(post);
    await s3Put(key, png, "image/png");   // Cache-Control: public, max-age=31536000, immutable
  }
  return `https://cdn.yourbrand.com/${key}`;
}

Putting the hash in the filename does double duty. The URL is immutable, so you can set a one-year cache header on your CDN. And when the content changes, the URL changes — which automatically busts Facebook's and LinkedIn's crawler caches, since to them it's a brand-new image. No manual "Scrape Again" clicks needed.

Regeneration on edit

With content-hash keys, regeneration is free: run getOgImageUrl() in your publish and update paths. Unchanged posts hit the existing S3 object and cost zero renders; edited titles miss the cache and render fresh. Bumping TEMPLATE_VERSION after a redesign invalidates every card at once, and they lazily re-render on the next publish or backfill run.

Latency: pre-render at publish, don't render on request

Never generate the image inside the request that serves your page's meta tags. Crawlers time out fast, and a 1.2-second render inside Slack's unfurl window is a coin flip. The right ordering is: render at publish time, store the image, and put a plain static URL in the meta tag. If you must render on-demand (e.g., user profiles that change constantly), serve from cache and regenerate in the background — or pass webhook_url in the API request to get a 202 immediately and receive the finished image asynchronously.

Always have a fallback image

Render failures, race conditions at publish time, legacy posts that predate the system — some page will eventually have no generated card. Ship a static brand-level fallback (your logo on the same gradient) and make the meta-tag helper default to it whenever the generated URL isn't confirmed to exist. A generic-but-branded card is fine; a broken image icon in a LinkedIn feed is not.

Test with the platform validators before launch

Run a handful of real URLs through the Facebook Sharing Debugger and LinkedIn Post Inspector, DM yourself the link on X, and paste one into a private Slack channel. Each tool shows you what the crawler actually fetched — the fastest way to catch a relative URL, a missing twitter:card tag, or a robots.txt rule blocking the crawler from your CDN.

CMS integration recipes

Headless CMS: webhook on publish

Contentful, Sanity, Strapi, and friends all fire webhooks on publish. Point one at a small handler that renders the card, uploads it, and writes the URL back to the entry:

app.post("/webhooks/cms-publish", async (req, res) => {
  const post = req.body.entry;
  const ogUrl = await getOgImageUrl(post);      // render + upload, cached by hash
  await cms.updateEntry(post.id, { ogImage: ogUrl });
  res.sendStatus(200);
});

WordPress: hook the publish transition

add_action('publish_post', function ($post_id) {
  $post = get_post($post_id);
  $html = str_replace(
    ['{{title}}', '{{author}}'],
    [esc_html($post->post_title), esc_html(get_the_author_meta('display_name', $post->post_author))],
    file_get_contents(__DIR__ . '/og-template.html')
  );
  $resp = wp_remote_post('https://screenshotapis.org/v1/screenshot', [
    'headers' => ['X-Api-Key' => SCREENSHOT_API_KEY, 'Content-Type' => 'application/json'],
    'body'    => wp_json_encode(['html' => $html, 'selector' => '#card',
                                 'device_scale_factor' => 2]),
    'timeout' => 30,
  ]);
  // upload wp_remote_retrieve_body($resp) to your media library or CDN,
  // then save the URL in post meta for your theme's og:image tag
});

Static sites: a build-step loop

For Astro, Hugo, Eleventy, or Jekyll, run a script after the content build: loop over posts, skip any whose content-hash image already exists in your bucket, render the rest. A 200-post site that publishes twice a week does two renders per week — effectively free. New sites backfilling their whole archive fit comfortably in the free tier of 100 renders per month, and one-time credit packs from $9 that never expire cover larger backfills without a subscription.

Cost and scale

Because caching means one render per piece of content (not per share), the economics are gentle. The free tier's 100 renders/month covers most blogs indefinitely — no card required. Paid plans run from $19/month for 2,000 renders up to $299/month for 75,000, which is the territory of programmatic-SEO sites generating cards for tens of thousands of pages. The same html-input pattern extends beyond social cards, too: the HTML-to-PDF endpoint turns the identical template workflow into invoices and reports, and the quick-start OG guide has a copy-paste cURL version if you want the minimal path before building the full system.

Frequently asked questions

What size should a social media preview image be?

Use 1200×630 pixels (a 1.91:1 aspect ratio). It renders correctly on Facebook, LinkedIn, Slack, and Discord, and acceptably on X. If X is a priority channel, generate an additional 1200×675 (16:9) variant and point twitter:image at it. Keep files under 5 MB, though a designed card typically compresses to 100–300 KB as PNG.

Do I need to regenerate the image every time the link is shared?

No — that's the key insight of the caching design. Render once per unique content (keyed by a hash of title, author, and template version), store the PNG on S3 or a CDN with immutable cache headers, and serve the same URL to every crawler. A post shared 10,000 times still costs exactly one render.

How do I force Facebook or LinkedIn to pick up an updated image?

Change the image URL. If you embed a content hash in the filename, an edited title produces a new URL automatically, and platform crawlers treat it as a fresh image. For manual refreshes, use Facebook's Sharing Debugger ("Scrape Again") or LinkedIn's Post Inspector, both of which re-crawl the page on demand.

Why render HTML instead of using a Canvas library or @vercel/og?

Canvas libraries make you position every element with pixel math and handle font loading yourself. Satori-based tools like @vercel/og support only a subset of CSS. Rendering real HTML in a real browser engine gives you full flexbox, grid, gradients, custom fonts, emoji, and line-clamping — and the API's raw HTML input means you don't need to deploy a public template page at all.

How fast is the rendering, and can it run at publish time?

Typical renders complete in 0.9–1.6 seconds, which is fine inside a publish webhook or build step. Don't render synchronously while a crawler is waiting on your page; pre-render at publish time, or use the API's webhook mode to receive the image asynchronously.

How much does automated OG image generation cost?

The free tier includes 100 renders per month with no credit card, which covers most blogs since each post renders once. Paid plans start at $19/month for 2,000 renders, and one-time credit packs start at $9 and never expire — useful for backfilling an existing archive.