---
title: "How to Automatically Generate Unique OG Images for Every Page in Next.js 15.4+"
slug: "complete-guide-dynamic-og-image-generation-for-next-js-15"
published: "2025-05-24"
updated: "2025-12-26"
validated: "2025-10-20"
categories:
  - "Next.js"
tags:
  - "nextjs og image"
  - "dynamic og images"
  - "vercel og image"
  - "social media preview"
  - "open graph images"
  - "nextjs metadata"
  - "automatic og generation"
  - "seo images"
  - "twitter cards"
  - "nextjs 15 seo"
llm-intent: "reference"
audience-level: "beginner"
framework-versions:
  - "next.js@15.4+"
  - "@vercel/og@latest"
  - "typescript@>=4.9"
  - "node@>=18"
status: "stable"
llm-purpose: "Learn how to implement dynamic Open Graph image generation in Next.js 15.4+ using Vercel’s OG Image tool."
llm-prereqs:
  - "General familiarity with the article topic"
llm-outputs:
  - "Completed outcome: Learn how to implement dynamic Open Graph image generation in Next.js 15.4+ using Vercel’s OG Image tool."
---

**Summary Triples**
- (OG API route, file path, app/api/og/route.tsx)
- (OG API route, runtime, export const runtime = 'edge' (use Edge Runtime for fast cold starts))
- (Request handling, extracts, query params (type, title, subtitle, background, font, etc.) with defaults)
- (Image generation, uses, ImageResponse from next/og to render JSX and return a PNG with width/height)
- (Font handling, requires, fetching fonts (fetch/ArrayBuffer) and passing them to ImageResponse options)
- (Metadata injection, uses, generateMetadata() in App Router to set openGraph.images to the OG API URL with encoded params)
- (URL encoding, must, encode query parameters (encodeURIComponent) to avoid invalid URLs)
- (Caching/Performance, recommendation, use Edge runtime, cache fonts, and set cache-control where appropriate)
- (Multiple sizes, support, generate multiple ImageResponse sizes and include them in openGraph.images for platform compatibility)
- (Deployment, best on, Vercel (Edge functions & @vercel/og integration) for production)

### {GOAL}
Learn how to implement dynamic Open Graph image generation in Next.js 15.4+ using Vercel’s OG Image tool.

### {PREREQS}
- General familiarity with the article topic

### {STEPS}
1. Step 1: Install Required Dependencies
2. Step 2: Create the Dynamic OG Image API Endpoint
3. Step 3: Add Type-Safe Utilities for OG Image Generation
4. Step 4: Integrate OG Metadata into Page Files
5. Step 5: Set Environment Variables and Robots.txt
6. Step 6: Test OG Images on Social Media Debuggers
7. Step 7: Improve Performance with Caching and Edge Runtime

<!-- llm:goal="Learn how to implement dynamic Open Graph image generation in Next.js 15.4+ using Vercel’s OG Image tool." -->
<!-- llm:prereq="General familiarity with the article topic" -->
<!-- llm:output="Completed outcome: Learn how to implement dynamic Open Graph image generation in Next.js 15.4+ using Vercel’s OG Image tool." -->

# How to Automatically Generate Unique OG Images for Every Page in Next.js 15.4+
> Learn how to implement dynamic Open Graph image generation in Next.js 15.4+ using Vercel’s OG Image tool.
Matija Žiberna · 2025-05-24

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

```bash
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`**

```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`**

```typescript
// 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`**

```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`**

```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`**

```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`**:

```env
NEXT_PUBLIC_BASE_URL=https://yourdomain.com
# For local development, use: http://localhost:3000
```

Update your **`robots.txt`** in the `public` folder:

```txt
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:
```bash
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](https://developers.facebook.com/tools/debug/)
- **Twitter**: [Card Validator](https://cards-dev.twitter.com/validator)
- **LinkedIn**: [Post Inspector](https://www.linkedin.com/post-inspector/)
- **General**: [OpenGraph.xyz](https://www.opengraph.xyz/)

# Advanced Enhancements

## Custom Fonts
Add custom fonts to your OG images:

```tsx
// 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:

```tsx
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:

```tsx
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](https://developers.facebook.com/tools/debug/) or [Twitter Card Validator](https://cards-dev.twitter.com/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!

## LLM Response Snippet
```json
{
  "goal": "Learn how to implement dynamic Open Graph image generation in Next.js 15.4+ using Vercel’s OG Image tool.",
  "responses": [
    {
      "question": "What does the article \"How to Automatically Generate Unique OG Images for Every Page in Next.js 15.4+\" cover?",
      "answer": "Learn how to implement dynamic Open Graph image generation in Next.js 15.4+ using Vercel’s OG Image tool."
    }
  ]
}
```