Next.js Dynamic OG Images: generateMetadata, @vercel/og, and OGPix
A deep dive into every approach for dynamic OG images in Next.js App Router — from the built-in metadata API to self-hosted Satori to using an external API like OGPix.
The Next.js App Router Metadata API
Next.js 13+ introduced the App Router with a powerful metadata system. You can define OG images using either a static metadata export or a dynamic generateMetadata function. The latter is what you need for pages with dynamic content like blog posts, product pages, or user profiles.
Static Metadata Export
For pages with fixed content, export a metadata object directly:
// app/about/page.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "About Us",
description: "Learn about our company",
openGraph: {
title: "About Us",
description: "Learn about our company",
images: [
{
url: "https://ogpix-pi.vercel.app/api/og?title=About+Us&theme=dark&key=YOUR_KEY",
width: 1200,
height: 630,
},
],
},
};This works great for landing pages, about pages, and other static routes. But for dynamic routes, you need generateMetadata.
Dynamic Metadata with generateMetadata
The generateMetadata function runs at request time (or build time with generateStaticParams) and can access route parameters, fetch data, and construct dynamic OG image URLs:
// app/blog/[slug]/page.tsx
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);
// Build the OGPix URL with dynamic data
const ogUrl = new URL("https://ogpix-pi.vercel.app/api/og");
ogUrl.searchParams.set("title", post.title);
ogUrl.searchParams.set("description", post.excerpt);
ogUrl.searchParams.set("theme", "dark");
ogUrl.searchParams.set("key", process.env.OGPIX_KEY!);
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
type: "article",
publishedTime: post.date,
images: [
{
url: ogUrl.toString(),
width: 1200,
height: 630,
alt: post.title,
},
],
},
twitter: {
card: "summary_large_image",
title: post.title,
description: post.excerpt,
images: [ogUrl.toString()],
},
};
}This pattern gives every blog post a unique OG image without any manual design work. The image is generated on-demand when a social platform scrapes the URL.
Key generateMetadata Features
- Automatic deduplication: Next.js deduplicates
fetchcalls, so if bothgenerateMetadataand your page component callgetPost(slug), the data is fetched only once. - Streaming compatible: Metadata is resolved before streaming the page content, so social crawlers always get the meta tags even with streaming enabled.
- Parent metadata merging: Child layouts can extend parent metadata using the
parentparameter, allowing you to set defaults at the layout level. - Type safe: The
Metadatatype fromnextprovides full TypeScript support for all possible meta tag fields.
Self-Hosted Approach: @vercel/og
If you prefer to generate OG images within your own Next.js app, you can use @vercel/og (which uses Satori under the hood). This creates a Route Handler that returns an ImageResponse:
// app/api/og/route.tsx
import { ImageResponse } from "@vercel/og";
export const runtime = "edge";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const title = searchParams.get("title") || "My App";
const description = searchParams.get("description") || "";
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
background: "linear-gradient(135deg, #0f172a, #1e293b)",
padding: 60,
}}
>
<h1 style={{ fontSize: 64, color: "white", textAlign: "center" }}>
{title}
</h1>
{description && (
<p style={{ fontSize: 28, color: "#94a3b8", textAlign: "center" }}>
{description}
</p>
)}
</div>
),
{ width: 1200, height: 630 }
);
}OGPix API vs Self-Hosted @vercel/og: Comparison
| Feature | OGPix API | Self-Hosted @vercel/og |
|---|---|---|
| Setup time | 2 minutes (get API key) | 30-60 minutes |
| Custom templates | Pre-built themes | Full control (JSX) |
| Custom fonts | Handled automatically | Manual font loading |
| Caching | CDN included | You manage caching |
| Rate limiting | Built-in | You implement |
| Analytics | Dashboard included | Not available |
| Cold starts | None (CDN) | Possible on edge |
| Hosting cost | Free tier available | Part of your infra |
The self-hosted approach gives you maximum design flexibility — you write the JSX template, load custom fonts, and control every pixel. The tradeoff is that you're responsible for caching, monitoring, and handling edge cases like timeout errors from social crawlers.
OGPix handles all that infrastructure for you. If your design needs are covered by the available themes, it's the faster path to production-ready OG images.
Using the opengraph-image Convention
Next.js also supports a file-based convention for OG images. Create an opengraph-image.tsx file in any route directory:
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
export const runtime = "edge";
export const alt = "Blog post image";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default async function Image({
params,
}: {
params: { slug: string };
}) {
const post = await getPost(params.slug);
return new ImageResponse(
(
<div style={{ /* your styles */ }}>
<h1>{post.title}</h1>
</div>
),
{ ...size }
);
}This convention automatically adds the correct og:image meta tags — you don't need to manually set them in generateMetadata. However, you still need Satori-compatible JSX (no CSS Grid, limited flexbox) and must handle font loading yourself.
Best Practices for Next.js OG Images
- Always set width and height: Include
width: 1200andheight: 630in the images array so platforms render previews faster. - Use absolute URLs: OG image URLs must be absolute. In Next.js, set
metadataBasein your root layout to avoid issues. - Add Twitter card tags: Always include
twitter.card: "summary_large_image"alongside your OG tags for the best Twitter/X display. - Set metadataBase: Define
metadataBase: new URL("https://yourdomain.com")in your root layout to resolve relative OG image paths. - Keep titles short: OG image text should be under 60 characters for readability at small sizes.
- Test before deploying: Use the Facebook Sharing Debugger, Twitter Card Validator, and LinkedIn Post Inspector to verify your meta tags render correctly.
Complete Example: Blog with OGPix
Here's a full example showing a Next.js blog with dynamic OG images via OGPix, including generateStaticParams for static generation:
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { getPost, getAllSlugs } from "@/lib/blog";
type Props = { params: Promise<{ slug: string }> };
export async function generateStaticParams() {
const slugs = await getAllSlugs();
return slugs.map((slug) => ({ slug }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
const ogUrl = new URL("https://ogpix-pi.vercel.app/api/og");
ogUrl.searchParams.set("title", post.title);
ogUrl.searchParams.set("description", post.excerpt);
ogUrl.searchParams.set("theme", "dark");
ogUrl.searchParams.set("key", process.env.OGPIX_KEY!);
return {
title: `${post.title} — My Blog`,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
type: "article",
publishedTime: post.date,
images: [{ url: ogUrl.toString(), width: 1200, height: 630 }],
},
twitter: {
card: "summary_large_image",
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 dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}Add OG images to your Next.js app in 2 minutes
Get an API key, drop the URL into your generateMetadata, and every page gets a unique social preview. No packages to install.
Get Free API Key →