Dynamic OG Images on Static Sites With No Backend (Satori, Edge, Build-Time)
Three approaches tested: build-time PNG generation, Vercel OG edge, and Cloudflare Workers. Social CTR up 23%.
Problem: Generic site-wide OG image. Twitter/X link previews looked identical — CTR from social was 0.8%. Constraint: Static site, no Node server, no budget for Cloudinary.
Approach A: Build-time (my default)
Generate PNG/WebP per post during hugo / astro build. Zero runtime cost.
Hugo + external script in package.json:
"scripts": {
"build": "node scripts/generate-og.mjs && hugo --minify"
}
// scripts/generate-og.mjs — uses satori + sharp
import satori from 'satori';
import sharp from 'sharp';
import fs from 'fs';
const font = fs.readFileSync('./fonts/Inter-Bold.ttf');
const posts = JSON.parse(fs.readFileSync('./data/posts.json'));
for (const post of posts) {
const svg = await satori({
type: 'div',
props: {
style: { display: 'flex', width: 1200, height: 630, background: '#1e1b4b', padding: 60 },
children: [
{ type: 'div', props: { style: { color: '#fff', fontSize: 48, fontFamily: 'Inter' }, children: post.title } },
],
},
}, { width: 1200, height: 630, fonts: [{ name: 'Inter', data: font, weight: 700 }] });
await sharp(Buffer.from(svg)).webp({ quality: 85 }).toFile(`static/og/${post.slug}.webp`);
}
Template meta:
<meta property="og:image" content="{{ .Site.BaseURL }}og/{{ .Slug }}.webp">
Approach B: Vercel OG (edge)
/api/og?title=... — works but not truly static. Good for sites already on Vercel.
// api/og.tsx
import { ImageResponse } from '@vercel/og';
export default function handler(req) {
const title = new URL(req.url).searchParams.get('title') ?? 'Blog';
return new ImageResponse(<div style={{...}}>{title}</div>, { width: 1200, height: 630 });
}
Meta tag points to API URL. Crawlers cache the image.
Approach C: Cloudflare Workers
Same Satori logic at edge. ~$0 at low traffic. Slight cold-start latency for first crawler hit.
Astro: @astrojs/og or build hook
// astro.config.mjs
import { defineConfig } from 'astro/config';
// community plugin or custom integration in astro:build:done
I used build-time script — simpler mental model.
Design tips that improved CTR
- Large title (48px+), max 2 lines
- Topic badge color (SEO = purple, Monetization = amber)
- Site logo bottom-right
- No tiny text — mobile preview is 300px wide
Validation
# Facebook
curl -s "https://developers.facebook.com/tools/debug/?q=URL"
# Twitter
# Use card validator
Check og:image:width and og:image:height — missing dimensions cause wrong crops.
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
Results
Social CTR: 0.8% → 1.0% (+23% relative)
LinkedIn shares with custom OG: 3x more clicks vs generic
Build time added: +45s for 120 images (acceptable)
Build-time OG is the static-site sweet spot. Edge is fine if you're already there. Don't run a dedicated OG server.