Article
Generate Dynamic Open Graph Images Using Satori
A practical guide to generating dynamic OG images with Satori, based on my project setup and a production-ready Next.js implementation.
- NextJS
- OpenGraph
- React
- SEO
- Satori
- WebPerformance
Open Graph images are the preview cards you see when a URL is shared on platforms like X, LinkedIn, Slack, Discord, and WhatsApp.
A good OG image improves click-through rate, brand consistency, and readability in crowded feeds. The problem with static OG images is that they do not scale well for dynamic content like blogs, docs, changelogs, and case studies. You either design hundreds of images manually, or settle for generic previews.
Dynamic OG generation solves this by rendering an image on demand (or at build time) using your page data, such as title, description, tags, author, and publish date.
In this post, I will cover:
- How I implemented dynamic OG images in my current project.
- How you can apply the same approach in a Next.js app with Satori.
What Is Satori
Satori is a rendering engine that takes JSX-like markup and returns SVG. Think of it as “React-style UI to image layout.”
The usual flow is:
- Build an OG card component (title, subtitle, brand, shapes, gradients).
- Render it with Satori to an SVG string.
- Convert that SVG into PNG.
- Return PNG from an API route or metadata image route.
In my project, I use:
satorifor JSX/virtual tree to SVG rendering.satori-htmlto write templates as HTML strings when convenient.sharpto convert SVG buffer to PNG.
How OG Image Generation Works in Next.js
In Next.js, OG image generation typically happens inside:
opengraph-image.tsxfor route-based metadata images.app/api/og/route.tsfor a custom OG endpoint.
For a custom endpoint, the lifecycle is:
- Receive request (
/api/og?title=...&description=...). - Validate and sanitize input.
- Render SVG using Satori.
- Convert SVG to PNG.
- Return
ResponsewithContent-Type: image/png.
This approach works well when you need:
- query-based previews
- external share generators
- reusable OG service across multiple pages
Project Implementation (How I Integrated It)
My site is Astro-based, but the architecture is the same pattern you would use in Next.js.
Core files involved in this setup:
src/utils/ogTemplate.tsxsrc/pages/open-graph/[...slug].png.tssrc/layouts/portfolio.astro
Required Dependencies
From my project:
{
"satori": "^0.4.4",
"satori-html": "^0.3.2",
"sharp": "^0.33.5"
}
OG Template and Rendering Utility
I keep rendering logic in src/utils/ogTemplate.tsx:
- define dimensions (
1200x630) - register custom fonts
- render a styled template
- return PNG buffer
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import type { SatoriOptions } from 'satori'
import satori from 'satori'
import { html } from 'satori-html'
import sharp from 'sharp'
const resolveProjectPath = (...segments: string[]) => resolve(process.cwd(), ...segments)
const ogDimensions = {
width: 1200,
height: 630,
}
const Template = (props: { title: string; date: string | Date }) =>
html(`
<div style="display:flex;width:100%;height:100%;padding:28px;background:#020617;">
<div style="display:flex;flex-direction:column;width:100%;height:100%;padding:42px;border-radius:24px;background:#0f172a;">
<div style="display:flex;font-size:20px;color:#94a3b8;">${props.date}</div>
<div style="display:flex;font-size:66px;font-weight:900;color:#f8fafc;">${props.title}</div>
</div>
</div>
`)
export const generateOgImage = async (title: string, date: string | Date): Promise<Buffer> => {
const options: SatoriOptions = {
width: ogDimensions.width,
height: ogDimensions.height,
embedFont: true,
fonts: [
{
name: 'JetBrainsMono',
data: await readFile(resolveProjectPath('src', 'assets', 'font', 'JetBrainsMono-Bold.ttf')),
weight: 600,
style: 'normal',
},
{
name: 'PlusJakartaSans',
data: await readFile(
resolveProjectPath('src', 'assets', 'font', 'PlusJakartaSans-Bold.ttf'),
),
weight: 900,
style: 'normal',
},
],
}
const svg = await satori(Template({ title, date }), options)
return sharp(Buffer.from(svg)).png().toBuffer()
}
Dynamic OG Route
In src/pages/open-graph/[...slug].png.ts, I:
- build static paths for profile page, blog list, blog posts, and tags
- map each slug to OG payload (
title,date) - return
image/png - provide fallback image on failure
import type { APIRoute, GetStaticPaths } from 'astro'
import { generateOgImage, getAllTags, getLatestPosts, modifySlug } from '@/utils'
export const getStaticPaths: GetStaticPaths = async () => {
const result = []
const blogs = await getLatestPosts()
const tags = await getAllTags()
blogs.forEach((blog) =>
result.push({
params: { slug: `blog/${modifySlug(blog.slug)}` },
props: { title: blog.data.title, date: blog.data.date },
}),
)
tags.forEach((tag) =>
result.push({
params: { slug: `tags/${tag}` },
props: { title: `Showing all contents from "${tag}"`, date: new Date() },
}),
)
return result
}
export const get: APIRoute = async ({ props }) => {
const response = await generateOgImage(props.title, props.date)
return new Response(response, {
status: 200,
headers: { 'Content-Type': 'image/png' },
})
}
Metadata Wiring
I use getOgImagePath and pass it into page layout metadata:
<meta property="og:image" content="{ogImage}" /> <meta name="twitter:image" content="{ogImage}" />
This keeps OG URL generation centralized and consistent across pages.
Example Next.js Implementation
Now let us implement the same architecture in a Next.js app using an API route.
1) Create /api/og Route
Install dependencies:
pnpm add satori @resvg/resvg-js
2) Render JSX to SVG with Satori
Create a presentational component:
// src/lib/og-card.tsx
type OgCardProps = {
title: string
description: string
siteName: string
}
export function OgCard({ title, description, siteName }: OgCardProps) {
return (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
background: '#0f172a',
color: '#e2e8f0',
padding: '56px',
}}
>
<div
style={{
display: 'flex',
fontSize: 24,
letterSpacing: 2,
textTransform: 'uppercase',
color: '#c4b5fd',
}}
>
Developer Publication
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div style={{ display: 'flex', fontSize: 64, fontWeight: 800, lineHeight: 1.1 }}>
{title}
</div>
<div style={{ display: 'flex', fontSize: 30, color: '#94a3b8', lineHeight: 1.3 }}>
{description}
</div>
</div>
<div style={{ display: 'flex', fontSize: 22, color: '#94a3b8' }}>{siteName}</div>
</div>
)
}
3) Convert SVG to PNG and Return Response
// app/api/og/route.ts
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { Resvg } from '@resvg/resvg-js'
import satori from 'satori'
import { OgCard } from '@/lib/og-card'
export const runtime = 'nodejs'
const WIDTH = 1200
const HEIGHT = 630
const interBoldPromise = readFile(join(process.cwd(), 'public/fonts/Inter-Bold.ttf'))
const limit = (value: string, max: number) =>
value.length > max ? `${value.slice(0, max - 1).trimEnd()}...` : value
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const title = limit(searchParams.get('title') || 'Dynamic OG with Satori', 90)
const description = limit(
searchParams.get('description') || 'Generate branded social cards directly from your app.',
160,
)
const svg = await satori(
OgCard({
title,
description,
siteName: 'yourdomain.com',
}),
{
width: WIDTH,
height: HEIGHT,
fonts: [
{
name: 'Inter',
data: await interBoldPromise,
weight: 700,
style: 'normal',
},
],
},
)
const png = new Resvg(svg, {
fitTo: { mode: 'width', value: WIDTH },
})
.render()
.asPng()
return new Response(png, {
status: 200,
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=0, s-maxage=86400, stale-while-revalidate=604800',
},
})
}
Dynamic Data in OG Images
There are two common ways to pass dynamic data into OG routes.
Query Params
Use query params when generating cards from reusable endpoint logic:
/api/og?title=How%20to%20Use%20Satori&description=Step%20by%20step%20guide
In your page metadata:
const og = new URL('/api/og', process.env.NEXT_PUBLIC_SITE_URL)
og.searchParams.set('title', post.title)
og.searchParams.set('description', post.description)
Route Params
Use route params when each content route owns its OG image:
app/blog/[slug]/opengraph-image.tsx
Fetch post by params.slug, then render OG from that data.
Styling OG Images
Satori works best with inline styles and a flexbox-first layout strategy.
Recommended styling rules:
- Always design for
1200x630. - Keep visual hierarchy obvious: eyebrow, title, subtitle, brand.
- Clamp or truncate long text to avoid overflow.
- Use embedded fonts to avoid runtime font differences.
- Keep color contrast high for small previews in social feeds.
If you use gradients or decorative shapes, keep them subtle so text stays readable in all platforms.
Performance Considerations
Edge Runtime vs Node Runtime
- Use
runtime = 'nodejs'when reading local fonts or using Node-centric libraries. - Use edge runtime only if your dependencies and data access are edge-friendly.
Caching
For API-route OG images, set explicit cache headers:
Cache-Control: public, max-age=0, s-maxage=86400, stale-while-revalidate=604800
This gives CDN caching while still allowing background refresh.
Reduce Per-Request Cost
- Load fonts at module scope (
const fontPromise = readFile(...)). - Avoid heavy remote fetches in the OG path.
- Keep template tree lightweight.
- Validate and limit user input size.
Complete Example Code
This is the full minimal setup in one place.
app/api/og/route.tssrc/lib/og-card.tsxpublic/fonts/Inter-Bold.ttf
src/lib/og-card.tsx
type OgCardProps = {
title: string
description: string
siteName: string
}
export function OgCard({ title, description, siteName }: OgCardProps) {
return (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
background: '#0b1020',
color: '#e5e7eb',
padding: '56px',
}}
>
<div style={{ display: 'flex', fontSize: 24, color: '#93c5fd' }}>Developer Blog</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
<div style={{ display: 'flex', fontSize: 64, fontWeight: 800, lineHeight: 1.1 }}>
{title}
</div>
<div style={{ display: 'flex', fontSize: 30, color: '#cbd5e1', lineHeight: 1.35 }}>
{description}
</div>
</div>
<div style={{ display: 'flex', fontSize: 22, color: '#94a3b8' }}>{siteName}</div>
</div>
)
}
app/api/og/route.ts
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { Resvg } from '@resvg/resvg-js'
import satori from 'satori'
import { OgCard } from '@/lib/og-card'
export const runtime = 'nodejs'
const WIDTH = 1200
const HEIGHT = 630
const interBoldPromise = readFile(join(process.cwd(), 'public/fonts/Inter-Bold.ttf'))
const limit = (value: string, max: number) =>
value.length > max ? `${value.slice(0, max - 1).trimEnd()}...` : value
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const title = limit(searchParams.get('title') || 'My Post Title', 90)
const description = limit(
searchParams.get('description') || 'A clean, dynamic Open Graph image with Satori.',
160,
)
const svg = await satori(
OgCard({
title,
description,
siteName: 'example.com',
}),
{
width: WIDTH,
height: HEIGHT,
fonts: [
{
name: 'Inter',
data: await interBoldPromise,
weight: 700,
style: 'normal',
},
],
},
)
const png = new Resvg(svg).render().asPng()
return new Response(png, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=0, s-maxage=86400, stale-while-revalidate=604800',
},
})
}
Use it like this:
https://yourdomain.com/api/og?title=Generate%20Dynamic%20OG%20Images%20Using%20Satori&description=Practical%20guide%20for%20Next.js%20developers
Conclusion
Dynamic OG images are one of those features that give immediate product value: better social previews, stronger branding, and less manual design work.
Satori is a great fit when:
- you want UI-driven OG templates
- you already think in React/JSX
- you need data-driven card generation at scale
The setup I use in my project (template utility + route + metadata wiring) is simple, portable, and production-friendly. If you are building with Next.js, the same architecture works with either a custom /api/og route or route-level opengraph-image.tsx.