March 29, 2026 · 10 min read

Dynamic OG Images in Next.js App Router (Server Components + Metadata API)

Next.js 14 and 15 changed how metadata works. The App Router gives you generateMetadata(), Server Components, and dynamic route segments—all running on the server by default. This guide shows you exactly how to wire up dynamic Open Graph images using these primitives, with a URL-based approach that is dramatically simpler than writing @vercel/og edge functions.

OG image preview for this article generated by OGPeek

Why OG Images Matter for Next.js Apps

When someone shares a link to your Next.js app on Twitter, LinkedIn, Slack, or Discord, the platform fetches your page and looks for Open Graph meta tags. The og:image tag determines the social preview card that appears alongside your URL. Pages with branded, descriptive cards get 2–3x higher click-through rates than bare links with no image or a generic fallback.

For content-heavy Next.js apps—blogs, documentation sites, SaaS dashboards with shareable pages, e-commerce product pages—every route needs its own unique OG image. Generating these manually in Figma does not scale. You need an automated solution that creates a unique image for every page based on its title, category, or other metadata.

There are two fundamentally different approaches to this problem in the Next.js ecosystem:

  1. Self-hosted rendering with @vercel/og (also available as next/og), where you write JSX templates, deploy edge functions, and manage the image generation infrastructure yourself.
  2. API-based generation with a service like OGPeek, where you construct a URL with parameters and the service returns a PNG. No code to deploy, no edge functions to maintain.

This guide focuses on the second approach because it is faster to set up, easier to maintain, and works identically whether you deploy to Vercel, AWS, Cloudflare, a VPS, or a Docker container.

How the Metadata API Works in the App Router

The Next.js App Router (introduced in Next.js 13.4, stable since Next.js 14) replaced the old <Head> component pattern from the Pages Router with a dedicated Metadata API. There are two ways to define metadata for a route.

Static Metadata with the metadata Object

For pages where the metadata does not depend on runtime data, you export a metadata object directly from your page.tsx or layout.tsx:

// app/about/page.tsx

export const metadata = {
  title: 'About Us',
  description: 'Learn about our team and what we build.',
  openGraph: {
    title: 'About Us',
    description: 'Learn about our team and what we build.',
    images: [
      {
        url: 'https://ogpeek.com/api/v1/og'
          + '?title=About+Us'
          + '&subtitle=Our+Team'
          + '&template=gradient'
          + '&theme=midnight'
          + '&brandColor=%23F59E0B',
        width: 1200,
        height: 630,
        alt: 'About Us — OG Image',
      },
    ],
  },
  twitter: {
    card: 'summary_large_image',
  },
};

export default function AboutPage() {
  return <main>{/* page content */}</main>;
}

Next.js reads this object at build time (for static routes) or at request time (for dynamic routes) and renders the corresponding <meta> tags in the HTML <head>. No client-side JavaScript is involved. The OGPeek URL is embedded directly in the HTML that social crawlers see.

Dynamic Metadata with generateMetadata()

For pages where the OG image depends on data—a blog post title pulled from a CMS, a product name from your database, a user profile—you export an async generateMetadata() function instead:

// app/blog/[slug]/page.tsx

import { getPost } from '@/lib/posts';
import type { Metadata } from 'next';

type Props = {
  params: Promise<{ slug: string }>;
};

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);

  const ogUrl = new URL('https://ogpeek.com/api/v1/og');
  ogUrl.searchParams.set('title', post.title);
  ogUrl.searchParams.set('subtitle', post.category);
  ogUrl.searchParams.set('template', 'gradient');
  ogUrl.searchParams.set('theme', 'midnight');
  ogUrl.searchParams.set('brandColor', '#F59E0B');

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: 'article',
      publishedTime: post.publishedAt,
      images: [
        { url: ogUrl.toString(), width: 1200, height: 630 },
      ],
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.excerpt,
      images: [ogUrl.toString()],
    },
  };
}

export default async function BlogPost({ params }: Props) {
  const { slug } = await params;
  const post = await getPost(slug);

  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  );
}

