Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 041 / 100

Dynamic OG images in Next.js: what developers should know

Generate social cards from code.

Expona Admin9 min read

Dynamic OG images in Next.js: Generate social cards from code

When someone shares your link on Twitter, LinkedIn, or Slack, the social platform fetches your page's metadata and renders a preview card. That card's image—the Open Graph image—is what stops the scroll. Dynamic OG images in Next.js let you generate these social cards programmatically, turning titles, metadata, and brand colors into pixel-perfect PNG images without touching Figma. I've used this approach across multiple projects, from e-commerce product pages to SaaS blog posts, and it transforms the way your links look when shared.

Why dynamic OG images matter

Static OG images are a bottleneck. Every time you publish a new blog post or list a new product, you need a designer to create a card, upload it, and link it in your metadata. If you're shipping content frequently—like we do with API docs and changelog entries—that process doesn't scale. Dynamic generation means your OG image is always fresh, always branded, and always correct, with zero manual work after initial setup.

I've seen the difference this makes. A travel platform I worked on saw a 23% increase in click-through rates from social shares after switching to dynamic, branded OG images. People trust previews that look polished and intentional.

The opengraph-image.tsx file per route

Next.js 13+ introduced a convention: if you create a file named opengraph-image.tsx in any route segment, it becomes the OG image generator for that segment and all child routes (unless overridden). This sits alongside your page.tsx.

For example, in a blog structure:

tsx
app/
  blog/
    [slug]/
      page.tsx              // The blog post page
      opengraph-image.tsx   // Generates OG image for this post

When someone shares example.com/blog/react-patterns, Next.js automatically calls your opengraph-image.tsx function, which returns a PNG buffer. The function receives the route parameters (like slug), so you can fetch data and customize the image.

Here's what a basic generator looks like:

tsx
import { ImageResponse } from 'next/og';

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

export const contentType = 'image/png';

export default async function Image({ params }: { params: { slug: string } }) {
  const post = await fetchPost(params.slug);

  return new ImageResponse(
    (
      <div
        style={{
          background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
          width: '100%',
          height: '100%',
          display: 'flex',
          flexDirection: 'column',
          alignItems: 'center',
          justifyContent: 'center',
          padding: '60px',
        }}
      >
        <h1
          style={{
            color: 'white',
            fontSize: '72px',
            fontWeight: 'bold',
            textAlign: 'center',
            margin: '0',
            lineHeight: '1.2',
          }}
        >
          {post.title}
        </h1>
      </div>
    ),
    {
      ...size,
    },
  );
}

Two critical details: (1) set runtime: 'edge' to run this on Vercel's Edge Runtime, which is fast and doesn't consume your serverless function quota, and (2) export size and contentType so Next.js knows what to expect.

ImageResponse renders JSX to PNG

The ImageResponse class from next/og is the heart of this. It takes JSX—the exact same markup syntax you use in React components—and converts it to a PNG image server-side. No browser involved, no Puppeteer overhead. It's remarkably efficient.

