Complete Guide: Dynamic OG Image Generation for Next.js 15
How to generate dynamic Open Graph images in Next.js 15 using Vercel's OG Image Generation tool and App Router.

When sharing links on social media (Facebook, Twitter, LinkedIn), the preview card (image, title, description) heavily influences click-through rates. Most websites show static OG images that don't reflect the actual page content, missing opportunities for engagement.
What we want to achieve:
- Dynamic OG images tailored to each page type (homepage, blog posts, services, etc.)
- Contextual content (title, subtitle, background images)
- Fast generation using Edge Runtime
- SEO-optimized social sharing
Solution: @vercel/og + Next.js 15 App Router
We'll use:
- @vercel/og to generate images as React components (converted to PNG)
- generateMetadata() in App Router to inject dynamic OG images into meta tags
- Edge Runtime for lightning-fast image generation
- TypeScript for type safety and better developer experience
Complete Implementation
Step 1: Install Dependencies
npm install @vercel/og
# @vercel/og is included in Next.js 13.4+ App Router by default
Ensure you're using Next.js 15 with App Router.
Step 2: Create the OG Image API Route
Create the file structure:
app/
├── api/
│ └── og/
│ └── route.tsx
app/api/og/route.tsx
import { ImageResponse } from 'next/og';
import { NextRequest } from 'next/server';
// Use Edge Runtime for faster cold starts
export const runtime = 'edge';
// Define our supported page types
type PageType = 'homepage' | 'service' | 'blog' | 'blogArticle' | 'about' | 'contact';
export async function GET(req: NextRequest) {
try {
const { searchParams } = req.nextUrl;
// Extract parameters with defaults
const type = (searchParams.get('type') as PageType) || 'homepage';
const title = searchParams.get('title') || 'Welcome';
const subtitle = searchParams.get('subtitle') || '';
const backgroundImage = searchParams.get('image') || '';
// Build background image URL if provided
const imageUrl = backgroundImage
? `${process.env.NEXT_PUBLIC_BASE_URL || 'https://yourdomain.com'}/${backgroundImage}`
: null;
// Define styles based on page type
const getTypeStyles = (pageType: PageType) => {
const baseStyles = {
homepage: { bg: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', badge: '🏠 HOME' },
service: { bg: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)', badge: '🔧 SERVICES' },
blog: { bg: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)', badge: '📝 BLOG' },
blogArticle: { bg: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)', badge: '📖 ARTICLE' },
about: { bg: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)', badge: '👋 ABOUT' },
contact: { bg: 'linear-gradient(135deg, #a8edea 0%, #fed6e3 100%)', badge: '📧 CONTACT' },
};
return baseStyles[pageType] || baseStyles.homepage;
};
const styles = getTypeStyles(type);
return new ImageResponse(
(
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'space-between',
background: imageUrl ? `url(${imageUrl})` : styles.bg,
backgroundSize: 'cover',
backgroundPosition: 'center',
padding: '60px',
fontFamily: '"Inter", system-ui, sans-serif',
}}
>
{/* Overlay for better text readability when using background images */}
{imageUrl && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.4)',
}}
/>
)}
{/* Content */}
<div style={{ display: 'flex', flexDirection: 'column', zIndex: 1 }}>
{/* Badge */}
<div
style={{
background: 'rgba(255, 255, 255, 0.2)',
backdropFilter: 'blur(10px)',
borderRadius: '25px',
padding: '12px 24px',
fontSize: '24px',
fontWeight: '600',
color: 'white',
marginBottom: '40px',
border: '1px solid rgba(255, 255, 255, 0.3)',
}}
>
{styles.badge}
</div>
{/* Main Title */}
<div
style={{
fontSize: title.length > 50 ? '56px' : '72px',
fontWeight: '800',
color: 'white',
lineHeight: '1.1',
textShadow: '0 4px 8px rgba(0, 0, 0, 0.3)',
marginBottom: subtitle ? '20px' : '0',
maxWidth: '1000px',
}}
>
{title}
</div>
{/* Subtitle */}
{subtitle && (
<div
style={{
fontSize: '36px',
fontWeight: '400',
color: 'rgba(255, 255, 255, 0.9)',
lineHeight: '1.3',
maxWidth: '900px',
textShadow: '0 2px 4px rgba(0, 0, 0, 0.3)',
}}
>
{subtitle}
</div>
)}
</div>
{/* Bottom branding/domain */}
<div
style={{
display: 'flex',
alignItems: 'center',
fontSize: '24px',
color: 'rgba(255, 255, 255, 0.8)',
zIndex: 1,
}}
>
<div
style={{
width: '8px',
height: '8px',
borderRadius: '50%',
background: '#10b981',
marginRight: '12px',
}}
/>
yourdomain.com
</div>
</div>
),
{
width: 1200,
height: 630,
// Add fonts for better typography (optional but recommended)
fonts: [
{
name: 'Inter',
data: await fetch(
new URL('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap')
).then((res) => res.arrayBuffer()),
style: 'normal',
weight: 400,
},
],
}
);
} catch (error) {
console.error('Error generating OG image:', error);
// Return a fallback image on error
return new ImageResponse(
(
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
fontSize: '48px',
fontWeight: 'bold',
}}
>
Something went wrong
</div>
),
{ width: 1200, height: 630 }
);
}
}
Step 3: Create Type-Safe Utility Functions
Create the file: lib/og-image.ts
// Define supported page types
export type PageType = 'homepage' | 'service' | 'blog' | 'blogArticle' | 'about' | 'contact';
export interface OgImageParams {
type?: PageType;
title: string;
subtitle?: string;
image?: string; // filename relative to public folder
}
/**
* Generates a URL for dynamic OG image generation
* @param params - Configuration for the OG image
* @returns Complete URL for the OG image endpoint
*/
export function getOgImageUrl({
type = 'homepage',
title,
subtitle,
image
}: OgImageParams): string {
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://yourdomain.com';
const endpoint = `${baseUrl}/api/og`;
const params = new URLSearchParams({
type: type.toString(),
title: title.trim(),
});
if (subtitle?.trim()) {
params.append('subtitle', subtitle.trim());
}
if (image?.trim()) {
params.append('image', image.trim());
}
return `${endpoint}?${params.toString()}`;
}
/**
* Utility to truncate text for better OG image display
*/
export function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength).trim() + '...';
}
/**
* Generate metadata object with OG image for Next.js generateMetadata
*/
export function generateOgMetadata({
title,
description,
ogImageParams
}: {
title: string;
description: string;
ogImageParams: OgImageParams;
}) {
return {
title,
description,
openGraph: {
title,
description,
images: [
{
url: getOgImageUrl(ogImageParams),
width: 1200,
height: 630,
alt: title,
},
],
},
twitter: {
card: 'summary_large_image',
title,
description,
images: [getOgImageUrl(ogImageParams)],
},
};
}
Step 4: Implement in Different Page Types
Homepage Example
app/page.tsx
import { generateOgMetadata } from '@/lib/og-image';
export async function generateMetadata() {
return generateOgMetadata({
title: 'Your Business Name - Professional Services',
description: 'We provide top-quality services to help your business grow and succeed.',
ogImageParams: {
type: 'homepage',
title: 'Your Business Name',
subtitle: 'Professional Services That Deliver Results',
image: 'hero-background.jpg', // Optional: file in /public folder
},
});
}
export default function HomePage() {
return (
<div>
<h1>Welcome to Your Business</h1>
{/* Your homepage content */}
</div>
);
}
Blog Article Example
app/blog/[slug]/page.tsx
import { generateOgMetadata, truncateText } from '@/lib/og-image';
// Mock function - replace with your actual blog post fetching logic
async function getBlogPost(slug: string) {
// Your blog post fetching logic here
return {
title: 'Understanding ADHD: A Complete Guide',
excerpt: 'Learn about ADHD symptoms, diagnosis, and treatment options in this comprehensive guide.',
author: 'Dr. Jane Smith',
publishedAt: '2024-01-15',
featuredImage: 'adhd-guide-bg.jpg',
content: '...',
};
}
export async function generateMetadata({
params
}: {
params: { slug: string }
}) {
const post = await getBlogPost(params.slug);
return generateOgMetadata({
title: post.title,
description: post.excerpt,
ogImageParams: {
type: 'blogArticle',
title: truncateText(post.title, 60), // Ensure it fits nicely
subtitle: truncateText(post.excerpt, 120),
image: post.featuredImage,
},
});
}
export default async function BlogPostPage({
params
}: {
params: { slug: string }
}) {
const post = await getBlogPost(params.slug);
return (
<article>
<h1>{post.title}</h1>
<p>{post.excerpt}</p>
{/* Your blog post content */}
</article>
);
}
Service Page Example
app/services/[serviceSlug]/page.tsx
import { generateOgMetadata } from '@/lib/og-image';
const services = {
'web-development': {
title: 'Web Development Services',
description: 'Custom web development solutions for modern businesses.',
features: ['Responsive Design', 'Performance Optimization', 'SEO Ready'],
backgroundImage: 'web-dev-bg.jpg',
},
'digital-marketing': {
title: 'Digital Marketing Services',
description: 'Grow your online presence with our digital marketing expertise.',
features: ['SEO', 'Social Media', 'Content Marketing'],
backgroundImage: 'marketing-bg.jpg',
},
};
export async function generateMetadata({
params
}: {
params: { serviceSlug: string }
}) {
const service = services[params.serviceSlug as keyof typeof services];
if (!service) {
return { title: 'Service Not Found' };
}
return generateOgMetadata({
title: service.title,
description: service.description,
ogImageParams: {
type: 'service',
title: service.title,
subtitle: service.description,
image: service.backgroundImage,
},
});
}
export default function ServicePage({
params
}: {
params: { serviceSlug: string }
}) {
const service = services[params.serviceSlug as keyof typeof services];
if (!service) {
return <div>Service not found</div>;
}
return (
<div>
<h1>{service.title}</h1>
<p>{service.description}</p>
{/* Your service page content */}
</div>
);
}
Step 5: Environment Configuration
Add to your .env.local
:
NEXT_PUBLIC_BASE_URL=https://yourdomain.com # For local development, use: http://localhost:3000
Update your robots.txt
in the public
folder:
User-agent: * Allow: / # Allow OG image generation Allow: /api/og/* Sitemap: https://yourdomain.com/sitemap.xml
Testing Your Implementation
1. Local Testing
Start your development server:
npm run dev
Test the OG endpoint directly:
http://localhost:3000/api/og?type=homepage&title=Welcome&subtitle=This is our homepage
http://localhost:3000/api/og?type=blogArticle&title=My Blog Post&subtitle=This is a great article
2. Social Media Testing
Use these tools to test your OG images:
- Facebook: Sharing Debugger
- Twitter: Card Validator
- LinkedIn: Post Inspector
- General: OpenGraph.xyz
Advanced Enhancements
Custom Fonts
Add custom fonts to your OG images:
// In your route.tsx, add fonts to ImageResponse options
{
fonts: [
{
name: 'CustomFont',
data: await fetch(new URL('./assets/CustomFont.ttf', import.meta.url)).then(
(res) => res.arrayBuffer()
),
style: 'normal',
weight: 700,
},
],
}
Theme Support
Add light/dark theme support:
const theme = searchParams.get('theme') || 'light';
const isDark = theme === 'dark';
// Use in your styles
background: isDark ? 'linear-gradient(135deg, #1f2937 0%, #111827 100%)' : styles.bg,
color: isDark ? '#f3f4f6' : 'white',
Caching and Performance
Add caching headers in your route:
export async function GET(req: NextRequest) {
const response = new ImageResponse(/* ... */);
// Cache for 1 hour
response.headers.set('Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400');
return response;
}
Best Practices
- Image Dimensions: Stick to 1200x630 for optimal compatibility
- File Size: Keep generated images under 1MB
- Text Length: Limit titles to ~60 characters, subtitles to ~120
- Error Handling: Always provide fallback images
- Performance: Use Edge Runtime for faster generation
- SEO: Include both OpenGraph and Twitter Card meta tags
- Testing: Test across multiple social platforms before deploying
Troubleshooting
Common Issues:
- Images not loading: Check your
NEXT_PUBLIC_BASE_URL
environment variable - Fonts not rendering: Ensure font files are accessible and properly loaded
- Social platforms not showing new images: Clear their cache using platform-specific debugging tools
- Use the Facebook Debugger or Twitter Card Validator
- Build errors: Make sure you're using Next.js 13.4+ with App Router
Your Next.js 15 app now generates beautiful, dynamic OG images that will significantly improve your social media engagement and click-through rates!