· Updated · Tutorials · By Simon Chiu

Generate OG Images From HTML: A Production Guide

Generate Open Graph images from an HTML template: platform size limits, escaping, signed URLs without redirects, cache-safe expiry and a Next.js example.

Line illustration of the RenderScreenshot mascot generating a social card from a browser

To generate OG images from HTML, build a 1200×630 page on your own site that shows one post's title, then put a signed screenshot URL of that page straight into og:image. The crawler fetches the URL, RenderScreenshot loads your template in Chromium with the og_card preset, and the PNG comes back in the same response. No image files to manage, no browser on your servers.

<meta property="og:image" content="https://api.renderscreenshot.com/v1/screenshot?cache_available=true&amp;cache_ttl=2592000&amp;expires=...&amp;key_id=rs_pub_...&amp;preset=og_card&amp;url=https%3A%2F%2Fexample.com%2Fog%2Fhello-world%3Fv%3D1760745600&amp;signature=...">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="Hello world, a post on example.com">

The rest of this guide is the part most OG tutorials skip: the sizes each platform asks for, why the URL must not redirect, how to pick an expiry when signed URLs last 30 days at most, escaping, cache busting, and a full Next.js App Router setup. If you are still choosing a tool, start with the OG image API overview or the comparison of OG image generators.

Tested on 2026-10-18 with Node 22.18 on macOS (Apple M1 Pro), Chromium from [email protected] for the template checks, and Node's crypto for signing. The signing function reproduces the API's documented reference signature. The escaping helper, signer and expiry helper were run; the Next.js files and SDK listings were not run. No request in this guide was sent to the live API.

How the pieces fit

sequenceDiagram
    participant C as Crawler (Slack, LinkedIn, X)
    participant S as Your site
    participant RS as RenderScreenshot API
    participant CDN as R2 + CDN
    C->>S: GET /blog/hello-world
    S-->>C: HTML with og:image = signed URL
    C->>RS: GET /v1/screenshot?...&signature=...
    RS->>RS: verify signature, look up cache
    alt cached render inside TTL
        RS-->>C: PNG (0 credits)
    else no cached render
        RS->>S: load /og/hello-world?v=... in Chromium
        RS->>CDN: store PNG
        RS-->>C: PNG (1 credit)
    end

Three parts belong to you: the card template route, the code that signs the URL, and the meta tags. The API owns the browser, the cache and the CDN copy.

What each platform asks for

Every platform crops and caches differently, and only some publish numbers. These are the published requirements as of 2026-10-18; where a platform publishes nothing, the table says so instead of guessing.

Platform Tags it reads Published size guidance Max file size Cache behavior worth knowing
Facebook og:image, og:image:width/height At least 1200×630 recommended, 200×200 minimum, ratio close to 1.91:1 (Meta docs) 8 MB Caches by image URL. The first share may show no image until the crawler has seen it once; width and height tags let it render without downloading first.
LinkedIn og:image Minimum 1200×627, ratio 1.91:1 (LinkedIn help) 5 MB Re-scrape with Post Inspector after a change.
X twitter:card, twitter:image, falls back to og:image summary_large_image is a 2:1 card not re-checked for this guide Use the twitter_card preset (1200×600) for an exact 2:1 image.
Slack, Discord, iMessage, WhatsApp, Telegram og:image No public size spec found No public limit found Unfurls are cached per message; changing the image does not update old messages.

The practical rule: one 1200×630 image satisfies Facebook and LinkedIn and crops acceptably everywhere else. Add a second, 1200×600 twitter:image only if your design has text near the top or bottom edge that X's 2:1 crop would cut. The Twitter card glossary entry covers the card types.

Keep the important text inside a centred safe area of about 1000×500. Chat apps show the card small, and a 24 px footer that looks fine at full size becomes unreadable.

Step 1: the card template route

The renderer needs a public URL that returns a page exactly 1200×630 pixels in size. Make it a normal route in your app, not a static file, so it reads the same data as the article.

