# Generate Dynamic OG Images with Next.js 16

> Learn to generate dynamic Open Graph images in Next.js 16 using ImageResponse and Sharp. Covers the opengraph-image.tsx convention, custom fonts, and Vercel deployment.

*Published: 2024-11-18*
*Canonical: https://makerkit.dev/blog/tutorials/dynamic-og-image*

---

Dynamic OG images make your content stand out when shared on social platforms. Instead of a generic logo or static image, each page gets a custom preview showing the title, description, and your branding. In Next.js 16, you have two production-ready approaches: the built-in `ImageResponse` API (recommended for most cases) and Sharp for advanced image processing.

{% definition title="What are Open Graph (OG) images?" %}
Open Graph images are the preview images that appear when you share a URL on social platforms like Twitter, LinkedIn, and Facebook. They're specified via `og:image` meta tags and typically display at 1200x630 pixels. Dynamic OG images are generated on-demand from page data rather than being static files.
{% /definition %}

*Updated January 2026 for Next.js 16.1 and @vercel/og 0.6+. Both approaches tested on Vercel.*

---

## Quick Start (TL;DR)

Want the fastest path to working OG images? Use the file convention:

1. Create `app/blog/[slug]/opengraph-image.tsx`
2. Export a default function returning `ImageResponse` with your JSX
3. Next.js automatically links it in metadata