Key detail: In Next.js 15, params is a Promise and must be awaited. In Next.js 14, it was a plain object. The code above uses the Next.js 15 pattern. If you are on Next.js 14, remove the await and the Promise type wrapper.

Notice we use the URL constructor and searchParams.set() to build the OG image URL. This handles encoding automatically—titles with ampersands, hash symbols, or other special characters are encoded correctly without manual encodeURIComponent() calls.

Dynamic Route Segments: One Image Per Page

The real power of this approach shows up with dynamic route segments. Consider a SaaS app with user-generated content—a tool directory, a template marketplace, a job board. Each item has its own page and needs its own OG image.

Catch-All Routes

// app/tools/[...slug]/page.tsx

import { getTool } from '@/lib/tools';
import type { Metadata } from 'next';

type Props = {
  params: Promise<{ slug: string[] }>;
};

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const tool = await getTool(slug.join('/'));

  const ogUrl = new URL('https://ogpeek.com/api/v1/og');
  ogUrl.searchParams.set('title', tool.name);
  ogUrl.searchParams.set('subtitle', `${tool.category} — ${tool.pricing}`);
  ogUrl.searchParams.set('template', 'gradient');
  ogUrl.searchParams.set('theme', 'midnight');
  ogUrl.searchParams.set('brandColor', tool.brandColor || '#F59E0B');

  return {
    title: `${tool.name} — Tool Directory`,
    openGraph: {
      images: [{ url: ogUrl.toString(), width: 1200, height: 630 }],
    },
    twitter: { card: 'summary_large_image' },
  };
}

export default async function ToolPage({ params }: Props) {
  const { slug } = await params;
  const tool = await getTool(slug.join('/'));
  return <main>{/* render tool details */}</main>;
}

Parallel Routes and Layouts

The App Router lets you define metadata in layout.tsx files too. A common pattern is to set default OG configuration in your root layout, then override specific fields in nested pages:

// app/layout.tsx — root layout with default OG settings

import type { Metadata } from 'next';

export const metadata: Metadata = {
  metadataBase: new URL('https://yoursite.com'),
  title: {
    template: '%s — YourSite',
    default: 'YourSite — Build faster',
  },
  openGraph: {
    siteName: 'YourSite',
    locale: 'en_US',
    type: 'website',
    images: [
      {
        url: 'https://ogpeek.com/api/v1/og'
          + '?title=YourSite'
          + '&subtitle=Build+faster'
          + '&template=gradient'
          + '&theme=midnight'
          + '&brandColor=%23F59E0B',
        width: 1200,
        height: 630,
      },
    ],
  },
};

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

When a nested page exports its own generateMetadata() that returns openGraph.images, it overrides the root layout's default. Pages that do not define their own OG image inherit the root layout's fallback automatically. This means every page in your app has an OG image with zero additional effort.

OGPeek vs @vercel/og: A Practical Comparison

Vercel ships @vercel/og (also available as next/og in newer versions) for generating OG images using edge functions. It is a capable tool, but it solves the problem in a fundamentally different way than OGPeek. Here is a concrete comparison.

What @vercel/og Requires

To generate a dynamic OG image with @vercel/og, you need to:

  1. Create a dedicated route handler at app/api/og/route.tsx
  2. Write a JSX template using Satori (a subset of CSS—no flexbox gap, limited font support)
  3. Load and embed custom fonts as ArrayBuffers
  4. Deploy the route as an edge function
  5. Point your og:image meta tag at the route handler URL

Here is what that looks like:

// app/api/og/route.tsx — the @vercel/og approach

import { ImageResponse } from 'next/og';

export const runtime = 'edge';

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const title = searchParams.get('title') ?? 'Default Title';

  // You need to load fonts manually
  const fontData = await fetch(
    new URL('./Inter-Bold.ttf', import.meta.url)
  ).then((res) => res.arrayBuffer());

  return new ImageResponse(
    (
      <div
        style={{
          background: 'linear-gradient(135deg, #0f0f0f, #1a1a2e)',
          width: '100%',
          height: '100%',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
          padding: '48px',
        }}
      >
        <h1 style={{ color: 'white', fontSize: 64, fontFamily: 'Inter' }}>
          {title}
        </h1>
      </div>
    ),
    {
      width: 1200,
      height: 630,
      fonts: [{ name: 'Inter', data: fontData, style: 'normal' }],
    }
  );
}

