Dynamic Open Graph Image Generation in This Digital Garden

A simple example of generating Open Graph images in Astro with Satori and Sharp — from a React component to a PNG at build time.

Updated: 2 October 2026Written with AI assistance

Overview

When you share a link on LINE, X, Facebook or Slack, the platform shows a preview image: the Open Graph (OG) image. This site generates one for every post at build time, from a React component:

React component → Satori (SVG) → Sharp (PNG) → /og/posts/<id>.png

This post walks through the smallest version that works, then shows how this site organises the same idea.

A simple example

Three pieces: a function that renders the image, a route that emits one PNG per post, and a <meta> tag that points to it.

1. Render a PNG

src/lib/render-og-image.tsx
import satori from 'satori'
import sharp from 'sharp'
import fs from 'node:fs/promises'
const font = await fs.readFile('./src/assets/fonts/VictorMono-Regular.ttf')
export async function renderOgImage(title: string, description: string) {
const svg = await satori(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
padding: '80px',
backgroundColor: '#f7f8f9',
}}
>
<h1 style={{ fontSize: '64px' }}>{title}</h1>
<p style={{ fontSize: '32px' }}>{description}</p>
</div>,
{
width: 1200,
height: 630,
fonts: [{ name: 'VictorMono', data: font, weight: 400 }],
},
)
return sharp(Buffer.from(svg)).png().toBuffer()
}

Satori only understands inline styles and a subset of Flexbox. No CSS classes, no Tailwind.

2. One route, one PNG per post

src/pages/og/posts/[...slug].png.ts
import type { APIContext } from 'astro'
import { getCollection } from 'astro:content'
import { renderOgImage } from '@/lib/render-og-image'
export const prerender = true
export async function getStaticPaths() {
const posts = await getCollection('posts')
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
}))
}
export async function GET({ props }: APIContext) {
const { title, description = '' } = props.post.data
const png = await renderOgImage(title, description)
return new Response(png, { headers: { 'Content-Type': 'image/png' } })
}

prerender = true makes Astro call GET once per path at build time and write the PNGs into dist/, so there is no image work at request time.

3. Point the page at it

src/pages/posts/[...slug].astro
---
const ogImage = new URL(`og/posts/${Astro.params.slug}.png`, Astro.site)
---
<meta property="og:image" content={ogImage} />
<meta property="twitter:card" content="summary_large_image" />

That’s all it takes. Run bun run dev and open http://localhost:4321/og/posts/<your-post>.png to see the image.

How this site does it

The simple example has two weak spots once a site grows:

  • Pages build the image URL themselves. If a page asks for a URL that no route generates (a typo in a slug), the build still passes and the preview is silently missing.
  • Styles are strings. Pick a style name that has no theme and you quietly get the default.

So this site puts everything behind one module, src/lib/og-image/, and two prerendered routes:

flowchart LR
subgraph sources["Sources"]
  fm["posts / notes<br/>frontmatter"]
  json["src/content/og-images.json<br/>(ogImages collection)"]
end

subgraph routes["Prerendered routes (build time)"]
ar["og/[articleType]/[...slug].png.ts<br/>one path per post and note"]
pr["og/pages/[...slug].png.ts<br/>one path per og-images.json entry"]
end

subgraph lib["src/lib/og-image"]
resp["ogImageResponse()<br/>fills {{siteTitle}}"]
render["renderOgImage()<br/>render.ts"]
end

tpl["OgImageTemplate<br/>Record#lt;OgStyle, Theme#gt;"]
satori["satori<br/>JSX → SVG<br/>VictorMono 300/400/700"]
sharp["sharp<br/>SVG → palette PNG"]
png[("dist/og/<br/>posts | notes | pages<br/>/#lt;slug#gt;.png")]

fm --> ar
json --> pr
ar --> resp
pr --> resp
resp --> render
render --> tpl --> satori --> sharp --> png
Drag to pan · Ctrl/⌘ + scroll or pinch to zoom

Rendering

  • Two routes, one renderer. og/[articleType]/[...slug].png.ts loops over OG_ARTICLE_COLLECTIONS (posts, notes) and emits /og/<collection>/<id>.png from each entry’s frontmatter. og/pages/[...slug].png.ts emits /og/pages/<slug>.png for every entry in src/content/og-images.json. Both are prerender = true and just return ogImageResponse(entry.data).
  • ogImageResponse() replaces {{siteTitle}} in the title with site.siteTitle, renders the PNG and wraps it in a Response with Content-Type: image/png.
  • renderOgImage() (render.ts) loads the three VictorMono weights (300, 400, 700) once at module level, picks the theme for the style from OgImageTemplate, runs Satori at 1200×630, then Sharp writes a palette-based PNG at compression level 9 to keep the files small.

The content looks like this:

  • Posts and notes take title, description and style from frontmatter:

    ---
    title: My Post
    description: Post description for social media
    ogStyle: 'particle' # 'default' | 'default-dark' | 'particle'; defaults to 'default'
    ---
  • Ordinary pages (home, resume, uses) get one entry each in src/content/og-images.json, with their own title, description and style:

    {
    "title": "{{siteTitle}}",
    "description": "A collection of thoughts, ideas, and knowledge",
    "slug": "index",
    "ogStyle": "default-dark"
    }
  • Styles are typed. The style list is a Zod enum in _og-styles.ts, and the theme map in og-template.tsx is Record<OgStyle, Theme>. Add a style without a theme and TypeScript refuses to compile. Put an unknown ogStyle in frontmatter or JSON and the content schema rejects it.

Linking a page to its image

Pages never write og/... paths themselves. They ask the module for a URL, and the layout turns it into meta tags:

flowchart LR
idx["index.astro<br/>resume.astro<br/>uses.astro"] -- "pageOgImage(slug)" --> check{"slug in<br/>og-images.json?"}
check -- no --> fail["build fails<br/>with the known slugs"]
check -- yes --> url["absolute URL<br/>og/pages/#lt;slug#gt;.png"]
post["posts/[...slug]/index.astro"] -- "articleOgImage('posts', id)" --> url2["absolute URL<br/>og/posts/#lt;id#gt;.png"]
url --> layout["BaseLayout<br/>ogImage prop"]
url2 --> layout
layout --> meta["OpenGraph.astro<br/>og:image + twitter:image<br/>(fallback: lucy.jpg)"]
Drag to pan · Ctrl/⌘ + scroll or pinch to zoom
// in a page
import { pageOgImage, articleOgImage } from '@/lib/og-image'
const ogImageUrl = await pageOgImage('resume') // fails the build if 'resume' isn't in og-images.json
const postImageUrl = articleOgImage('posts', Astro.params.slug!)

pageOgImage() is async because it reads the ogImages collection to check the slug. articleOgImage() doesn’t need to: every post and note gets an image, so the URL can’t point at nothing. A page that passes no ogImage falls back to lucy.jpg.

Testing

src/lib/og-image/ is split in two so the rules can be unit-tested. resolve.ts holds the pure rules (URL shape, the page-slug check, {{siteTitle}}), and Vitest tests it directly. index.ts is the Astro adapter that reads astro:content, which tests can’t import. The rendering itself is checked by the build.

Tips

  • Keep text inside the 1200×630 canvas: truncate long titles and shrink the font size for long ones.
  • Load fonts once at module level. Satori needs the raw font file (.ttf/.otf), not .woff2.
  • Check the result with the Facebook Sharing Debugger or LinkedIn Post Inspector.

References