Generate Open Graph images with an HTML template — from scratch

Every blog post, product page, and landing page you ship should have a unique og:image. It's the single biggest lever for social-share click-through rates. But nobody wants to design a new 1200×630 PNG in Figma every time.

This tutorial walks through the pattern we recommend: design your OG image as a small HTML template, and generate the PNG on-demand via a screenshot API. Cache aggressively so you're not re-rendering the same URL forever.

End result: adding a new OG image to your app takes 30 seconds and costs nothing at your traffic level.

1

Design your template in HTML

Pick your dimensions. The de-facto standard is 1200×630 — that's what Twitter, LinkedIn, and Slack render at. Facebook accepts anything but rescales aggressively; 1200×630 looks fine there too.

Build a static HTML page that reads the dynamic bits from URL params. Here's the entire template we use for the getsnap.dev blog itself (~2 KB):

<!DOCTYPE html>
<html>
<head>
  <style>
    body { margin: 0; width: 1200px; height: 630px;
           background: linear-gradient(135deg, #0b0b13, #1a1a2e);
           color: #fff;
           font-family: -apple-system, BlinkMacSystemFont, sans-serif;
           display: flex; flex-direction: column; padding: 80px; }
    .brand { font-size: 24px; opacity: 0.7; margin-bottom: auto; }
    .title { font-size: 64px; font-weight: 700; line-height: 1.1;
             margin-bottom: 24px; letter-spacing: -0.02em; }
    .meta  { font-size: 22px; opacity: 0.6; }
  </style>
</head>
<body>
  <div class="brand">getsnap.dev → Blog</div>
  <div class="title" id="title"></div>
  <div class="meta"  id="meta"></div>
  <script>
    const q = new URLSearchParams(location.search);
    document.getElementById('title').textContent = q.get('title') || 'Untitled';
    document.getElementById('meta').textContent  = q.get('meta')  || '';
  </script>
</body>
</html>

Deploy this template somewhere permanent. You can host it on Vercel, Netlify, GitHub Pages — anywhere that serves static HTML. Let's assume you deploy to https://myapp.com/og-template.

2

Wire it to the screenshot API

The /v1/screenshot endpoint captures any URL. Pass the template URL with the dynamic params in the querystring:

curl -X POST https://api.getsnap.dev/v1/screenshot \
  -H "X-API-Key: sk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://myapp.com/og-template?title=Ship+Fast&meta=Read+it+in+4+min",
    "format": "png",
    "viewport_width": 1200,
    "viewport_height": 630,
    "block_ads": false,
    "remove_popups": false
  }'

Response:

{
  "url": "https://getsnap-screenshots.fra1.cdn.digitaloceanspaces.com/screenshots/…/req_abc.png",
  "cached": false,
  "request_id": "req_abc",
  "response_time_ms": 1834
}

That's your OG image. Drop the CDN URL into your page's <meta property="og:image"> tag.

Why block_ads: false and remove_popups: false? Because your template doesn't have ads or popups. The default popup remover would still succeed (it just wouldn't remove anything), but it also delays capture by a few hundred ms to scan the DOM. Disabling for OG templates shaves ~500 ms per capture.

3

Cache aggressively

Every OG image URL is deterministic for a given title + meta. You don't want to re-generate it every time someone shares the page. Two layers:

Layer 1: getsnap.dev cache (free)

getsnap.dev keys its own cache by a hash of every parameter that would affect the output. So calling the same endpoint with the same body twice returns cached: true on the second call and doesn't consume quota. This layer is automatic — you just get it.

Every response includes:

X-Cache: HIT
X-Credits-Remaining: 4998
X-Response-Time: 12ms

12 ms round-trip for a cached response is fast enough for inline generation on any request path.

Layer 2: your CDN (cheaper still)

The response URL points at DigitalOcean Spaces with a 24-hour Cache-Control header, so once your first user shares the link, subsequent shares hit the DO edge cache without touching our API at all.

If you want to be extra careful about latency in specific regions, you can copy the image to your own CDN once and serve from there forever.

4

Next.js example (App Router)

Here's the entire integration for a Next.js blog. This goes in app/blog/[slug]/page.tsx:

import type { Metadata } from "next";
import GetSnap from "getsnap";

const snap = new GetSnap(process.env.GETSNAP_KEY!);

export async function generateMetadata({ params }: {
  params: { slug: string };
}): Promise<Metadata> {
  const post = await getPostBySlug(params.slug);
  const templateUrl =
    `https://myapp.com/og-template?title=${encodeURIComponent(post.title)}` +
    `&meta=${encodeURIComponent(post.readTime + ' min read')}`;

  const { url: ogImage } = await snap.screenshot({
    url: templateUrl,
    format: "png",
    viewport_width: 1200,
    viewport_height: 630,
    block_ads: false,
    remove_popups: false,
  });

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

This runs at build time (or on-demand-revalidation) depending on your Next.js config. The first build generates the image, subsequent builds use the cached URL, deploys never regenerate unless the post title changes.

5

Handle failures

Screenshot APIs are HTTP APIs. They can 500 or time out. Wrap the call with a fallback so you always ship a valid og:image:

let ogImage = "https://myapp.com/default-og.png";
try {
  const r = await snap.screenshot({ url: templateUrl, format: "png", viewport_width: 1200, viewport_height: 630 });
  ogImage = r.url;
} catch (err) {
  console.warn("OG generation failed, using default:", err);
}

Ship a permanent, hand-designed default OG image and fall back to it on any error. Social platforms never see broken image URLs.

Cost math

If you publish 5 blog posts per week and each gets 200 shares, you'll generate ~1,000 OG images / month worth of cache-warming and ~40,000 cache hits (which are free).

1,000 real captures / month fits comfortably in the getsnap.dev Free tier (100/mo) if you're just starting, or Starter ($9/mo, 5,000 captures) if you're publishing more or want the trial. Either way, dynamic OG images cost you literally nothing incremental.

Try it in 30 seconds

Grab a free API key and generate your first OG image inline in this browser.

Open the playground

Related reading