The template below is the one we tested. It uses CSS grid, -webkit-line-clamp and text-wrap: balance, which is the point of rendering with a real browser: you write the CSS you already know.

// lib/og.mjs
// Escape every value that comes from a request or the database before it goes into HTML.
export function escapeHtml(value) {
  return String(value)
    .replaceAll('&', '&amp;')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;')
    .replaceAll('"', '&quot;')
    .replaceAll("'", '&#39;');
}

export function cardHtml({ title, section, author }) {
  return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<style>
  * { box-sizing: border-box; }
  html, body { margin: 0; width: 1200px; height: 630px; }
  body {
    display: grid; grid-template-rows: auto 1fr auto; gap: 24px;
    padding: 72px 80px; background: #0f172a; color: #f8fafc;
    font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  }
  .section { font-size: 28px; color: #7dd3fc; letter-spacing: .04em; text-transform: uppercase; }
  h1 {
    margin: 0; align-self: center; font-size: 68px; line-height: 1.08; font-weight: 800;
    display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden;
    text-wrap: balance;
  }
  .meta { font-size: 28px; color: #cbd5e1; }
</style>
</head>
<body>
  <div class="section">${escapeHtml(section)}</div>
  <h1>${escapeHtml(title)}</h1>
  <div class="meta">${escapeHtml(author)}</div>
</body>
</html>`;
}

Why the escaping is not optional

The title comes from your CMS, and sometimes from users. A template string that drops it into HTML unescaped has two failure modes:

  • Broken cards. A post called "Why <b> tags break layouts" turns half its title bold, or swallows the rest of the line.
  • Script injection in the renderer. If the card route takes the title from the query string (/og?title=...), anyone can craft a link whose title contains a <script> tag. The renderer runs it with your template's origin. Even when the damage is limited to a defaced image under your domain's preview, that is your brand on someone else's joke.

We tested the helper with the title Why <b>bold</b> & "quoted" titles break naive OG templates. Chromium rendered the literal text, innerText of the h1 matched the input exactly, and the page contained no <b> element.

A safer design than escaping query values is to not take display text from the query at all. Let the route take a slug, look the post up, and render what the database says. Then a forged URL can only ever produce a card for a real post.

Fonts

system-ui resolves to whatever the rendering machine has. On a Mac that is San Francisco; on a Linux renderer it is something else. For a card that looks the same everywhere, load a web font in the template with a <link> to Google Fonts or a self-hosted @font-face, and keep font-display: block so the capture does not happen during a fallback flash. The guide to consistent fonts in headless rendering goes deeper, and the emoji rendering guide covers colour emoji.

Text that is too long

Titles vary from three words to thirty. Plan for both ends:

  • -webkit-line-clamp: 3 with overflow: hidden ends the third line with an ellipsis instead of overflowing the card.
  • text-wrap: balance stops a two-line title from leaving one word alone on line two.
  • For very short titles, a larger font through a container query or a simple length check in your template keeps the card from looking empty.

Step 2: sign the screenshot URL

The og:image value is fetched by crawlers you do not control, so it cannot carry your API key. A signed URL carries a public key ID (rs_pub_...), an expiry and an HMAC signature instead. Your secret key (rs_secret_...) stays on the server.

// lib/sign.mjs
import { createHmac } from 'node:crypto';

// Same encoding rules as the API's signature check: space -> "+", only A-Z a-z 0-9 - _ . ~ kept.
function encode(value) {
  return encodeURIComponent(String(value))
    .replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase())
    .replace(/%20/g, '+');
}

export function signedScreenshotUrl(params, { keyId, secret, expiresAt }) {
  const fields = { ...params, key_id: keyId, expires: Math.floor(expiresAt.getTime() / 1000) };
  const query = Object.entries(fields)
    .map(([k, v]) => `${encode(k)}=${encode(v)}`)
    .sort()
    .join('&');
  const signature = createHmac('sha256', secret).update(query).digest('hex');
  return `https://api.renderscreenshot.com/v1/screenshot?${query}&signature=${signature}`;
}

We checked this function against the reference vector on the signed URL docs page and got the documented signature. The Node SDK does the same with client.generateUrl(options, expiresAt); the hand-written version is shown because it lets you sign any flat GET parameter, including cache_available, without checking which options a given SDK version copies into the URL.

The parameters worth signing for an OG card:

Parameter Value Why
url your card route, with a version The page to render. The version busts caches when content changes.
preset og_card 1200×630 viewport, PNG output, 24-hour default cache
cache_available true A repeat fetch inside the TTL reuses the earlier render for 0 credits
cache_ttl 2592000 30 days, the maximum. A card only changes when its version changes.
format optional jpeg Override the preset's PNG when your design is photographic

Never append anything to a signed URL after signing it. A template that adds &format=webp later breaks the signature, and every crawler gets invalid_signature instead of an image.

Step 3: choose an expiry that suits crawlers

Signed URLs need an expiry, and the API rejects any expiry more than 30 days ahead. Crawlers, meanwhile, may fetch an image days after the page was cached, and some fetch again later. Two naive choices both go wrong:

  • "Now + 30 days" on every page render produces a different URL on every request. Facebook caches images by URL, so each re-scrape looks like a new image.
  • A fixed date baked in at build time works for a month, then every page built before that date serves an expired URL until the next deploy.

What we recommend is a bucketed expiry: round the start time down to the start of the week, then add 28 days. Every render in the same week (Monday to Sunday, UTC) signs the same URL, so the image URL is stable for a week at a time, and every URL handed out stays valid for at least 21 more days.

// lib/expiry.mjs
const WEEK = 7 * 24 * 60 * 60 * 1000;

// Monday 00:00 UTC of the current week, plus 28 days. Always <= 28 days ahead, never < 21.
export function bucketedExpiry(now = new Date()) {
  const day = (now.getUTCDay() + 6) % 7; // Monday = 0
  const monday = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - day);
  return new Date(monday + 4 * WEEK);
}

The cache key that decides whether a render is reused is built from the rendering options only; expires, key_id and signature are not rendering options. So when the week rolls over and the URL changes, the first fetch with the new URL still finds last week's render if it is inside the TTL. You pay for a render when the card changes, not when the week changes.

If a page is cached as static HTML for longer than three weeks, a signed URL is the wrong tool. Render the card once at deploy time with a Bearer-token request instead, and serve a file you own. The storage options can copy each render into your own S3-compatible bucket on paid plans (the path template must contain {hash} and {ext}).

Step 4: put the final URL in the tag, not a redirect

A common pattern is og:image = /api/og?slug=..., where that route answers with a 302 to the real image. It works when you test it in a browser. It is still worse than putting the signed URL in the tag:

  • Every crawler pays an extra round trip, and some unfurlers have tight time budgets for the whole preview.
  • Platforms cache by the URL they were given. A redirect hides the real image URL, so changes behind the redirect may not reach previews that were already cached.
  • Your own server is now in the path of every crawler fetch. If it is down or cold-starting, the preview fails.

The signed URL is a plain GET that returns image/png directly, so it can go into the tag as it is. In HTML attributes, write & as &amp;; template engines that escape attribute values do this for you.

The full tag set we use:

<meta property="og:image" content="{signed url}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="{plain-text description of the card}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="{signed url}">
<meta name="twitter:image:alt" content="{same description}">

Twitter tags use name=, Open Graph tags use property=. Mixing them up is the most common reason an X preview falls back to a small card.

The full Next.js App Router example

This setup has two files in your Next.js app: the card route and the metadata for the post page. It assumes Next.js 15 or later and the two helper modules above copied into lib/. For the built-in file convention and measured timings, see Next.js OG images: opengraph-image.tsx vs a route handler.

The card route returns the template as HTML. It takes a slug, not a title, so it can only render cards for real posts:

// app/og/[slug]/route.ts
import { cardHtml } from '@/lib/og.mjs';
import { getPost } from '@/lib/posts';

export async function GET(_req: Request, { params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) return new Response('Not found', { status: 404 });

  const html = cardHtml({ title: post.title, section: post.section, author: post.author });
  return new Response(html, {
    headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=300' },
  });
}

The post page signs the card URL inside generateMetadata, which runs on the server, so the secret key never reaches the browser:

// app/blog/[slug]/page.tsx (metadata part)
import type { Metadata } from 'next';
import { signedScreenshotUrl } from '@/lib/sign.mjs';
import { bucketedExpiry } from '@/lib/expiry.mjs';
import { getPost } from '@/lib/posts';

const SITE = 'https://example.com';

export async function generateMetadata(
  { params }: { params: Promise<{ slug: string }> },
): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) return {};

  const cardUrl = `${SITE}/og/${encodeURIComponent(slug)}?v=${post.updatedAt.getTime()}`;
  const image = signedScreenshotUrl(
    { url: cardUrl, preset: 'og_card', cache_available: 'true', cache_ttl: 2592000 },
    {
      keyId: process.env.RS_PUBLIC_KEY_ID!, // rs_pub_...
      secret: process.env.RS_SECRET_KEY!,   // rs_secret_..., server only
      expiresAt: bucketedExpiry(),
    },
  );
  const alt = `${post.title}, on example.com`;

  return {
    title: post.title,
    openGraph: { images: [{ url: image, width: 1200, height: 630, alt }] },
    twitter: { card: 'summary_large_image', images: [{ url: image, alt }] },
  };
}

