SKIP TO CONTENT
Uroš Karić
Back to all essays
ESSAY·TYPESCRIPT

Generating OG Images at Build Time with Satori

How I built a custom OG image generator with Satori, Resvg, and procedural ASCII art. Why social previews matter, and how to automate them.

UROŠ KARIĆ/DEC 8, 2025/6 MIN READ

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. The previews come from Open Graph (OG) images. Most developers either ignore them entirely or reach for a generic template.

I wanted something different for this site: unique, branded images that match the brutalist aesthetic, generated automatically. 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. Everything supports it now:

  • 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
ApproachProsCons
Build-timeZero runtime cost, static hosting, CDN cachedRebuild needed for changes
On-demand (Edge)Dynamic content, no rebuild neededCold starts, compute costs, complexity
ManualFull control over designDoesn't scale, tedious
3 rowsDATA

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 holds procedurally generated ASCII art. The right panel shows the post metadata. Every post looks different without going off-brand.

04 // Satori: JSX to SVG

Satori is Vercel’s library that renders React JSX to SVG. It skips the browser entirely and implements a subset of CSS Flexbox directly. Incredibly fast, but the constraints are real:

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;
}
}

Deterministic randomness. The same title always produces the same pattern. With some vibe coding I’ve 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. It reads every MDX file and creates the corresponding image.

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:

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 stop the script regenerating every image 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 whole system is ~500 lines of TypeScript. It 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 and you never think about it again.

XBLUESKY