Auto-Generate Social Media Preview Images with a Screenshot API
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
100 renders / month, no credit card. Or buy credits one-time and use them whenever — credits never expire.
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:
- X (Twitter) — the Twitterbot crawler reads
twitter:cardandog:image. The old Card Validator no longer shows a visual preview, so the reliable test is posting the link in a DM to yourself. X crops toward 16:9, so 1200×675 is the safest size forsummary_large_imagecards. - Facebook — the facebookexternalhit crawler caches aggressively. The Sharing Debugger shows exactly what was scraped and has a "Scrape Again" button to bust the cache.
- LinkedIn — uses the Post Inspector, which both previews and refreshes its cache. LinkedIn recommends a 1.91:1 ratio, so the standard 1200×630 works as-is.
- Slack and Discord — both unfurl links using Open Graph tags with no special markup needed. Slack caches unfurls for roughly 30 minutes, which matters when you're iterating on a design.
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, "&").replace(/</g, "<").replace(/>/g, ">");
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.