What OGPeek Requires

With OGPeek, you skip all of that. There is no route handler, no JSX template, no font loading, and no edge function. You build a URL:

// That's it. This is the entire "setup."
const ogUrl = new URL('https://ogpeek.com/api/v1/og');
ogUrl.searchParams.set('title', post.title);
ogUrl.searchParams.set('subtitle', post.category);
ogUrl.searchParams.set('template', 'gradient');
ogUrl.searchParams.set('theme', 'midnight');
ogUrl.searchParams.set('brandColor', '#F59E0B');
Aspect @vercel/og OGPeek
Setup complexity Route handler + JSX + fonts One URL
Code to maintain 50–100 lines of JSX/CSS 0 lines
CSS support Subset (Satori limitations) Full (server-rendered)
Custom fonts Manual ArrayBuffer loading Built-in font options
Hosting requirement Edge runtime (Vercel, Cloudflare) None (external API)
Framework lock-in Next.js only Any framework, any language
Templates Build your own from scratch Pre-built, tested templates
Customization ceiling Unlimited (custom JSX) Template parameters
Free tier Vercel hobby limits apply 50 images/day, no signup

When does @vercel/og make more sense? If you need pixel-perfect custom layouts that go beyond what any template system can offer—for example, embedding user avatars, charts, or complex multi-column designs—then @vercel/og gives you full control. For the vast majority of use cases (branded title cards for blog posts, docs pages, product pages), OGPeek is faster and simpler.

A Complete, Copy-Paste Example

Here is a full, working Next.js 15 App Router project structure with OGPeek OG images wired into every route. You can copy these files directly into your project.

Step 1: Create a Shared OG Helper

// lib/og.ts

const OGPEEK_BASE = 'https://ogpeek.com/api/v1/og';

export function ogImageUrl(
  title: string,
  subtitle?: string,
  options?: {
    brandColor?: string;
    template?: string;
    theme?: string;
  }
): string {
  const url = new URL(OGPEEK_BASE);
  url.searchParams.set('title', title);
  if (subtitle) url.searchParams.set('subtitle', subtitle);
  url.searchParams.set('template', options?.template ?? 'gradient');
  url.searchParams.set('theme', options?.theme ?? 'midnight');
  url.searchParams.set('brandColor', options?.brandColor ?? '#F59E0B');
  return url.toString();
}

Step 2: Set Defaults in Root Layout

// app/layout.tsx

import type { Metadata } from 'next';
import { ogImageUrl } from '@/lib/og';

export const metadata: Metadata = {
  metadataBase: new URL('https://yoursite.com'),
  title: {
    template: '%s — YourSite',
    default: 'YourSite',
  },
  openGraph: {
    siteName: 'YourSite',
    images: [
      {
        url: ogImageUrl('YourSite', 'Build faster, ship sooner'),
        width: 1200,
        height: 630,
      },
    ],
  },
  twitter: {
    card: 'summary_large_image',
  },
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

Step 3: Use in Dynamic Blog Routes

// app/blog/[slug]/page.tsx

import { getPost, getAllPosts } from '@/lib/posts';
import { ogImageUrl } from '@/lib/og';
import type { Metadata } from 'next';

type Props = {
  params: Promise<{ slug: string }>;
};

// Optional: pre-generate pages at build time
export async function generateStaticParams() {
  const posts = await getAllPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: 'article',
      publishedTime: post.publishedAt,
      authors: [post.author],
      images: [
        {
          url: ogImageUrl(post.title, post.category),
          width: 1200,
          height: 630,
          alt: post.title,
        },
      ],
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.excerpt,
      images: [ogImageUrl(post.title, post.category)],
    },
  };
}

