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 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 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 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
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 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
- 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/:
// 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>
)
}
- 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 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:
- 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.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
-
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/