Skip to content
Codeloom
Next.js

Dynamic OG Image Generation in Next.js

Generate dynamic Open Graph images in Next.js using @vercel/og and the ImageResponse API for social media previews with custom fonts and layouts.

·6 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • How Open Graph images work and why dynamic generation matters
  • Using the ImageResponse API from next/og
  • Designing OG images with JSX and inline styles
  • Loading custom fonts and external images
  • Generating images for blog posts, products, and user profiles

Prerequisites

  • Working knowledge of [Next.js App Router](/blog/nextjs-app-router-basics)
  • Familiarity with [Next.js route handlers](/blog/nextjs-route-handlers-vs-api-routes)

When you share a link on Twitter, LinkedIn, or Slack, the platform fetches an Open Graph image to display as a preview card. Manually creating these images for every page is tedious. Next.js lets you generate them dynamically at request time using JSX, so every page gets a unique, branded preview card automatically.

How it works

Next.js includes the ImageResponse class (from next/og) that converts JSX into a PNG image. It runs on the Edge Runtime using Satori under the hood, which converts HTML and CSS subset into SVG, then renders it as a PNG.

The generated image is served from a route handler or a special opengraph-image.tsx file that Next.js recognizes automatically.

The opengraph-image.tsx convention

The simplest approach uses the file convention. Create an opengraph-image.tsx file in any route segment:

// app/opengraph-image.tsx
import { ImageResponse } from 'next/og';

export const runtime = 'edge';

export const alt = 'My Site';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';

export default function OGImage() {
  return new ImageResponse(
    (
      <div
        style={{
          fontSize: 64,
          background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
          width: '100%',
          height: '100%',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
          color: 'white',
          fontFamily: 'sans-serif',
        }}
      >
        Welcome to My Site
      </div>
    ),
    { ...size }
  );
}

Next.js automatically sets the <meta property="og:image"> tag in the page’s metadata. No manual metadata configuration needed.

Dynamic OG images for blog posts

Generate unique images for each blog post using route params:

// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og';

export const runtime = 'edge';
export const alt = 'Blog post preview';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';

export default async function OGImage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;

  // Fetch the post data
  const post = await fetch(
    `https://api.example.com/posts/${slug}`
  ).then((r) => r.json());

  return new ImageResponse(
    (
      <div
        style={{
          display: 'flex',
          flexDirection: 'column',
          width: '100%',
          height: '100%',
          background: '#0f172a',
          padding: '60px',
          fontFamily: 'sans-serif',
        }}
      >
        <div
          style={{
            display: 'flex',
            alignItems: 'center',
            marginBottom: '40px',
          }}
        >
          <div
            style={{
              fontSize: 24,
              color: '#94a3b8',
              background: '#1e293b',
              padding: '8px 16px',
              borderRadius: '8px',
            }}
          >
            {post.category}
          </div>
        </div>

        <div
          style={{
            fontSize: 56,
            fontWeight: 700,
            color: '#f8fafc',
            lineHeight: 1.2,
            flex: 1,
            display: 'flex',
            alignItems: 'center',
          }}
        >
          {post.title}
        </div>

        <div
          style={{
            display: 'flex',
            justifyContent: 'space-between',
            alignItems: 'center',
            fontSize: 24,
            color: '#94a3b8',
          }}
        >
          <span>{post.author}</span>
          <span>{post.readTime} min read</span>
        </div>
      </div>
    ),
    { ...size }
  );
}

Loading custom fonts

Custom fonts make OG images match your brand. Load font files from the filesystem or a URL:

// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og';

export const runtime = 'edge';