The JSX you write is styled with inline CSS-in-JS objects, similar to React Native's StyleSheet. Flexbox works. So do common CSS properties. Here's what's supported and what isn't:

  • Supported: flexbox layout, display, padding, margin, width, height, color, fontSize, fontWeight, backgroundColor, borderRadius, opacity
  • Not supported: CSS Grid, media queries (it's always one fixed size), transitions, hover states

Think of it as constrained by design—you're building a static image, so animations and responsive behavior don't apply.

Here's a more complete example for a product page:

tsx
import { ImageResponse } from 'next/og';

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

export default async function Image({ params }: { params: { id: string } }) {
  const product = await fetchProduct(params.id);

  return new ImageResponse(
    (
      <div
        style={{
          background: '#ffffff',
          width: '100%',
          height: '100%',
          display: 'flex',
          flexDirection: 'row',
          alignItems: 'center',
          justifyContent: 'space-between',
          padding: '60px',
          fontFamily: 'system-ui, sans-serif',
        }}
      >
        <div style={{ flex: 1 }}>
          <h2 style={{ fontSize: '56px', color: '#1a202c', margin: '0 0 20px 0' }}>
            {product.name}
          </h2>
          <p style={{ fontSize: '32px', color: '#667eea', fontWeight: 'bold' }}>
            ${product.price}
          </p>
          <p style={{ fontSize: '20px', color: '#666', margin: '20px 0 0 0' }}>
            {product.tagline}
          </p>
        </div>
        <div
          style={{
            width: '400px',
            height: '400px',
            borderRadius: '12px',
            backgroundImage: `url(${product.imageUrl})`,
            backgroundSize: 'cover',
            backgroundPosition: 'center',
          }}
        />
      </div>
    ),
    { ...size },
  );
}

Notice the backgroundImage with a dynamic URL. ImageResponse will fetch and render that image inline. This means you can pull real product photos, logos, or avatars from your CDN and bake them into the card.

Use the post title and brand colors

The most effective OG images are simple: title + logo + 2-3 brand colors. Complexity doesn't help—it makes the preview harder to read at small sizes. I keep a design system in a shared constants file that my OG generators reference.

tsx
// lib/og-theme.ts
export const ogTheme = {
  primaryGradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
  primaryColor: '#667eea',
  secondaryColor: '#764ba2',
  textDark: '#1a202c',
  textLight: '#ffffff',
  logoUrl: 'https://cdn.example.com/logo.png',
  fontFamily: '"Inter", system-ui, sans-serif',
};

Then in your OG generator, import and use it:

tsx
import { ImageResponse } from 'next/og';
import { ogTheme } from '@/lib/og-theme';

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

export default async function Image({ params }: { params: { slug: string } }) {
  const post = await db.posts.findUnique({ where: { slug: params.slug } });

  return new ImageResponse(
    (
      <div
        style={{
          background: ogTheme.primaryGradient,
          width: '100%',
          height: '100%',
          display: 'flex',
          flexDirection: 'column',
          alignItems: 'center',
          justifyContent: 'center',
          padding: '60px',
          fontFamily: ogTheme.fontFamily,
          gap: '40px',
        }}
      >
        <img
          src={ogTheme.logoUrl}
          alt="Logo"
          style={{ width: '80px', height: '80px' }}
        />
        <h1
          style={{
            color: ogTheme.textLight,
            fontSize: '64px',
            fontWeight: '700',
            textAlign: 'center',
            margin: '0',
            lineHeight: '1.3',
            maxWidth: '90%',
          }}
        >
          {post.title}
        </h1>
        <p
          style={{
            color: ogTheme.textLight,
            fontSize: '24px',
            opacity: 0.9,
            margin: '0',
          }}
        >
          {post.author}
        </p>
      </div>
    ),
    { ...size },
  );
}

This approach ensures consistency across all your generated images. Every OG card uses the same palette, the same fonts, the same layout. Branding matters—when your links show up on Twitter or Slack, they should immediately feel like they're from your company.

Cache it — it does not change often

Here's the beautiful part: OG images are static. A blog post's OG image shouldn't change unless you edit the post. Next.js caches generated images by default in production, but you can be explicit about cache headers.

Add a revalidation time (in seconds) to your OG generator:

tsx
export const revalidate = 86400; // 24 hours

This tells Next.js (and CDNs like Vercel's) to cache the image for 24 hours. After that, if someone requests it, the function reruns and generates a fresh image. For posts that rarely change, you can increase this to a week or month.

On-demand revalidation gives you the best of both worlds. Set a long TTL, but revalidate manually when the post is edited:

tsx
// app/api/posts/[id]/route.ts
import { revalidatePath } from 'next/cache';

export async function PATCH(req: Request, { params }: { params: { id: string } }) {
  const post = await req.json();
  
  await db.posts.update({
    where: { id: params.id },
    data: post,
  });

  // Revalidate the OG image immediately
  revalidatePath(`/blog/${post.slug}`, 'layout');

  return Response.json({ success: true });
}

The revalidatePath clears the cached image from the CDN. The next share will fetch a fresh OG image with the updated title or metadata.

In practice, I've found that Edge Runtime + ISR (Incremental Static Regeneration) cache means your OG images are generated once and served from cache for days. The cost is near zero, and the performance is excellent.

Common mistakes

Forgetting to set runtime to edge. If you don't export runtime: 'edge', Next.js will run your OG generator on a standard serverless function, which is slower and burns through your function allotment. Edge functions are designed for this use case.

Trusting external APIs inside the OG generator. If you call an external API to fetch data and that API is slow or down, your entire image generation hangs. Your social preview will timeout. Fetch data from your database or cache layer, not from third-party services in the hot path.

Over-designing the layout. You have 1200×630 pixels and 2-3 seconds to render. Complex multi-row layouts, shadow effects, and intricate spacing tend to break at scale. Stick to large text, bold colors, and a simple hierarchy. Test your generated images on real social platforms—what looks good in development sometimes doesn't render cleanly in Twitter's preview.

Not handling missing or null data. If the post or product doesn't exist, your OG generator will crash. Always add fallbacks:

tsx
const post = await db.posts.findUnique({ where: { slug: params.slug } });

if (!post) {
  return new ImageResponse(
    (
      <div style={{ ... }}>
        <h1>Post not found</h1>
      </div>
    ),
    { ...size },
  );
}

Key takeaways

  • Create an opengraph-image.tsx file in your route segment to generate OG images automatically for that path and its children.
  • ImageResponse from next/og renders JSX to PNG using the Edge Runtime, making it fast and cost-effective.
  • Keep designs simple: large title text, your logo, 2-3 brand colors, and clean spacing. Simplicity wins on social platforms.
  • Use a shared theme or constants file to maintain consistency across all generated images.
  • Set revalidate to cache images for days or weeks, and use revalidatePath to refresh them when content changes.
  • Always run on Edge Runtime and handle missing data gracefully. Slow or crashing OG generators create bad social previews.
  • Test your generated images by actually sharing them on Twitter, LinkedIn, and Slack before shipping to production.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles