NNicheStackTutorials
← All tutorials
SEO & Web Vitals

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%.

·13 min read·SEO & Web Vitals
Dynamic OG Images on Static Sites With No Backend (Satori, Edge, Build-Time) cover

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.

Tags: Open GraphSatoriSocial SEOAstroHugo