Back to Blog

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.

March 04, 2026 ☕ 8 min read
  • 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:

  1. Build an OG card component (title, subtitle, brand, shapes, gradients).
  2. Render it with Satori to an SVG string.
  3. Convert that SVG into PNG.
  4. Return PNG from an API route or metadata image route.

In my project, I use:

  • satori for JSX/virtual tree to SVG rendering.
  • satori-html to write templates as HTML strings when convenient.
  • sharp to convert SVG buffer to PNG.

How OG Image Generation Works in Next.js

In Next.js, OG image generation typically happens inside:

  1. opengraph-image.tsx for route-based metadata images.
  2. app/api/og/route.ts for a custom OG endpoint.

For a custom endpoint, the lifecycle is:

  1. Receive request (/api/og?title=...&description=...).
  2. Validate and sanitize input.
  3. Render SVG using Satori.
  4. Convert SVG to PNG.
  5. Return Response with Content-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.tsx
  • src/pages/open-graph/[...slug].png.ts
  • src/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:

  1. build static paths for profile page, blog list, blog posts, and tags
  2. map each slug to OG payload (title, date)
  3. return image/png
  4. 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.ts
  • src/lib/og-card.tsx
  • public/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.