Skip to [Approach 1: ImageResponse](#approach-1-imageresponse-recommended) for the full implementation.

---

## What We're Building

This tutorial shows you how to generate OG images that include:

- The page title with automatic text wrapping
- Publication date or custom metadata
- Description text
- Your brand colors and fonts

Here's an example of this post's dynamically generated OG image:

{% img src="/blog/tutorials/dynamic-og-image/og" width="800" height="400" alt="Example dynamic OG image showing the post title, date, and description on a dark gradient background" /%}

You can preview any blog post's OG image by appending `/og` to the URL.

## Two Approaches: When to Use Each

Next.js 16 gives you two ways to generate OG images. Here's when to use each:

{% decision-rules
   title="ImageResponse vs Sharp decision guide"
   useWhen=["PNG output is acceptable for your use case", "Your design uses flexbox layout only (no CSS Grid)", "You don't need to resize, crop, or overlay images", "You want the simplest setup with automatic metadata linking"]
   avoidWhen=["You need WebP output for smaller file sizes (<50KB)", "Your design requires CSS Grid, calc(), or CSS variables", "You're compositing multiple images or need advanced filters", "You need consistent output across all runtimes (not just Edge)"]
   ifUnsure="Start with ImageResponse. You can migrate to Sharp later if you hit limitations."
/%}

| Feature | ImageResponse | Sharp |
|---------|---------------|-------|
| **Output format** | PNG only | PNG, WebP, JPEG, AVIF |
| **Typical file size** | ~180KB | ~45KB (WebP) |
| **Runtime** | Edge Runtime | Node.js Runtime |
| **Layout engine** | Satori (flexbox only) | Full SVG support |
| **Cold start** | ~100ms | ~300ms |
| **Image manipulation** | No | Yes (resize, crop, overlay) |
| **Setup complexity** | Low | Medium (fonts config) |

**[MakerKit](https://makerkit.dev/ai-saas-starter) uses Sharp** because we wanted WebP output for smaller file sizes. But for most new projects, ImageResponse is the right choice.

## Approach 1: ImageResponse (Recommended)

The `ImageResponse` API renders JSX to an image using Satori under the hood. It supports flexbox layouts, custom fonts, and runs on Edge Runtime.

{% callout type="info" title="Note on imports" %}
In Next.js 14+, import from `next/og`. The older `@vercel/og` package is still available but `next/og` is preferred for Next.js projects as it's maintained as part of the framework.
{% /callout %}

### Option A: The opengraph-image.tsx Convention

Next.js can automatically generate OG images using file conventions. Create a file named `opengraph-image.tsx` in any route folder:

```tsx {% title="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 Image({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;

  // Replace with your data fetching logic
  // This could be a CMS client, database query, or file read
  const post = await getPost(slug);

  if (!post) {
    return new ImageResponse(
      <div style={{
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
        background: '#0a0b10',
        width: '100%',
        height: '100%',
        color: 'white',
        fontSize: 32,
      }}>
        Post not found
      </div>,
      { ...size }
    );
  }

  return new ImageResponse(
    <div
      style={{
        display: 'flex',
        flexDirection: 'column',
        width: '100%',
        height: '100%',
        padding: 60,
        background: 'linear-gradient(135deg, #0a0b10 0%, #0f1015 100%)',
      }}
    >
      <span style={{ color: '#71717a', fontSize: 24 }}>
        {formatDate(post.publishedAt)}
      </span>

      <h1 style={{
        display: 'flex',
        color: 'white',
        fontSize: 64,
        fontWeight: 600,
        marginTop: 40,
        lineHeight: 1.2,
      }}>
        {post.title}
      </h1>

      <p style={{
        display: 'flex',
        color: '#a1a1aa',
        fontSize: 28,
        marginTop: 24,
      }}>
        {post.description}
      </p>

      <span style={{
        display: 'flex',
        color: '#71717a',
        fontSize: 24,
        marginTop: 'auto',
      }}>
        yoursite.com
      </span>
    </div>,
    { ...size }
  );
}

function formatDate(date: string) {
  return new Date(date).toLocaleDateString('en-US', {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
  });
}
```

With this file in place, Next.js automatically:
- Generates the image at build time for static routes
- Links it in the `<head>` as the `og:image` meta tag
- Serves it at `/blog/[slug]/opengraph-image`

{% callout type="info" title="About escaping" %}
Unlike raw SVG, JSX in ImageResponse handles HTML entity escaping automatically. Titles containing `<`, `>`, or `&` are safe without manual escaping.
{% /callout %}

### Option B: Route Handler Approach

If you need more control (query parameters, custom paths like `/og`), use a route handler:

```tsx {% title="app/blog/[slug]/og/route.tsx" %}
import { ImageResponse } from 'next/og';
import { NextRequest } from 'next/server';

export const runtime = 'edge';

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ slug: string }> }
) {
  const { slug } = await params;
  const post = await getPost(slug); // Your data fetching function

  if (!post) {
    return new Response('Not found', { status: 404 });
  }

  return new ImageResponse(
    <div
      style={{
        display: 'flex',
        flexDirection: 'column',
        width: '100%',
        height: '100%',
        padding: 60,
        background: 'linear-gradient(135deg, #0a0b10 0%, #0f1015 100%)',
      }}
    >
      {/* Same JSX as above */}
    </div>,
    {
      width: 1200,
      height: 630,
    }
  );
}
```

Then reference it in your page's metadata:

```tsx {% title="app/blog/[slug]/page.tsx" %}
import { 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,
    description: post.description,
    openGraph: {
      images: [`/blog/${slug}/og`],
    },
  };
}
```

### Loading Custom Fonts with ImageResponse

To use custom fonts, load them in your image component:

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

export const runtime = 'edge';

export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
  // Font must be fetched inside the function, not at module level
  const interBold = fetch(
    new URL('./Inter-Bold.ttf', import.meta.url)
  ).then((res) => res.arrayBuffer());

  const { slug } = await params;
  const post = await getPost(slug);

  return new ImageResponse(
    <div style={{ fontFamily: 'Inter', /* ... */ }}>
      {post.title}
    </div>,
    {
      width: 1200,
      height: 630,
      fonts: [
        {
          name: 'Inter',
          data: await interBold,
          style: 'normal',
          weight: 700,
        },
      ],
    }
  );
}
```

Place your font file (`Inter-Bold.ttf`) in the same directory as your image component. Next.js bundles it automatically.

### ImageResponse Limitations

Satori (the rendering engine) supports a subset of CSS:

- **Supported**: Flexbox, basic typography, borders, shadows, gradients, absolute positioning
- **Not supported**: CSS Grid, `calc()`, CSS variables, `transform`, animations, advanced selectors

If your design hits these limits, consider Sharp.

## Approach 2: Sharp for Advanced Processing

Sharp gives you full control over image processing: format conversion, resizing, compositing, and more. The trade-off is more complex setup, especially for fonts on serverless platforms.

### Why Use Sharp?

- **WebP output**: ~75% smaller file sizes than PNG
- **Image manipulation**: Resize, crop, overlay images, apply filters
- **No JSX constraints**: Full SVG support without Satori's CSS limitations
- **Consistent output**: Same result across all environments (no Edge Runtime variance)

### Implementation

Here's the Sharp approach we use in [MakerKit](/courses/nextjs-turbo/introduction). Note the `[collection]` segment in the path, which organizes posts by category:

```tsx {% title="app/(marketing)/blog/[collection]/[slug]/og/route.tsx" %}
import { NextRequest } from 'next/server';
import path from 'node:path';

export const dynamic = 'force-dynamic';

export async function GET(
  _: NextRequest,
  { params }: { params: Promise<{ collection: string; slug: string }> }
) {
  const { slug } = await params;
  const post = await getPost(slug);

  if (!post) {
    return new Response('Not found', { status: 404 });
  }

  const dateString = new Date(post.publishedAt).toLocaleDateString('en-US', {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
  });

  // Configure fonts for Vercel
  const fontsPath = path.resolve(process.cwd(), 'fonts');
  process.env.FONTCONFIG_FILE = path.resolve(fontsPath, 'fonts.conf');
  process.env.FONTCONFIG_PATH = fontsPath;

  const svg = generateOGSvg({
    date: dateString,
    title: post.title,
    description: post.description ?? '',
  });

  try {
    const { default: sharp } = await import('sharp');
    const image = await sharp(Buffer.from(svg)).webp().toBuffer();

    return new Response(image, {
      headers: {
        'Content-Type': 'image/webp',
        'Cache-Control': 'public, max-age=31536000, immutable',
      },
    });
  } catch (error) {
    console.error('OG image generation failed:', error);
    return new Response('Image generation failed', { status: 500 });
  }
}
```

### The SVG Template

Sharp renders SVG to raster images. Here's the template function with proper text wrapping and XML escaping:

```tsx
function generateOGSvg({
  date = '',
  title = '',
  description = '',
}: {
  date?: string;
  title?: string;
  description?: string;
}) {
  const titleLines = wrapText(title, 40);
  const descriptionLines = wrapText(description, 65);

  // Adjust description position based on title length
  const descriptionTop = title.length > 40 ? 220 : 200;

  return `
<svg viewBox="0 0 800 400" xmlns="http://www.w3.org/2000/svg">
  <defs>
    <style>
      @font-face {
        font-family: 'Inter';
        src: url('./fonts/Inter.ttf');
      }
      text { font-family: Inter, sans-serif; }
    </style>
    <linearGradient id="bg" x1="0" y1="1" x2="1" y2="1">
      <stop offset="0%" stop-color="#0a0b10"/>
      <stop offset="100%" stop-color="#0f1015"/>
    </linearGradient>
  </defs>

  <rect width="800" height="400" fill="#0a0b10"/>
  <rect x="21" y="21" width="758" height="358" rx="23" fill="url(#bg)"/>

  <text x="60" y="60" fill="#71717a" font-size="18">${escapeXml(date)}</text>

  <text fill="white" font-size="36" font-weight="600">
    ${titleLines.map((line, i) =>
      `<tspan x="60" y="${125 + i * 32}">${escapeXml(line)}</tspan>`
    ).join('')}
  </text>

  ${descriptionLines.map((line, i) => `
    <text x="60" y="${descriptionTop + i * 28}" fill="#a1a1aa" font-size="22">
      ${escapeXml(line)}
    </text>
  `).join('')}

  <text x="60" y="360" fill="#71717a" font-size="20">yoursite.com</text>
</svg>`;
}

function wrapText(text: string, maxLength: number): string[] {
  if (!text) return [];

  const words = text.split(' ');
  const lines: string[] = [];
  let currentLine: string[] = [];
  let currentLength = 0;

  for (const word of words) {
    if (currentLength + word.length + 1 <= maxLength || currentLine.length === 0) {
      currentLine.push(word);
      currentLength += word.length + 1;
    } else {
      lines.push(currentLine.join(' '));
      currentLine = [word];
      currentLength = word.length;
    }
  }

  if (currentLine.length > 0) {
    lines.push(currentLine.join(' '));
  }

  return lines;
}

function escapeXml(text: string): string {
  return text
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}
```

### Font Configuration for Vercel

Sharp uses system fonts, which aren't available in Vercel's serverless environment. You need to bundle fonts and configure fontconfig:

1. Create a `fonts` directory in your project root (or `apps/web/fonts` for monorepos)
2. Add your font files (e.g., `Inter.ttf`)
3. Create `fonts/fonts.conf`:

```xml {% title="fonts/fonts.conf" %}
<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
<fontconfig>
  <dir>/var/task/apps/web/fonts/</dir>
  <cachedir>/tmp/fonts-cache/</cachedir>
  <config></config>
</fontconfig>
```

The path `/var/task/apps/web/fonts/` matches Vercel's deployment structure for a monorepo. Adjust if your app lives elsewhere (e.g., `/var/task/fonts/` for single-app repos).

## Linking OG Images in Metadata

Whichever approach you use, link the image in your page metadata:

```tsx {% title="app/blog/[slug]/page.tsx" %}
import { Metadata } from 'next';

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

  return {
    title: post.title,
    description: post.description,
    openGraph: {
      title: post.title,
      description: post.description,
      type: 'article',
      publishedTime: post.publishedAt,
      images: [
        {
          url: `${siteUrl}/blog/${slug}/og`,
          width: 1200,
          height: 630,
          alt: post.title,
        },
      ],
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.description,
      images: [`${siteUrl}/blog/${slug}/og`],
    },
  };
}
```

## Common Pitfalls

These issues have cost us debugging hours. Learn from our mistakes:

1. **Satori silently ignores unsupported CSS**: If your layout looks broken, check that you're not using CSS Grid, `calc()`, or CSS variables. Satori doesn't error; it just ignores them. Stick to flexbox.

2. **Every element needs `display: flex`**: Satori's default is different from browsers. Add `display: 'flex'` to every container element, even `<span>` and `<p>`.

3. **Fonts must be fetched inside the function**: In Edge Runtime, you can't load fonts at module scope. The `fetch()` for font data must happen inside your default export function.

4. **Edge Runtime has no `fs` module**: If you're used to `fs.readFileSync` for loading fonts, that won't work in Edge Runtime. Use `fetch()` with `import.meta.url` instead.

5. **Sharp requires dynamic import on Vercel**: Import Sharp dynamically (`await import('sharp')`) to avoid bundling issues in serverless environments.

6. **ImageResponse bundle limit is 500KB**: This includes fonts, images, and all code. If you exceed it, you'll get cryptic deployment errors. Keep fonts minimal (one weight, one style).

## Testing and Verification

### Local Testing

Visit the OG route directly to see the rendered image:

```bash
# Start your dev server
pnpm dev