Next.js writes the og:image:width, og:image:height and og:image:alt tags from that object. Because the template route lives in the same app, a post and its card always read the same data.

Two notes on this setup:

  • The card route must be reachable from the public internet. Preview deployments behind authentication will render a login page. Give the renderer a production URL, or pass credentials with a POST request in a deploy script instead.
  • If you statically generate pages, generateMetadata runs at build time and the bucketed expiry is frozen into the HTML. Rebuild or revalidate at least every three weeks, or switch to the stored-file approach from Step 3.

The same signing in Python and Ruby

The SDKs sign URLs too. Use them when you only need options they include in signed URLs; check the Python SDK and Ruby SDK pages for the exact list.

import os
from datetime import datetime, timedelta, timezone
from renderscreenshot import Client, TakeOptions

client = Client(
    os.environ['RS_API_KEY'],
    signing_key=os.environ['RS_SECRET_KEY'],      # rs_secret_...
    public_key_id=os.environ['RS_PUBLIC_KEY_ID'], # rs_pub_...
)

og_image = client.generate_url(
    TakeOptions.url('https://example.com/og/hello-world?v=1760745600').preset('og_card').cache_ttl(2592000),
    datetime.now(timezone.utc) + timedelta(days=21),
)
client = RenderScreenshot::Client.new(
  ENV.fetch('RS_API_KEY'),
  signing_key: ENV.fetch('RS_SECRET_KEY'),      # rs_secret_...
  public_key_id: ENV.fetch('RS_PUBLIC_KEY_ID')  # rs_pub_...
)

