#TYPESCRIPT#BUILD-TOOLS#SEO

Generating OG Images at Build Time with Satori

Dec 8, 2025
6 min read Read
ID: GENERATING-OG-IMAGES-AT-BUILD-TIME

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 to og: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:

Generation Approaches
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
3 rows DATA

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:

OG Image Template Layout
// OG template structure with emerald ribbons and two-panel layout

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 ::before or ::after
  • TTF fonts only - WOFF2 not supported
  • Explicit dimensions - Everything needs width/height
  • No gap shorthand - Use individual rowGap/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:

  1. Dense Matrix - Digital rain effect with binary streams
  2. Geometric Blocks - Overlapping rectangles with block characters
  3. Flowing Waves - Sine waves with ═ ─ ~ ā‰ˆ characters
  4. Data Cascade - Vertical falling data streams
  5. Noise Field - Perlin-like noise with varying density
  6. Glitch Bands - Horizontal corruption bands
  7. Crosshatch - Intersecting diagonal lines
  8. 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:

Common issues to watch for:

  1. Image not updating - Platforms cache aggressively. Use cache-busting query params or their debug tools.
  2. Wrong dimensions - Some platforms crop differently. Test on each.
  3. 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.

Notes

* Published Dec 8, 2025. Last verified working as of this date.

* Code samples are MIT licensed unless otherwise noted.

// END_OF_FILE