export default async function OGImage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;

  // Load custom font
  const interBold = await fetch(
    new URL('../../fonts/Inter-Bold.ttf', import.meta.url)
  ).then((res) => res.arrayBuffer());

  const interRegular = await fetch(
    new URL('../../fonts/Inter-Regular.ttf', import.meta.url)
  ).then((res) => res.arrayBuffer());

  return new ImageResponse(
    (
      <div
        style={{
          display: 'flex',
          flexDirection: 'column',
          width: '100%',
          height: '100%',
          background: 'white',
          padding: '60px',
        }}
      >
        <h1
          style={{
            fontSize: 64,
            fontFamily: 'Inter Bold',
            color: '#111827',
          }}
        >
          Article Title Here
        </h1>
        <p
          style={{
            fontSize: 28,
            fontFamily: 'Inter Regular',
            color: '#6b7280',
          }}
        >
          A brief description of the article.
        </p>
      </div>
    ),
    {
      width: 1200,
      height: 630,
      fonts: [
        {
          name: 'Inter Bold',
          data: interBold,
          style: 'normal',
          weight: 700,
        },
        {
          name: 'Inter Regular',
          data: interRegular,
          style: 'normal',
          weight: 400,
        },
      ],
    }
  );
}

Place font files in a fonts/ directory at the project root or inside app/.

Using external images

Include images like logos or avatars in your OG images:

<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
  {/* eslint-disable-next-line @next/next/no-img-element */}
  <img
    src="https://example.com/logo.png"
    alt=""
    width={48}
    height={48}
    style={{ borderRadius: '50%' }}
  />
  <span style={{ fontSize: 28, color: '#374151' }}>Example.com</span>
</div>

For images hosted on your own domain, use an absolute URL:

const baseUrl = process.env.VERCEL_URL
  ? `https://${process.env.VERCEL_URL}`
  : 'http://localhost:3000';

<img src={`${baseUrl}/logo.png`} alt="" width={64} height={64} />

Using a route handler instead

If you need more control or want to generate images for paths that do not map to the file convention:

// app/api/og/route.tsx
import { ImageResponse } from 'next/og';
import { NextRequest } from 'next/server';

export const runtime = 'edge';

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const title = searchParams.get('title') ?? 'Default Title';
  const category = searchParams.get('category') ?? 'General';

  return new ImageResponse(
    (
      <div
        style={{
          display: 'flex',
          flexDirection: 'column',
          width: '100%',
          height: '100%',
          background: '#1a1a2e',
          padding: '60px',
          color: 'white',
          fontFamily: 'sans-serif',
        }}
      >
        <div style={{ fontSize: 20, color: '#e94560', marginBottom: '20px' }}>
          {category}
        </div>
        <div style={{ fontSize: 56, fontWeight: 700, lineHeight: 1.3 }}>
          {title}
        </div>
      </div>
    ),
    { width: 1200, height: 630 }
  );
}

Reference it in your page metadata:

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);

  return {
    title: post.title,
    openGraph: {
      title: post.title,
      description: post.description,
      images: [
        {
          url: `/api/og?title=${encodeURIComponent(post.title)}&category=${encodeURIComponent(post.category)}`,
          width: 1200,
          height: 630,
        },
      ],
    },
  };
}

CSS limitations

Satori supports a subset of CSS. Key limitations to be aware of:

  • Flexbox only. No CSS Grid, no floats, no position: absolute (use nested flex containers)
  • No pseudo-elements. No ::before, ::after
  • No CSS variables. Use inline style values directly
  • Limited text styling. text-decoration, text-transform, and letter-spacing work. text-shadow does not
  • All elements need display: flex. Every <div> must explicitly set display: 'flex'

Always test your OG images in a browser during development. Visit the route directly (e.g., /api/og?title=Test) to see the rendered PNG.

Caching OG images

OG images are generated on every request by default. Add caching headers for images that do not change frequently:

export async function GET(request: NextRequest) {
  // ... generate the image

  const response = new ImageResponse(/* ... */);

  response.headers.set(
    'Cache-Control',
    'public, max-age=86400, s-maxage=86400, stale-while-revalidate=604800'
  );

  return response;
}

For the file convention, use the revalidate export:

export const revalidate = 86400; // Cache for 24 hours

Testing OG images

Verify your images look correct on social platforms using these tools:

  • Twitter Card Validator: cards-dev.twitter.com/validator
  • Facebook Sharing Debugger: developers.facebook.com/tools/debug
  • LinkedIn Post Inspector: linkedin.com/post-inspector
  • opengraph.xyz: previews how your link appears across platforms

Dynamic OG images give every page on your site a polished, branded social media presence. The file convention approach is the easiest to set up, while route handlers offer more flexibility for complex scenarios.