Dynamic Open Graph Image Generation in This Digital Garden

How this project generates beautiful Open Graph images using Astro, Satori, and Sharp - from React components to PNG images.

Updated: December 19, 2025

Overview

This digital garden implements a sophisticated Open Graph (OG) image generation system that creates beautiful social media preview images dynamically. When you share a link to any post or page, platforms like Twitter, Facebook, and LinkedIn display a custom-generated preview image.

The system converts React components to SVG (using Satori), then to optimized PNG images (using Sharp) - all at build time.

Architecture

The OG image generation system consists of four main parts:

1. Image Generation Library (src/lib/generate-og-image.ts)

The core engine that orchestrates the conversion pipeline:

// Simplified flow
React Component → Satori (SVG) → Sharp (PNG) → Buffer

Key features:

  • Loads custom fonts (VictorMono) for consistent typography
  • Configures Satori with dimensions (1200x630 - the standard OG size)
  • Optimizes PNG output with Sharp (compression, adaptive filtering)
  • Returns image as Buffer for Astro API routes

Function signature:

generateOgImage(
  title: string,
  description: string,
  style: string = 'default'
) => Promise<Buffer>

2. SEO Header Component (src/components/seo/OpenGraph.astro)

Astro component that injects OpenGraph and Twitter Card meta tags into page <head>:

<meta property="og:image" content="{ogImage}" />
<meta property="twitter:card" content="summary_large_image" />

Props:

  • title - Page title for social preview
  • description - Short description for social preview
  • image - Path to OG image (defaults to fallback if not provided)

Usage in layouts:

<OpenGraph
  title="My Post Title"
  description="Post description"
  image="/og/posts/my-post.png"
/>

3. Dynamic Route Handlers (src/pages/og/)

Astro API routes that generate OG images at build time. This project has two implementations:

Simple Version: Single Collection (og/posts_/[...slug].png.ts)

Generates OG images for one collection only (posts):

// Load collection at module level
const posts = await getCollection('posts')

export async function getStaticPaths() {
  return posts.map((post) => ({
    params: { slug: post.id },
    props: post,
  }))
}

export async function GET(ctx: APIContext) {
  const post = posts.find((p) => p.id === ctx.params.slug)
  const ogImage = await generateOgImage(
    post.data.title,
    post.data.description || '',
    post.data.ogStyle || 'default',
  )
  return new Response(ogImage, {
    headers: { 'Content-Type': 'image/png' },
  })
}

URL Pattern: /og/posts_/my-post-slug.png

Pros: Simple, straightforward, minimal boilerplate Cons: Only works for one collection, requires duplication for additional content types

Dynamic Version: Multiple Collections (og/[articleType]/[...slug].png.ts)

Generates OG images for multiple collections (posts, notes, etc.):

const VALID_ARTICLE_TYPES = ['posts', 'notes'] as const

export async function getStaticPaths() {
  const paths = []
  for (const articleType of VALID_ARTICLE_TYPES) {
    const articles = await getCollection(articleType)
    for (const article of articles) {
      paths.push({
        params: { articleType, slug: article.id },
        props: { article, articleType },
      })
    }
  }
  return paths
}

export async function GET(ctx: APIContext) {
  const { articleType } = ctx.params
  
  // Validate article type
  if (!VALID_ARTICLE_TYPES.includes(articleType as ArticleType)) {
    return new Response('Invalid article type', { status: 400 })
  }
  
  const article = ctx.props.article
  const ogImage = await generateOgImage(
    article.data.title,
    article.data.description || '',
    article.data.ogStyle || 'default',
  )
  return new Response(ogImage, {
    headers: { 'Content-Type': 'image/png' },
  })
}

URL Pattern: /og/posts/my-post-slug.png, /og/notes/my-note-slug.png

Pros: Works with multiple collections, easy to extend (add to array), type-safe validation Cons: Slightly more complex, extra validation overhead

Adding new collections: Simply add to the VALID_ARTICLE_TYPES array:

const VALID_ARTICLE_TYPES = ['posts', 'notes', 'projects'] as const

For detailed comparison and usage guide, see _docs/how-og-image-generator-work.md.

4. OG Image Templates (src/components/og/)

React components that define the visual design of OG images:

Template Structure

Main Template (og-template.tsx)

  • Router component that selects theme based on style prop
  • Available styles: default, default-dark, particle

Theme Components (_components/)

  • og-default-theme.tsx - Light theme with border
  • og-default-dark-theme.tsx - Dark theme variant
  • og-particle-theme.tsx - Animated particle background effect
  • og-logo.tsx - Reusable logo component
  • og-particle.tsx - Particle animation component
  • utils.tsx - Text truncation and font sizing utilities

Design Constraints:

  • Must use inline styles (Satori doesn’t support CSS classes)
  • Dimensions: 1200x630px (OpenGraph standard)
  • Limited CSS support (subset of Flexbox)
  • No external images (must use SVG or inline data)

Example theme structure:

