Why Dynamic OG Images Matter

When someone shares a link on Twitter/X, LinkedIn, Slack, or iMessage, the platform fetches the og:image meta tag and renders it as a preview card. A static image for all pages works, but a dynamic image — one that shows the specific article title, author, category, or product data — increases click-through rates significantly.

The standard OG image size is 1200×630px. Twitter/X and LinkedIn both use this ratio. Generate at 2× (2400×1260) for retina clarity, then serve at 1200×630.

Approach 1: Next.js ImageResponse (Edge Runtime)

The modern choice for Next.js apps. ImageResponse from next/og renders JSX to a PNG using Satori under the hood — no browser needed, runs in the Edge Runtime with near-zero cold starts:

// app/api/og/route.tsx
import { ImageResponse } from 'next/og';

export const runtime = 'edge'; // runs on Vercel Edge, fast globally

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const title = searchParams.get('title') ?? 'My Blog';
  const author = searchParams.get('author') ?? 'Anonymous';
  const category = searchParams.get('category') ?? 'Article';

  return new ImageResponse(
    (
      
myblog.com

{title}

{author} {category}
), { width: 1200, height: 630, } ); }

Then reference in your page metadata:

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';

export async function generateMetadata({ params }): Promise {
  const post = await getPost(params.slug);
  const ogUrl = new URL('/api/og', 'https://yourdomain.com');
  ogUrl.searchParams.set('title', post.title);
  ogUrl.searchParams.set('author', post.author);
  ogUrl.searchParams.set('category', post.category);

  return {
    openGraph: {
      images: [{ url: ogUrl.toString(), width: 1200, height: 630 }],
    },
  };
}
⚠️ Satori/ImageResponse CSS limitations

Satori doesn't support all CSS — no background-image: url() from external URLs, no CSS variables, no gradients on text, limited flexbox support. For complex designs (gradients, images, advanced typography), you'll need a full browser renderer.

Approach 2: Vercel OG + Custom Fonts

Loading custom fonts in Edge ImageResponse requires fetching the font as ArrayBuffer:

import { ImageResponse } from 'next/og';

export const runtime = 'edge';

// Cache font fetch — runs once per Edge worker lifetime
const fontPromise = fetch(
  new URL('../../../public/fonts/Inter-Bold.woff', import.meta.url)
).then(res => res.arrayBuffer());

export async function GET(request: Request) {
  const font = await fontPromise;
  const { searchParams } = new URL(request.url);

  return new ImageResponse(
    
{searchParams.get('title')}
, { width: 1200, height: 630, fonts: [{ name: 'Inter', data: font, weight: 700 }], } ); }
💡 Host fonts in /public, not via Google Fonts CDN

Google Fonts CDN URLs are often blocked in Edge environments due to network policy or timeout constraints. Download the woff/woff2 files and serve them from your own /public directory for reliable font loading.

Approach 3: Puppeteer/Playwright HTML Rendering

For full CSS support — gradients, images, complex layouts, web fonts — render actual HTML with a browser:

// og-image-service.js — Express microservice
const express = require('express');
const { chromium } = require('playwright');

const app = express();
let browser;

async function getBrowser() {
  if (!browser) browser = await chromium.launch({ args: ['--no-sandbox'] });
  return browser;
}

function buildTemplate({ title, author, avatar, category }) {
  return \`
  
    
      
      
    
    
      
      

\${title}

\`; } app.get('/og', async (req, res) => { const browser = await getBrowser(); const page = await browser.newPage(); try { await page.setViewportSize({ width: 1200, height: 630 }); await page.setContent(buildTemplate(req.query), { waitUntil: 'networkidle' }); await page.evaluate(() => document.fonts.ready); const buf = await page.screenshot({ type: 'png' }); res.set({ 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=604800' }); res.send(buf); } finally { await page.close(); } }); app.listen(3001);

Approach 4: SnapAPI (No Browser Infrastructure)

For teams that want full HTML/CSS rendering fidelity without managing browser infrastructure — especially useful for serverless apps or non-Next.js backends:

// Works anywhere — Express, Fastify, Lambda, Deno, Bun
async function generateOgImage({ title, author, category, slug }) {
  const html = \`
  
    
      
      
    
    
      
myblog.com

\${title}

by \${author} \${category}
\`; const res = await fetch('https://api.snapapi.pics/v1/screenshot', { method: 'POST', headers: { 'X-Api-Key': process.env.SNAPAPI_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ html, // raw HTML — no URL hosting needed viewport_width: 1200, viewport_height: 630, format: 'png', full_page: false, wait_until: 'networkidle', // waits for Google Fonts to load }), }); return Buffer.from(await res.arrayBuffer()); } // Express endpoint with Redis caching import { createClient } from 'redis'; import crypto from 'crypto'; const redis = createClient({ url: process.env.REDIS_URL }); await redis.connect(); app.get('/og/:slug', async (req, res) => { const post = await db.getPost(req.params.slug); const cacheKey = 'og:' + crypto.createHash('sha256').update(JSON.stringify(post)).digest('hex').slice(0, 16); let buf = await redis.getBuffer(cacheKey); if (!buf) { buf = await generateOgImage(post); await redis.setEx(cacheKey, 7 * 24 * 3600, buf); // 7-day cache } res.set({ 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=604800' }); res.send(buf); });

Approach Comparison

ApproachCSS supportCold startInfrastructureBest for
Next.js ImageResponse Limited (Satori) ~0ms (Edge) Zero Simple layouts, Next.js apps
Vercel OG Limited (Satori) ~0ms (Edge) Zero Vercel-deployed apps
Puppeteer/Playwright Full CSS ~1.5-2s Browser server required Complex designs, self-hosted
SnapAPI Full CSS + web fonts ~440ms Zero (REST API) Full CSS without browser infra

Performance & Caching Strategy

OG images don't change frequently — the same post title/author combination always produces the same image. Cache aggressively:

  1. CDN caching — set Cache-Control: public, max-age=604800 on the response. Cloudflare/Vercel CDN will serve cached versions globally
  2. Redis caching — cache the generated PNG buffer server-side so you don't regenerate on every CDN miss
  3. Cache key — use a hash of the template parameters (title, author, etc.) as the key
  4. Pre-generation — for known pages (blog posts, product pages), generate OG images at build time or publish time, not on first request

Which Approach to Choose

If you're building a Next.js app and your OG image design is relatively simple (text over gradient, no complex images or web font rendering), Next.js ImageResponse is the fastest to implement with zero infrastructure overhead.

For complex designs requiring full CSS fidelity — gradients on images, complex typography, background images, CSS grid layouts — you need a full browser renderer. SnapAPI gives you that without managing browser infrastructure, at ~440ms latency with a Redis cache making subsequent requests instant.

Get started: 200 free calls/month at snapapi.pics.