February 19, 2026 · 9 min read

How to Build a Dynamic OG Image Service for Your SaaS

When someone shares a link to your SaaS on Twitter, Slack, or LinkedIn, what do they see? If every page shows the same generic preview image, you're leaving engagement on the table. Here's how to generate unique, dynamic OG images for every page, automatically.

Why Dynamic OG Images Matter

Static OG images are fine for your homepage. But SaaS products have hundreds or thousands of unique pages: user profiles, dashboards, reports, blog posts, documentation pages. Each one gets shared, and each one deserves a preview that reflects its content.

Companies like Vercel, GitHub, and Stripe all generate dynamic OG images. When you share a GitHub repo link, the preview shows the repo name, description, stars, and language breakdown. That's not a manually designed image. It's generated on the fly.

The results speak for themselves: tweets with rich previews get 2-3x more engagement than those with generic or missing images. For a SaaS, that means more signups from every share.

The Architecture

A dynamic OG image service has three components:

  1. HTML template - A styled template that accepts parameters (title, subtitle, avatar, stats)
  2. Screenshot API - Renders the HTML to a PNG image
  3. Caching layer - Stores generated images so you don't re-render on every request

The flow: when a crawler (Twitter, Slack, etc.) hits your og:image URL, your server checks the cache. If the image exists, serve it. If not, render the HTML template with the page's data, screenshot it, cache it, and return the PNG.

Step 1: Build the HTML Template

OG images are 1200x630 pixels. Your template should be a self-contained HTML page at that exact size. Here's a versatile starting point:

<!DOCTYPE html>
<html>
<head>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      width: 1200px; height: 630px;
      display: flex; flex-direction: column;
      justify-content: center; padding: 60px 80px;
      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
      font-family: 'Inter', system-ui, sans-serif;
      color: white;
    }
    .tag { font-size: 18px; text-transform: uppercase;
           letter-spacing: 2px; opacity: 0.8; margin-bottom: 20px; }
    .title { font-size: 56px; font-weight: 800;
             line-height: 1.15; margin-bottom: 24px; }
    .subtitle { font-size: 24px; opacity: 0.9; line-height: 1.5; }
    .footer { margin-top: auto; display: flex;
              align-items: center; gap: 16px; }
    .logo { width: 40px; height: 40px; border-radius: 8px;
            background: rgba(255,255,255,0.2); }
    .brand { font-size: 20px; font-weight: 600; }
  </style>
</head>
<body>
  <div class="tag">{{tag}}</div>
  <div class="title">{{title}}</div>
  <div class="subtitle">{{subtitle}}</div>
  <div class="footer">
    <img class="logo" src="{{logo_url}}" />
    <span class="brand">{{brand}}</span>
  </div>
</body>
</html>

The {{placeholder}} values get replaced server-side before screenshotting. You can use any templating engine (Handlebars, EJS, or plain string replacement).

Step 2: Screenshot the Template

You need a way to render HTML to an image. You can run headless Chrome yourself, but managing browser instances at scale is painful. A screenshot API handles the rendering, scaling, and reliability for you.

Using curl

The simplest approach: send your rendered HTML directly to the API.

# Render HTML to a 1200x630 OG image
curl "https://grabshot.dev/api/screenshot" \
  -H "X-API-Key: YOUR_API_KEY" \
  -d '{
    "html": "<!DOCTYPE html><html>...your template...</html>",
    "width": 1200,
    "height": 630,
    "format": "png"
  }' \
  --output og-image.png

Node.js Implementation

Here's a complete Express middleware that generates and caches OG images:

const express = require('express');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');

const app = express();
const CACHE_DIR = './og-cache';
const API_KEY = process.env.GRABSHOT_API_KEY;

// Ensure cache directory exists
fs.mkdirSync(CACHE_DIR, { recursive: true });

// OG image template
function renderTemplate({ tag, title, subtitle, brand }) {
  return `<!DOCTYPE html>
<html><head><style>
  * { margin:0; padding:0; box-sizing:border-box; }
  body { width:1200px; height:630px; display:flex;
         flex-direction:column; justify-content:center;
         padding:60px 80px;
         background:linear-gradient(135deg,#667eea,#764ba2);
         font-family:system-ui; color:white; }
  .tag { font-size:18px; text-transform:uppercase;
         letter-spacing:2px; opacity:.8; margin-bottom:20px; }
  .title { font-size:52px; font-weight:800; line-height:1.15;
           margin-bottom:24px; }
  .subtitle { font-size:22px; opacity:.9; }
  .brand { margin-top:auto; font-size:20px; font-weight:600; }
</style></head><body>
  <div class="tag">${tag}</div>
  <div class="title">${title}</div>
  <div class="subtitle">${subtitle}</div>
  <div class="brand">${brand}</div>
</body></html>`;
}