og_image = client.generate_url(
  RenderScreenshot::TakeOptions.url('https://example.com/og/hello-world?v=1760745600').preset('og_card').cache_ttl(2_592_000),
  expires_at: Time.now + (21 * 24 * 3600)
)

These two listings follow the SDK reference pages and were not executed for this guide. For Rails, remember that ERB escapes <%= %> output by default, which covers the escaping rule in Step 1. The Rails OG image walkthrough builds the ERB card route and tests.

Choosing the output format

The og_card preset outputs PNG. You can override it with format in the signed parameters. To see what the choice costs, we captured the template above (dark background, flat colour, large text) with local Chromium and compared the files:

Format Bytes Median capture time (9 runs)
PNG 45,433 17 ms
JPEG, quality 80 45,175 16 ms
WebP, quality 80 20,720 50 ms

Method: one 1200×630 page, [email protected] bundled Chromium, warm browser and page, Apple M1 Pro, 2026-10-18. These are local numbers that show the relative cost of each format for this design, not RenderScreenshot API latency.

What this means for cards:

  • For flat designs with text, PNG and JPEG land at about the same size, and PNG keeps text edges sharp. Stay on PNG.
  • For photographic backgrounds, JPEG at quality 80 is usually much smaller than PNG. Measure your own template; we did not test one here.
  • WebP was half the size for this card, but not every unfurler documents WebP support. Since every platform above accepts files of several megabytes, the saving buys little. Use PNG or JPEG for the widest support.

