Generating OG Images at Build Time with Satori
When you share a link on Twitter, Discord, or Slack, that little preview card is often the difference between someone clicking through or scrolling past. These previews are powered by Open Graph (OG) images - and most developers either ignore them entirely or use generic templates.
I wanted something different for this site: unique, branded images that match the brutalist aesthetic while being fully automated. Hereās how I built it.
01 // Why OG Images Matter
Open Graph protocol was created by Facebook in 2010 to control how URLs appear when shared. Today, itās universally supported:
- X uses
twitter:image(falls back toog:image) - Discord renders rich embeds from OG tags
- Slack unfurls links with preview images
- LinkedIn, iMessage, Telegram - all of them
The impact is measurable. Links with custom OG images see 2-3x higher click-through rates compared to those with generic favicons or no image at all. For a blog, thatās the difference between your post being read or buried.
<!-- The essential OG tags --><meta property="og:title" content="Your Post Title" /><meta property="og:description" content="A compelling excerpt..." /><meta property="og:image" content="https://yoursite.com/og/post.png" /><meta property="og:image:width" content="1200" /><meta property="og:image:height" content="630" />The standard dimensions are 1200x630 pixels - a 1.91:1 aspect ratio that displays well across all platforms.
02 // The Architecture
My approach generates all OG images at build time rather than on-demand. Hereās why:
| Approach | Pros | Cons |
|---|---|---|
| Build-time | Zero runtime cost, static hosting, CDN cached | Rebuild needed for changes |
| On-demand (Edge) | Dynamic content, no rebuild needed | Cold starts, compute costs, complexity |
| Manual | Full control over design | Doesn't scale, tedious |
For a blog with infrequent posts, build-time generation is the clear winner. The images are generated once, deployed to Cloudflare, and served instantly.
The stack:
- Satori - Converts React JSX to SVG (by Vercel)
- Resvg - Converts SVG to PNG (Rust-based, fast)
- gray-matter - Parses MDX frontmatter
- Custom ASCII generator - Procedural art seeded by post title
03 // The Template Design
Each OG image follows a consistent āfoiled cardā design with emerald accent ribbons:
The left panel contains procedurally generated ASCII art, while the right panel shows the post metadata. This creates visual interest while maintaining brand consistency.
04 // Satori: JSX to SVG
Satori is Vercelās library that renders React JSX to SVG. It doesnāt use a browser - it implements a subset of CSS Flexbox directly. This makes it incredibly fast but comes with constraints:
import satori from 'satori';
const svg = await satori( <div style={{ display: 'flex', width: '1200px', height: '630px', backgroundColor: '#09090b', fontFamily: 'JetBrains Mono', }} > <div style={{ fontSize: '48px', color: '#f4f4f5' }}> {title} </div> </div>, { width: 1200, height: 630, fonts: [ { name: 'JetBrains Mono', data: fontBuffer, // Must be TTF, not WOFF2 style: 'normal', weight: 400, }, ], });Key limitations to know:
- No CSS Grid - Flexbox only
- No pseudo-elements - No
::beforeor::after - TTF fonts only - WOFF2 not supported
- Explicit dimensions - Everything needs width/height
- No
gapshorthand - Use individualrowGap/columnGap
05 // Procedural ASCII Art
The most interesting part is the ASCII art generator. Rather than static images, I generate unique patterns seeded by the post title:
function hashString(str: string): number { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; } return Math.abs(hash);}
class SeededRandom { private seed: number;
constructor(seed: number) { this.seed = seed; }
next(): number { this.seed = (this.seed * 9301 + 49297) % 233280; return this.seed / 233280; }}This gives us deterministic randomness - the same title always produces the same pattern. With some vibe coding I have implemented 8 different pattern generators:
- Dense Matrix - Digital rain effect with binary streams
- Geometric Blocks - Overlapping rectangles with block characters
- Flowing Waves - Sine waves with
ā ā ~ ācharacters - Data Cascade - Vertical falling data streams
- Noise Field - Perlin-like noise with varying density
- Glitch Bands - Horizontal corruption bands
- Crosshatch - Intersecting diagonal lines
- Scatter Plot - Clustered point distributions
Much of it was inspired by shader powering background
The pattern is selected based on hash % 8, so each post gets a unique but reproducible visual:
export function generateAsciiArt( width: number, height: number, seed: string,): string[] { const hash = hashString(seed); const patternType = hash % 8;
switch (patternType) { case 0: return generateDenseMatrix(cols, rows, seed); case 1: return generateGeometricBlocks(cols, rows, seed); // ... etc }}06 // The Build Script
The generation script runs at build time, reading all MDX files and creating corresponding images:
The output goes to public/og/, making them available at /og/post-slug.png.
07 // Connecting to Astro
In my base layout, I wire up the OG tags with a fallback to the homepage image:
---interface Props { title: string; description?: string; ogImage?: string;}
const { title, description, ogImage } = Astro.props;const siteUrl = Astro.site?.origin || "https://uros.dev";const imageUrl = ogImage || `${siteUrl}/og/home.png`;---
<head> <meta property="og:title" content={title} /> <meta property="og:description" content={description} /> <meta property="og:image" content={imageUrl} /> <meta property="og:image:width" content="1200" /> <meta property="og:image:height" content="630" />
<!-- Twitter needs its own tags --> <meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:image" content={imageUrl} /></head>Blog posts pass their specific OG image:
<BaseLayout title={post.title} description={post.excerpt} ogImage={`${siteUrl}/og/${post.slug}.png`}/>08 // Testing Your OG Images
Before deploying, always test your images. These tools are invaluable:
- opengraph.xyz - Preview across all platforms
- Twitter Card Validator - Official Twitter testing tool
- Facebook Sharing Debugger - Clear Facebookās cache
- LinkedIn Post Inspector - Check LinkedIn rendering
Common issues to watch for:
- Image not updating - Platforms cache aggressively. Use cache-busting query params or their debug tools.
- Wrong dimensions - Some platforms crop differently. Test on each.
- Text cut off - Keep titles under ~60 characters for safe display.
09 // Performance Considerations
Since images are generated at build time:
- Build duration - Each image takes ~200-500ms. For 10 posts, thatās 2-5 seconds.
- Bundle size - PNGs are ~50-150KB each. Consider WebP if size is critical.
I still need to update the script to not regenerate all the images on every build - incremental generation by hashing frontmatter and comparing against previous builds is on my TODO list. For now, regenerating 5-10 images in a few seconds isnāt a big deal.
10 // The Result
Every post on this site now has a unique, branded preview image that:
- Includes procedural ASCII art seeded by the title
- Shows post metadata (tags, date, description)
- Works across all social platforms
The entire system is ~500 lines of TypeScript, runs in seconds, and requires zero manual intervention. Share any post from this blog and see for yourself.
If youāre building a blog or portfolio, investing time in OG images is one of the highest-ROI improvements you can make for discoverability. Automate it once, benefit forever.