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.
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:
- Self-hosted rendering with
@vercel/og(also available asnext/og), where you write JSX templates, deploy edge functions, and manage the image generation infrastructure yourself. - 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:
- Create a dedicated route handler at
app/api/og/route.tsx - Write a JSX template using Satori (a subset of CSS—no flexbox gap, limited font support)
- Load and embed custom fonts as ArrayBuffers
- Deploy the route as an edge function
- Point your
og:imagemeta 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:
- You can use
async/awaitto fetch data from databases, APIs, or the filesystem. - You can import server-only modules like database clients, and they will never be bundled for the browser.
- The function runs once per request (or once at build time for static pages). It does not re-run on client-side navigation—Next.js prefetches metadata during link hover.
- Next.js deduplicates fetch calls between
generateMetadata()and your page component. If both callgetPost(slug), the actual fetch happens only once.
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:
- Browser preview: Copy your OGPeek URL and paste it into a browser tab. You should see the rendered PNG immediately.
- 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. - Twitter Card Validator: Paste your page URL at
cards-dev.twitter.com/validatorto see how the card will render on Twitter/X. - Facebook Sharing Debugger: Use
developers.facebook.com/tools/debug/to test how your OG tags render on Facebook and LinkedIn. - 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:
- Forgetting metadataBase: If you use relative URLs anywhere in your metadata, Next.js needs
metadataBaseset in your root layout to resolve them. Always set it to your production domain. - URL encoding issues: Titles with
&,#, or+characters will break your OG image URL if not encoded. Use theURLAPI withsearchParams.set()as shown in the examples—it handles encoding automatically. - Missing image dimensions: Always include
width: 1200andheight: 630in your images array. Some platforms will not display the image without explicit dimensions. - Stale social platform caches: Twitter, Facebook, and LinkedIn cache OG images aggressively. After changing your image parameters, use the platform debugging tools to force a re-scrape.
- Title length: Keep titles under 60 characters for best results. Longer titles will be truncated or reduced in font size to fit the 1200×630 canvas.
Pricing for Next.js Developers
OGPeek is priced for developers who want to ship fast without managing image infrastructure:
- Free tier (50 images/day) — Perfect for personal blogs and side projects. Includes a small watermark.
- Starter at $9/month (5,000 images/month) — For production apps. No watermark, API key access, POST endpoint.
- Pro at $29/month (50,000 images/month) — For high-traffic SaaS apps and marketplaces.
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.