Skip to content
Writing

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:

Open graph meta tags
<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:

SatoriTakumi
What it doesTurns JSX and CSS into an SVGRenders JSX, HTML and CSS straight to an image
OutputSVG (add resvg for a PNG)PNG, JPEG, WebP, SVG, animated formats and PDF
LayoutFlexbox onlyFlexbox, grid and block
FontsYou supply TTF, OTF or WOFF filesA built-in font, plus Google Fonts and custom ones
StylingInline styles and a tw propInline 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:

Terminal
# Satori
npm i satori @resvg/resvg-js

# Takumi
npm i takumi-js

Both ship native code, so tell Next.js not to bundle them:

next.config.ts
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.

components/og-card.tsx
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:

app/api/og/satori/route.tsx
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:

app/api/og/takumi/route.tsx
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:

tsx
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:

scripts/generate-og-images.tsx
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:

package.json
{
  "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:

app/blog/[slug]/page.tsx
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!