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.

·Matija Žiberna·
Complete Guide: Dynamic OG Image Generation for Next.js 15

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
Enjoyed this article?
Subscribe to my newsletter for more insights and tutorials.
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.

You might be interested in