export function OgDefaultTheme({ title, description }) {
  return (
    <div
      style={{
        width: '100%',
        height: '100%',
        display: 'flex',
        flexDirection: 'column',
        backgroundColor: '#f7f8f9',
        padding: '80px',
      }}
    >
      <h1 style={{ fontSize: '72px' }}>{title}</h1>
      <p style={{ fontSize: '32px' }}>{description}</p>
    </div>
  )
}

5. Frontmatter Integration

Posts and notes can specify their OG style preference in frontmatter:

---
title: My Post
description: Post description for social media
ogStyle: 'particle' # or 'default', 'default-dark'
---

The route handler reads this field and passes it to generateOgImage(), allowing per-article customization of the OG image theme.

How It Works: Complete Flow

  1. Build time: Astro calls getStaticPaths() in OG route handlers
  2. Route handler loads content from collections (posts or ogImages)
  3. For each item, creates a static route (e.g., /og/posts/my-slug.png)
  4. When accessed, the GET handler:
    • Retrieves post/page data by slug
    • Calls generateOgImage() with title, description, style
    • Returns PNG buffer with proper headers
  5. In page layouts, OpenGraph.astro component references the OG image URL
  6. Social platforms fetch the image when the page is shared

Content Collections Integration

Posts Collection

OG images auto-generate for all posts. Each post gets:

  • URL: /og/posts/{post-id}.png
  • Data: Pulled from frontmatter (title, description, ogStyle)

OG Images Collection

Custom OG images for non-post pages defined in src/content/og-images.json:

[
  {
    "title": "My Digital Garden",
    "description": "A collection of interconnected notes and ideas",
    "slug": "home",
    "ogStyle": "particle"
  }
]

Schema (from collection-definitions/og-images.ts):

z.object({
  title: z.string(),
  description: z.string(),
  slug: z.string(),
  ogStyle: ogStyleChoices, // 'default' | 'default-dark' | 'particle'
})

Adding a New OG Theme

  1. Create theme component in src/components/og/_components/:
// og-gradient-theme.tsx
export function OgGradientTheme({ title, description }) {
  return (
    <div
      style={{
        background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
        // ... rest of your design
      }}
    >
      <h1>{title}</h1>
      <p>{description}</p>
    </div>
  )
}
  1. Register in template router (og-template.tsx):
import { OgGradientTheme } from './_components/og-gradient-theme'

const themeComponents = {
  default: OgDefaultTheme,
  'default-dark': OgDefaultDarkTheme,
  particle: OgParticleTheme,
  gradient: OgGradientTheme, // Add here
}
  1. Update style choices (collection-definitions/_og-styles.ts if exists):
export const ogStyleChoices = z.enum([
  'default',
  'default-dark',
  'particle',
  'gradient', // Add here
])
  1. Use in posts:
---
title: My Post
ogStyle: 'gradient'
---

Utilities & Helpers

The og/_components/utils.tsx provides text formatting utilities:

// Smart title truncation (max 60 chars)
truncateTitle(title: string) => string

// Description truncation (max 120 chars)
truncateDescription(desc: string) => string

// Dynamic font sizing based on title length
getTitleFontSizePx(title: string) => number

// Responsive description sizing
getDescriptionFontSizePx(titleSize: number) => number

These ensure text always fits within the 1200x630 canvas.

Technical Stack

  • Satori - Converts React JSX to SVG (works with limited HTML/CSS subset)
  • Sharp - High-performance image processing (SVG → PNG optimization)
  • VictorMono - Custom monospace font loaded from src/assets/fonts/
  • Astro API Routes - Server-side image generation with static prerendering

Performance Optimization

The system includes several optimizations:

  1. Prerendering: export const prerender = true - All OG images generated at build time
  2. PNG Compression: Sharp configured with maximum compression (level 9)
  3. Palette Optimization: Converts to palette-based PNG for smaller file sizes
  4. Adaptive Filtering: Smart compression based on image content
  5. Font Loading: Fonts loaded once at build time, cached in memory

Testing OG Images

Local development:

npm run dev
# Visit: http://localhost:4321/og/posts/your-slug.png

Verify social previews:

Common Issues & Solutions

Image not updating after changes:

  • Clear build cache: rm -rf dist/
  • Rebuild: npm run build

Fonts not rendering:

  • Verify font file exists at src/assets/fonts/VictorMono-Regular.woff
  • Check font loading in generate-og-image.ts

Text overflow:

  • Use utility functions: truncateTitle(), truncateDescription()
  • Test with long titles/descriptions

Satori errors:

  • Ensure only inline styles (no CSS classes)
  • Use supported CSS properties (mostly Flexbox)
  • Avoid external images

Future Enhancements

Potential improvements to consider:

  • Add more theme variations
  • Support custom fonts per theme
  • Add date/author information to templates
  • Implement tag-based color schemes
  • Generate multiple sizes (Twitter, Facebook, LinkedIn optimized)
  • Add watermarks or branding elements
  • Cache-busting for updated images

References