export default async function BlogPost({ params }: Props) {
  const { slug } = await params;
  const post = await getPost(slug);

  return (
    <article className="prose">
      <h1>{post.title}</h1>
      <time dateTime={post.publishedAt}>{post.publishedAt}</time>
      <p>{post.content}</p>
    </article>
  );
}

Step 4: Product Pages with Search Params

For pages that rely on search parameters rather than route segments, use searchParams in generateMetadata():

// app/search/page.tsx

import { ogImageUrl } from '@/lib/og';
import type { Metadata } from 'next';

type Props = {
  searchParams: Promise<{ q?: string }>;
};

export async function generateMetadata({
  searchParams,
}: Props): Promise<Metadata> {
  const { q } = await searchParams;
  const query = q || 'Search';

  return {
    title: `Results for "${query}"`,
    openGraph: {
      images: [
        {
          url: ogImageUrl(
            `Search: ${query}`,
            'Find what you need'
          ),
          width: 1200,
          height: 630,
        },
      ],
    },
  };
}

export default async function SearchPage({ searchParams }: Props) {
  const { q } = await searchParams;
  return <main>{/* search results */}</main>;
}

Server Components and Metadata: How It Fits Together

A common point of confusion: generateMetadata() is not a React component. It is a server-only function that Next.js calls during rendering to collect metadata. It runs in the same Server Component context as your page component, which means:

This last point is important. You might worry that fetching data in both generateMetadata() and the page component doubles your API calls. It does not. Next.js uses the React cache() function internally, and fetch() calls are automatically deduplicated within a single render pass.

Performance note: Because the OGPeek URL is just a string embedded in your HTML, it adds zero overhead to your page load. The image is only fetched when a social platform crawls your page to build a preview card. Your users never download the OG image during normal browsing.

Testing Your OG Images

After setting up generateMetadata() with your OGPeek URLs, you want to verify that the images render correctly. Here are the tools to use:

  1. Browser preview: Copy your OGPeek URL and paste it into a browser tab. You should see the rendered PNG immediately.
  2. View page source: Load your Next.js page in a browser, view source, and search for og:image. Verify the meta tag contains the correct URL.
  3. Twitter Card Validator: Paste your page URL at cards-dev.twitter.com/validator to see how the card will render on Twitter/X.
  4. Facebook Sharing Debugger: Use developers.facebook.com/tools/debug/ to test how your OG tags render on Facebook and LinkedIn.
  5. Slack: Paste your URL in a Slack message to yourself. Slack renders OG cards immediately.

If the image does not appear, the most common causes are: missing metadataBase in your root layout (relative URLs will not resolve), the OGPeek URL being malformed (check encoding), or social platform caches showing a stale version (use the debugger tools to force a re-fetch).

Common Pitfalls to Avoid

A few mistakes that trip up developers when setting up OG images in the App Router:

Pricing for Next.js Developers

OGPeek is priced for developers who want to ship fast without managing image infrastructure:

Most Next.js blogs and documentation sites fit within the free tier. Social platforms cache OG images aggressively, so the same URL is only fetched once until the cache expires—actual API usage is typically much lower than your page view count.

Add OG images to your Next.js app in 5 minutes

No signup, no API key, no edge functions. Just a URL. 50 free images per day.

Try the playground →

Wrapping Up

The Next.js App Router makes dynamic OG images straightforward. The generateMetadata() function gives you a clean, server-side place to build your OG image URL based on route data. Server Components ensure the metadata is rendered into the HTML before it reaches the client. Dynamic route segments mean every page in your app can have a unique social preview card.

By using OGPeek's URL-based API instead of building your own image generation pipeline with @vercel/og, you eliminate an entire category of infrastructure: no edge functions, no JSX templates, no font loading, no Satori CSS limitations. You get a single URL that returns a polished PNG, and you set it in your metadata. That is the entire integration.

For more on OG images, explore our complete API guide, the image size reference, our detailed OGPeek vs Vercel OG comparison, or the guide to automating OG images for SaaS blogs.