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:
- Create
app/blog/[slug]/opengraph-image.tsx - Export a default function returning
ImageResponsewith your JSX - 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:
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:
| 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 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.
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 theog:imagemeta 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, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"');}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:
- Create a
fontsdirectory in your project root (orapps/web/fontsfor monorepos) - Add your font files (e.g.,
Inter.ttf) - 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:
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.Every element needs
display: flex: Satori's default is different from browsers. Adddisplay: 'flex'to every container element, even<span>and<p>.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.Edge Runtime has no
fsmodule: If you're used tofs.readFileSyncfor loading fonts, that won't work in Edge Runtime. Usefetch()withimport.meta.urlinstead.Sharp requires dynamic import on Vercel: Import Sharp dynamically (
await import('sharp')) to avoid bundling issues in serverless environments.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 serverpnpm dev# Check the image in your browseropen http://localhost:3000/blog/your-post/ogVerify 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, immutableDebugging Tools
Social platforms cache OG images aggressively. Use these tools to validate and refresh:
- Facebook Sharing Debugger - Shows what Facebook sees, lets you clear cache
- Twitter Card Validator - Validates Twitter card markup
- LinkedIn Post Inspector - Tests LinkedIn previews
- 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:
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?
Why isn't my OG image showing on Twitter/Facebook?
How do I handle fonts on Vercel with Sharp?
Do I need both og:image and twitter:image?
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.