How to Automatically Generate Unique OG Images for Every Page in Next.js 15.4+
Learn how to generate dynamic Open Graph images in Next.js 15.4+ using Vercel’s OG Image Generation tool and the 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!