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.

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.

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.

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

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:

FeatureImageResponseSharp
Output formatPNG onlyPNG, WebP, JPEG, AVIF
Typical file size~180KB~45KB (WebP)
RuntimeEdge RuntimeNode.js Runtime
Layout engineSatori (flexbox only)Full SVG support
Cold start~100ms~300ms
Image manipulationNoYes (resize, crop, overlay)
Setup complexityLowMedium (fonts config)

MakerKit uses Sharp because we wanted WebP output for smaller file sizes. But for most new projects, ImageResponse is the right choice.

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

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.

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:

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

Unlike raw SVG, JSX in ImageResponse handles HTML entity escaping automatically. Titles containing <, >, or & are safe without manual escaping.

Option B: Route Handler Approach

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

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:

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:

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. Note the [collection] segment in the path, which organizes posts by category:

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:

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:

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:

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:

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

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:

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:

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

Revalidation for dynamic content

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

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:

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.

Frequently Asked Questions

What size should OG images be?
Use 1200x630 pixels for optimal display across all platforms. Twitter also works well with 1200x600. Avoid other dimensions as they may be cropped unpredictably.
Why isn't my OG image showing on Twitter/Facebook?
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.
How do I handle fonts on Vercel with Sharp?
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.
Do I need both og:image and twitter:image?
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. For a production Next.js setup, see our guide on 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 to see how it integrates with the metadata system.

Some other posts you might like...