# Check the image in your browser
open http://localhost:3000/blog/your-post/og
```

Verify the response headers are correct:

```bash
curl -I http://localhost:3000/blog/your-post/og

# Expected output includes:
# Content-Type: image/webp (for Sharp) or image/png (for ImageResponse)
# Cache-Control: public, max-age=31536000, immutable
```

### Debugging Tools

Social platforms cache OG images aggressively. Use these tools to validate and refresh:

- [Facebook Sharing Debugger](https://developers.facebook.com/tools/debug/) - Shows what Facebook sees, lets you clear cache
- [Twitter Card Validator](https://cards-dev.twitter.com/validator) - Validates Twitter card markup
- [LinkedIn Post Inspector](https://www.linkedin.com/post-inspector/) - Tests LinkedIn previews
- [opengraph.xyz](https://www.opengraph.xyz) - Universal OG preview tool

### Common Issues

**Image not updating after changes**: Social platforms cache images. Use the debugger tools above to force a refresh, or add a cache-busting query parameter during development.

**Fonts not rendering on Vercel (Sharp)**: Check that `fonts.conf` has the correct path and that font files are included in your build. Run `vercel build` locally to verify the fonts directory is in `.vercel/output`.

**ImageResponse returning blank image**: Satori has strict requirements. Make sure all elements have explicit `display: 'flex'`, and avoid unsupported CSS.

**Wrong image dimensions**: OG images should be 1200x630 for optimal display. Twitter also accepts 1200x600. Using other dimensions may cause cropping.

## Caching Strategies

### Long-term caching for static content

OG images for blog posts rarely change. Cache them for a year:

```tsx
headers: {
  'Cache-Control': 'public, max-age=31536000, immutable',
}
```

### Revalidation for dynamic content

For content that updates (user profiles, dashboards), use stale-while-revalidate:

```tsx
headers: {
  'Cache-Control': 'public, s-maxage=86400, stale-while-revalidate=604800',
}
```

This serves cached images for 24 hours, then revalidates in the background for up to a week.

### Pre-generation at build time

For static content, pre-generate OG images during `next build`:

```tsx {% title="app/blog/[slug]/opengraph-image.tsx" %}
export async function generateStaticParams() {
  const posts = await getAllPosts();
  return posts.map((post) => ({ slug: post.slug }));
}
```

This eliminates runtime generation entirely for known routes.

{% faq
   title="Frequently Asked Questions"
   items=[
     {"question": "What size should OG images be?", "answer": "Use 1200x630 pixels for optimal display across all platforms. Twitter also works well with 1200x600. Avoid other dimensions as they may be cropped unpredictably."},
     {"question": "Why isn't my OG image showing on Twitter/Facebook?", "answer": "Social platforms cache OG images aggressively. Use their debugging tools (Facebook Sharing Debugger, Twitter Card Validator) to force a cache refresh. Also verify your meta tags are correct using browser DevTools."},
     {"question": "How do I handle fonts on Vercel with Sharp?", "answer": "Create a fonts directory with your font files and a fonts.conf file pointing to the Vercel deployment path (/var/task/...). Set FONTCONFIG_FILE and FONTCONFIG_PATH environment variables before rendering."},
     {"question": "Do I need both og:image and twitter:image?", "answer": "Yes, for best compatibility. While Twitter can fall back to og:image, explicitly setting twitter:image ensures your images display correctly with the summary_large_image card type."}
   ]
/%}

## Next Steps

OG images are one piece of the [complete SEO guide for developers](/blog/saas/seo-for-developers). For a production Next.js setup, see our guide on [App Router project structure](/blog/tutorials/nextjs-app-router-project-structure), which covers where these files should live in a scalable application.

MakerKit implements this OG image pattern using Sharp. Check the [CMS and content documentation](/docs/next-supabase-turbo/content/cms-api) to see how it integrates with the metadata system.