Cache busting when a post changes

Three caches stand between an edit and a fresh preview:

  1. The API cache. With cache_available=true, a request with the same rendering options inside the TTL returns the old render. The v= value in the card URL changes the target URL, which changes the cache key. Use the post's updated_at as the version so every edit gives a new key, and nothing else does.
  2. The platform's cache. Facebook and LinkedIn cache by image URL. A new v= value gives a new signed URL, but the platform still has to re-scrape the page to see it. Use the Facebook Sharing Debugger or LinkedIn Post Inspector after an important edit.
  3. Messages already posted. Slack and Discord unfurls are stored with the message. No change on your side updates them.

To remove old renders from the CDN as well, use the cache purge endpoint with a URL pattern such as https://example.com/og/*. The cache invalidation guide has purge-on-deploy patterns.

Warm the cache before the first share

Facebook's documentation says the first person to share a URL may see no image, because the crawler has not fetched it yet. You can reduce that window. When a post is published, fetch its signed og:image URL once from your server. That request renders the card and stores it. The first crawler fetch then reuses that render with cache_available=true instead of waiting for a fresh one. This costs one credit per published post, which you would spend on the first crawler fetch anyway.

Testing previews

  • Facebook: the Sharing Debugger shows what the crawler saw and lets you re-scrape.
  • LinkedIn: Post Inspector does the same.
  • X: X's Card Validator no longer shows a preview. Compose a post with the link (without sending it) to see the card.
  • Everything else: paste the link into a private Slack or Discord channel.
  • Your own check: curl -sS -o /dev/null -w '%{http_code} %{content_type}\n' "$SIGNED_URL" should print 200 image/png. This is a real render and uses a credit.

Troubleshooting

Symptom Likely cause Fix
Preview shows no image at all URL contains a raw & that the HTML parser cut, or the image request returns 401 Write &amp; in the attribute; curl the URL and read the error code
invalid_signature A parameter was added after signing, or the target URL was encoded twice Sign every parameter, send the exact string you signed, pass the raw target URL to the signer
expired_signature on old shares HTML with an old signed URL is still cached somewhere Use the bucketed expiry and refresh cached pages within three weeks, or serve stored files
invalid_expiration Expiry more than 30 days ahead, or sent in milliseconds Send Unix seconds and keep it within 30 days
Card shows a login page Card route is behind auth or a preview-deployment password Render a public production URL
Card shows the old title Same v= value, so the cached render was reused, or the platform cached the old URL Version the card URL with updated_at; re-scrape on the platform
Text is cut off at the bottom Long title overflows the fixed height Add -webkit-line-clamp, reduce font size for long titles
Boxes instead of emoji or CJK text The renderer has no font for those characters Load a web font with those glyphs; see the emoji guide
X shows a small card twitter:card missing, or set with property= instead of name= Use <meta name="twitter:card" content="summary_large_image">
Every crawler fetch uses a credit cache_available not signed into the URL Add cache_available=true to the signed parameters
Title contains &amp;lt; on the card Value was escaped twice, once in your code and once by the template engine Escape exactly once, at the point where the value enters HTML

When a screenshot API is the wrong tool

If your cards are simple flexbox layouts and you already deploy on Vercel, the built-in ImageResponse is free to start and fast. Our Vercel OG alternative page shows which CSS it does not support, with test results. If designers want to edit cards in a visual editor without touching code, a template tool such as Bannerbear fits better; see the Bannerbear alternative for the trade-offs.

For everything else, the HTML route plus one signed URL is the shortest path we know to cards that look like your site. Create a free account to get a signing key pair; 150 credits a month are enough to test a full site of cards.

Last tested 2026-10-18.

Related in this topic