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.

·Matija Žiberna·
How to Automatically Generate Unique OG Images for Every Page in Next.js 15.4+

⚡ Next.js Implementation Guides

In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.

No spam. Unsubscribe anytime.

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:

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

  1. Image Dimensions: Stick to 1200x630 for optimal compatibility
  2. File Size: Keep generated images under 1MB
  3. Text Length: Limit titles to ~60 characters, subtitles to ~120
  4. Error Handling: Always provide fallback images
  5. Performance: Use Edge Runtime for faster generation
  6. SEO: Include both OpenGraph and Twitter Card meta tags
  7. 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!

5

Frequently Asked Questions

Comments

Leave a Comment

Your email will not be published

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.