app.get('/og/:slug.png', async (req, res) => {
  const { slug } = req.params;

  // Check cache first
  const hash = crypto.createHash('md5').update(slug).digest('hex');
  const cachePath = path.join(CACHE_DIR, `${hash}.png`);

  if (fs.existsSync(cachePath)) {
    return res.sendFile(path.resolve(cachePath));
  }

  // Look up page data from your database
  const page = await getPageData(slug);
  if (!page) return res.status(404).send('Not found');

  // Render template
  const html = renderTemplate({
    tag: page.category,
    title: page.title,
    subtitle: page.description,
    brand: 'YourSaaS'
  });

  // Screenshot via GrabShot API
  const response = await fetch('https://grabshot.dev/api/screenshot', {
    method: 'POST',
    headers: {
      'X-API-Key': API_KEY,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      html, width: 1200, height: 630, format: 'png'
    })
  });

  const buffer = Buffer.from(await response.arrayBuffer());

  // Cache the result
  fs.writeFileSync(cachePath, buffer);

  res.set('Content-Type', 'image/png');
  res.set('Cache-Control', 'public, max-age=86400');
  res.send(buffer);
});

Python Implementation

Same concept with Flask:

import hashlib, os, requests
from flask import Flask, send_file, abort

app = Flask(__name__)
CACHE_DIR = "./og-cache"
API_KEY = os.environ["GRABSHOT_API_KEY"]
os.makedirs(CACHE_DIR, exist_ok=True)

def render_template(title, subtitle, tag="Article"):
    return f"""<!DOCTYPE html>
<html><head><style>
  body {{ width:1200px; height:630px; display:flex;
         flex-direction:column; justify-content:center;
         padding:60px 80px;
         background:linear-gradient(135deg,#667eea,#764ba2);
         font-family:system-ui; color:white; margin:0; }}
  .tag {{ font-size:18px; text-transform:uppercase;
          letter-spacing:2px; opacity:.8; margin-bottom:20px; }}
  .title {{ font-size:52px; font-weight:800; line-height:1.15;
            margin-bottom:24px; }}
  .sub {{ font-size:22px; opacity:.9; }}
</style></head><body>
  <div class="tag">{tag}</div>
  <div class="title">{title}</div>
  <div class="sub">{subtitle}</div>
</body></html>"""

@app.route("/og/<slug>.png")
def og_image(slug):
    # Check cache
    h = hashlib.md5(slug.encode()).hexdigest()
    cache_path = os.path.join(CACHE_DIR, f"{h}.png")

    if os.path.exists(cache_path):
        return send_file(cache_path, mimetype="image/png")

    # Fetch page data from your DB
    page = get_page_data(slug)
    if not page:
        abort(404)

    html = render_template(page["title"], page["description"])

    # Screenshot via GrabShot
    resp = requests.post(
        "https://grabshot.dev/api/screenshot",
        headers={"X-API-Key": API_KEY},
        json={"html": html, "width": 1200, "height": 630, "format": "png"}
    )

    with open(cache_path, "wb") as f:
        f.write(resp.content)

    return send_file(cache_path, mimetype="image/png")

Step 3: Wire Up Your Meta Tags

Point your OG meta tags to your new image endpoint. In your page's <head>:

<meta property="og:image" content="https://yourapp.com/og/{{slug}}.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://yourapp.com/og/{{slug}}.png">

Always include both og:image and twitter:image. Twitter uses its own tags and won't fall back to OG tags for images.

Step 4: Add Edge Caching

Social crawlers hit your OG image URL frequently. You don't want to generate a new screenshot every time. Beyond the file cache in the examples above, add a CDN layer:

A typical cache strategy: serve cached images for 24 hours. When content updates, invalidate the cache by changing the URL (add a version parameter: /og/my-page.png?v=2).

Template Design Tips

After generating thousands of OG images, here's what works:

Advanced: Multiple Templates

Most SaaS products need more than one template. A blog post looks different from a user profile or a pricing page. Structure your templates by content type:

// Template registry
const templates = {
  blog: ({ title, author, readTime }) => `...`,
  profile: ({ name, avatar, role, stats }) => `...`,
  docs: ({ title, section, version }) => `...`,
  changelog: ({ version, date, highlights }) => `...`
};

app.get('/og/:type/:slug.png', async (req, res) => {
  const { type, slug } = req.params;
  const template = templates[type];
  if (!template) return res.status(404).send('Unknown type');

  const data = await getPageData(type, slug);
  const html = template(data);
  // ... screenshot and cache as before
});

Cache Invalidation

The hardest part of any caching system. For OG images, here are practical strategies:

Cost at Scale

The math on dynamic OG images is favorable. Consider:

On GrabShot's Starter plan ($9/month for 2,000 screenshots), you can generate OG images for a 2,000-page site. The Pro plan ($29/month) covers 10,000. For most SaaS products, that's more than enough, since you only regenerate when content changes.

Testing Your OG Images

Before deploying, validate that your images render correctly across platforms:

Wrapping Up

Dynamic OG images turn every shared link into a branded, content-rich preview. The setup is straightforward: an HTML template, a screenshot API to render it, and a caching layer to keep things fast.

The investment pays off quickly. Better social previews mean more clicks, more shares, and more signups. And once the system is in place, it runs itself: every new page automatically gets a unique OG image.

Ready to start? Try GrabShot free and generate your first OG image in under a minute. Check out the API docs for the full HTML-to-image reference.

Published by the GrabShot team. We build APIs for screenshots, PDFs, and meta extraction.