7 min read
How to generate dynamic open graph images
Generate an open graph image for every page of your Next.js site with Satori or Takumi, no headless browser required.
Every link you share in Slack, WhatsApp or X gets a preview card, and the image on it decides whether anyone clicks. Making that image by hand for every page doesn't scale, so let's generate them from code. In this post I'll show you how open graph images work, then build the same card with two renderers, Satori and Takumi, so you can pick the one that fits.
What is an open graph?
Facebook created the open graph protocol so a page could describe itself with a few meta tags. Any app that shows link previews reads them to decide what to display, including the image.
Here's what they look like for one of my posts:
<meta property="og:title" content="How I built my blog with NextJs"/>
<meta property="og:description" content="Want to build an interactive blog? Let's look at the design decisions, project structure, and libraries that you need to build it, one component at a time."/>
<meta property="og:type" content="website"/>
<meta property="og:url" content="https://janasundar.dev/"/>
<meta property="og:image" content="https://janasundar.dev/images/og/how-i-built-my-blog-with-nextjs.png"/>The size that works everywhere is 1200 × 630, so that's what we'll render.
Why not a headless browser?
The first version of this post did it the heavy way: open the page in headless Chrome with Puppeteer and take a screenshot. It works, but it means shipping a browser, waiting seconds per image and fighting with serverless size limits.
Satori and Takumi skip the browser entirely. You describe the card in JSX, and they lay it out and draw it themselves:
| Satori | Takumi | |
|---|---|---|
| What it does | Turns JSX and CSS into an SVG | Renders JSX, HTML and CSS straight to an image |
| Output | SVG (add resvg for a PNG) | PNG, JPEG, WebP, SVG, animated formats and PDF |
| Layout | Flexbox only | Flexbox, grid and block |
| Fonts | You supply TTF, OTF or WOFF files | A built-in font, plus Google Fonts and custom ones |
| Styling | Inline styles and a tw prop | Inline styles and Tailwind through tw |
Already on Next.js?
next/og and its ImageResponse are built on Satori, so you don't need to install anything to use it. That's what this site does. Everything below is still useful if you want to see what happens underneath, or want Takumi's extra formats.
Getting started
Install whichever renderer you want to try. Satori needs resvg alongside it to turn its SVG into a PNG, while Takumi renders the image itself:
# Satori
npm i satori @resvg/resvg-js
# Takumi
npm i takumi-jsBoth ship native code, so tell Next.js not to bundle them:
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
serverExternalPackages: ['@takumi-rs/core', '@resvg/resvg-js'],
};
export default nextConfig;One design, two renderers
I keep the card in a single component, so switching renderers never means redesigning it. It only uses inline styles and flexbox, which both engines understand.
export function OgCard({ title, eyebrow = 'janasundar.dev' }: { title: string; eyebrow?: string }) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
width: '100%',
height: '100%',
padding: '64px 80px',
background: '#0a0a0a',
color: '#ededed',
}}
>
<div style={{ display: 'flex', fontSize: 28, color: '#a1a1a1' }}>{eyebrow}</div>
<div style={{ display: 'flex', fontSize: 84, fontWeight: 700, lineHeight: 1.05, letterSpacing: -3 }}>{title}</div>
</div>
);
}Give every element `display: flex`
Satori is strict about layout: any element with more than one child needs display: flex (or none). It's a good habit even with Takumi, because it keeps the card portable.
Rendering with Satori
Satori returns an SVG, so we convert it to a PNG with resvg. The route reads the title from the URL, which is what makes the image dynamic:
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { Resvg } from '@resvg/resvg-js';
import satori from 'satori';
import { OgCard } from '@/components/og-card';
export async function GET(req: Request) {
const title = new URL(req.url).searchParams.get('title') ?? 'Hello, world';
const font = await readFile(join(process.cwd(), 'assets/Inter-Bold.ttf'));
const svg = await satori(<OgCard title={title} />, {
width: 1200,
height: 630,
fonts: [{ name: 'Inter', data: font, weight: 700, style: 'normal' }],
});
const png = new Resvg(svg, { fitTo: { mode: 'width', value: 1200 } }).render().asPng();
return new Response(new Uint8Array(png), { headers: { 'Content-Type': 'image/png' } });
}Satori has no built-in font, so you pass one in as a buffer. It accepts TTF, OTF and WOFF, but not WOFF2.
Rendering with Takumi
Takumi's ImageResponse is the whole pipeline in one call. It extends the standard web Response, so you just return it:
import { ImageResponse } from 'takumi-js/response';
import { OgCard } from '@/components/og-card';
export async function GET(req: Request) {
const title = new URL(req.url).searchParams.get('title') ?? 'Hello, world';
return new ImageResponse(<OgCard title={title} />, {
width: 1200,
height: 630,
});
}Takumi comes with a built-in font that covers Latin text. When you want something else, its googleFonts helper loads it for you:
import { googleFonts } from 'takumi-js/helpers';
return new ImageResponse(<OgCard title={title} />, {
width: 1200,
height: 630,
fonts: googleFonts([{ name: 'Inter', weight: '700' }]),
});Open /api/og/takumi?title=Hello in your browser and you should see the card. Change the title in the URL and the image changes with it.
Generating images at build time
Rendering on demand is flexible, but if your titles only change when you publish, you can render everything once at build time and serve plain files. That's what my old Puppeteer script did, and it's much simpler now. Takumi's render function returns the image directly:
import { mkdir, writeFile } from 'node:fs/promises';
import { render } from 'takumi-js';
import { OgCard } from '../components/og-card';
import { getAllPosts } from './get-posts';
const outDir = './public/images/og';
await mkdir(outDir, { recursive: true });
const posts = await getAllPosts();
await Promise.all(
posts.map(async ({ slug, title }) => {
const image = await render(<OgCard title={title} />, { width: 1200, height: 630 });
await writeFile(`${outDir}/${slug}.png`, image);
}),
);Then run it automatically before every build. The script uses JSX, so run it with tsx:
{
"scripts": {
"generate:og": "tsx scripts/generate-og-images.tsx",
"prebuild": "npm run generate:og"
}
}No browser, no worry
There's no browser to launch, so all the images render in parallel with Promise.all. My old script had to start a new Chrome for every post.
Pointing your pages at the images
Finally, tell each page where its image lives. With Next.js metadata that's a few lines, and it generates the og:image tag from the start of this post:
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
return {
openGraph: { images: [`/images/og/${slug}.png`] },
};
}If you went with the on-demand route instead, point at that with the title in the query string: `/api/og/takumi?title=${encodeURIComponent(title)}`.
Wrapping up
That's all it takes: one card component, a renderer, and either a route or a build script. Satori is the battle-tested choice and already inside next/og, while Takumi gives you grid layout, more formats and a built-in font. If you have questions or ideas, find me on X at @jana__sundar or email me at mailtojana23@gmail.com. Until next time, happy coding!