The Problem
Mika leads marketing at a 20-person content platform with 5,000 articles. When articles are shared on Twitter, LinkedIn, or Slack, they show a generic logo instead of a compelling preview image. Design creates custom OG images for 5 high-profile articles per week — the other 100+ get nothing. Creating images manually takes 15 minutes each. They need a system that auto-generates branded OG images for every page, with the article title, author photo, and category — all dynamically rendered.
Step 1: Build the OG Image Service
typescript
// src/og/generator.ts — Dynamic OG image generation with HTML templates and caching
import puppeteer, { Browser } from "puppeteer";
import { Redis } from "ioredis";
import { createHash } from "node:crypto";
const redis = new Redis(process.env.REDIS_URL!);
let browser: Browser | null = null;
const OG_WIDTH = 1200;
const OG_HEIGHT = 630;
interface OGImageOptions {
template: "article" | "product" | "profile" | "event";
title: string;
subtitle?: string;
authorName?: string;
authorAvatar?: string;
category?: string;
backgroundImage?: string;
brandColor?: string;
logo?: string;
date?: string;
price?: string;
rating?: number;
}
// Generate OG image
export async function generateOGImage(options: OGImageOptions): Promise<Buffer> {
// Check cache
const cacheKey = `og:${createHash("md5").update(JSON.stringify(options)).digest("hex")}`;
const cached = await redis.getBuffer(cacheKey);
if (cached) return cached;
// Get or create browser
if (!browser) {
browser = await puppeteer.launch({
headless: true,
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu"],
});
}
const html = renderTemplate(options);
const page = await browser.newPage();
try {
await page.setViewport({ width: OG_WIDTH, height: OG_HEIGHT });
await page.setContent(html, { waitUntil: "networkidle0" });
const imageBuffer = await page.screenshot({ type: "png" }) as Buffer;
// Cache for 7 days
await redis.setex(cacheKey, 86400 * 7, imageBuffer);
return imageBuffer;
} finally {
await page.close();
}
}
function renderTemplate(options: OGImageOptions): string {
const brandColor = options.brandColor || "#6366f1";
const logo = options.logo || "";
switch (options.template) {
case "article":
return `<!DOCTYPE html>
<html>
<head><style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: ${OG_WIDTH}px; height: ${OG_HEIGHT}px;
font-family: 'Inter', -apple-system, sans-serif;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
display: flex; flex-direction: column; justify-content: space-between;
padding: 60px 80px;
color: white;
overflow: hidden;
}
.top { display: flex; justify-content: space-between; align-items: center; }
.category {
background: ${brandColor}; color: white;
padding: 8px 20px; border-radius: 20px;
font-size: 18px; font-weight: 600; text-transform: uppercase;
letter-spacing: 1px;
}
.logo { height: 40px; }
.title {
font-size: ${options.title.length > 60 ? "42px" : options.title.length > 40 ? "52px" : "60px"};
font-weight: 800; line-height: 1.15;
max-height: 280px; overflow: hidden;
background: linear-gradient(90deg, #fff, #e2e8f0);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.bottom { display: flex; align-items: center; gap: 20px; }
.avatar {
width: 56px; height: 56px; border-radius: 50%;
border: 3px solid ${brandColor}; object-fit: cover;
}
.author-info { display: flex; flex-direction: column; }
.author-name { font-size: 20px; font-weight: 600; }
.date { font-size: 16px; color: #94a3b8; }
.accent-bar {
position: absolute; bottom: 0; left: 0; right: 0;
height: 6px; background: ${brandColor};
}
</style></head>
<body>
<div class="top">
${options.category ? `<span class="category">${escapeHtml(options.category)}</span>` : "<span></span>"}
${logo ? `<img class="logo" src="${logo}" />` : ""}
</div>
<div class="title">${escapeHtml(options.title)}</div>
<div class="bottom">
${options.authorAvatar ? `<img class="avatar" src="${options.authorAvatar}" />` : ""}
<div class="author-info">
${options.authorName ? `<span class="author-name">${escapeHtml(options.authorName)}</span>` : ""}
${options.date ? `<span class="date">${escapeHtml(options.date)}</span>` : ""}
</div>
</div>
<div class="accent-bar"></div>
</body>
</html>`;
case "product":
return `<!DOCTYPE html>
<html>
<head><style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: ${OG_WIDTH}px; height: ${OG_HEIGHT}px;
font-family: 'Inter', sans-serif;
background: white;
display: flex; overflow: hidden;
}
.left { flex: 1; padding: 60px; display: flex; flex-direction: column; justify-content: center; }
.right { width: 400px; background: #f8fafc; display: flex; align-items: center; justify-content: center; }
.right img { max-width: 300px; max-height: 400px; object-fit: contain; }
.title { font-size: 44px; font-weight: 800; color: #0f172a; line-height: 1.2; margin-bottom: 20px; }
.subtitle { font-size: 22px; color: #64748b; margin-bottom: 30px; }
.price { font-size: 36px; font-weight: 700; color: ${brandColor}; }
.rating { font-size: 24px; color: #f59e0b; margin-top: 10px; }
</style></head>
<body>
<div class="left">
<div class="title">${escapeHtml(options.title)}</div>
${options.subtitle ? `<div class="subtitle">${escapeHtml(options.subtitle)}</div>` : ""}
${options.price ? `<div class="price">${escapeHtml(options.price)}</div>` : ""}
${options.rating ? `<div class="rating">${"★".repeat(Math.round(options.rating))}${"☆".repeat(5 - Math.round(options.rating))}</div>` : ""}
</div>
<div class="right">
${options.backgroundImage ? `<img src="${options.backgroundImage}" />` : ""}
</div>
</body>
</html>`;
default:
return `<html><body style="width:${OG_WIDTH}px;height:${OG_HEIGHT}px;display:flex;align-items:center;justify-content:center;font-family:sans-serif;background:#0f172a;color:white;font-size:48px;font-weight:bold;padding:60px;">${escapeHtml(options.title)}</body></html>`;
}
}
function escapeHtml(str: string): string {
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
}
// HTTP endpoint
import { Hono } from "hono";
const app = new Hono();
app.get("/og/:template", async (c) => {
const template = c.req.param("template") as OGImageOptions["template"];
const title = c.req.query("title") || "Untitled";
const image = await generateOGImage({
template,
title: decodeURIComponent(title),
subtitle: c.req.query("subtitle"),
authorName: c.req.query("author"),
authorAvatar: c.req.query("avatar"),
category: c.req.query("category"),
brandColor: c.req.query("color"),
date: c.req.query("date"),
price: c.req.query("price"),
rating: c.req.query("rating") ? parseFloat(c.req.query("rating")!) : undefined,
});
return c.body(image, {
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=604800",
},
});
});
export default app;
Results
- Every page has a branded social preview — 5,000 articles generate OG images on demand; sharing on Twitter/LinkedIn looks professional with title, author, and category
- 15 minutes per image → 0 manual work — images are generated automatically from the page metadata; design team focuses on higher-value work
- Click-through rate on shared links up 35% — branded previews with compelling titles stand out in social feeds; generic logos are invisible
- 7-day cache eliminates repeated generation — same URL parameters return cached PNG; Redis cache means sub-millisecond response for repeat requests
- Template system scales to any content type — article, product, profile, and event templates; adding a new template is 50 lines of HTML/CSS