How this project generates beautiful Open Graph images using Astro, Satori, and Sharp - from React components to PNG images.
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 flowReact Component → Satori (SVG) → Sharp (PNG) → BufferKey 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 previewdescription- Short description for social previewimage- 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 levelconst 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 constFor 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
styleprop - Available styles:
default,default-dark,particle
Theme Components (_components/)
og-default-theme.tsx- Light theme with borderog-default-dark-theme.tsx- Dark theme variantog-particle-theme.tsx- Animated particle background effectog-logo.tsx- Reusable logo componentog-particle.tsx- Particle animation componentutils.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 Postdescription: Post description for social mediaogStyle: '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
- Build time: Astro calls
getStaticPaths()in OG route handlers - Route handler loads content from collections (posts or ogImages)
- For each item, creates a static route (e.g.,
/og/posts/my-slug.png) - When accessed, the
GEThandler:- Retrieves post/page data by slug
- Calls
generateOgImage()with title, description, style - Returns PNG buffer with proper headers
- In page layouts,
OpenGraph.astrocomponent references the OG image URL - 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
- Create theme component in
src/components/og/_components/:
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> )}- 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}- Update style choices (
collection-definitions/_og-styles.tsif exists):
export const ogStyleChoices = z.enum([ 'default', 'default-dark', 'particle', 'gradient', // Add here])- Use in posts:
---title: My PostogStyle: '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 lengthgetTitleFontSizePx(title: string) => number
// Responsive description sizinggetDescriptionFontSizePx(titleSize: number) => numberThese 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:
- Prerendering:
export const prerender = true- All OG images generated at build time - PNG Compression: Sharp configured with maximum compression (level 9)
- Palette Optimization: Converts to palette-based PNG for smaller file sizes
- Adaptive Filtering: Smart compression based on image content
- 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.pngVerify 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
-
Astro + Satori + OG Image
- https://bepyan.me/en/post/astro-dynamic-og/
- https://skyfall.dev/posts/astro-og-with-satori/
- https://cai.im/blog/og-images-using-satori/
- https://cassidoo.co/post/og-image-gen-astro/
- https://astro-paper.pages.dev/posts/dynamic-og-image-generation-in-astropaper-blog-posts/
- https://knaap.dev/posts/dynamic-og-images-with-any-static-site-generator/