A simple example of generating Open Graph images in Astro with Satori and Sharp — from a React component to a PNG at build time.
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>.pngThis 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
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
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
---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 --> pngRendering
- Two routes, one renderer.
og/[articleType]/[...slug].png.tsloops overOG_ARTICLE_COLLECTIONS(posts,notes) and emits/og/<collection>/<id>.pngfrom each entry’s frontmatter.og/pages/[...slug].png.tsemits/og/pages/<slug>.pngfor every entry insrc/content/og-images.json. Both areprerender = trueand justreturn ogImageResponse(entry.data). ogImageResponse()replaces{{siteTitle}}in the title withsite.siteTitle, renders the PNG and wraps it in aResponsewithContent-Type: image/png.renderOgImage()(render.ts) loads the three VictorMono weights (300, 400, 700) once at module level, picks the theme for the style fromOgImageTemplate, 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 Postdescription: Post description for social mediaogStyle: '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 inog-template.tsxisRecord<OgStyle, Theme>. Add a style without a theme and TypeScript refuses to compile. Put an unknownogStylein 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)"]// in a pageimport { pageOgImage, articleOgImage } from '@/lib/og-image'const ogImageUrl = await pageOgImage('resume') // fails the build if 'resume' isn't in og-images.jsonconst 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
-
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/