Open Graph Tags in Next.js: App Router, Dynamic Metadata, and OG Image Generation
Next.js has built-in metadata support that makes OG tag management straightforward. This guide covers every approach: static metadata, dynamic generation, OG images, and the Pages Router fallback.
App Router: Static Metadata
For pages with fixed OG data, export a metadata object from your layout.tsx or page.tsx.
// app/blog/rest-api/page.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "How to Build a REST API with Node.js",
description: "Production-ready REST API guide with Express and PostgreSQL.",
openGraph: {
title: "How to Build a REST API with Node.js",
description: "Production-ready REST API guide with Express and PostgreSQL.",
url: "https://example.com/blog/rest-api/",
siteName: "Dev Tutorials",
locale: "en_US",
type: "article",
publishedTime: "2025-03-15T08:00:00Z",
authors: ["Jane Doe"],
images: [
{
url: "https://example.com/images/rest-api-og.jpg",
width: 1200,
height: 630,
alt: "REST API architecture diagram",
},
],
},
twitter: {
card: "summary_large_image",
title: "How to Build a REST API with Node.js",
description: "Production-ready REST API guide with Express and PostgreSQL.",
images: ["https://example.com/images/rest-api-og.jpg"],
},
};
export default function Page() {
return <article>...</article>;
}App Router: Dynamic Metadata
For pages where OG data depends on route parameters, a database, or an API, use generateMetadata.
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { getPost } from "@/lib/posts";
type Props = {
params: Promise<{ slug: string }>;
};
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,
url: `https://example.com/blog/${slug}/`,
type: "article",
publishedTime: post.publishedAt,
images: [
{
url: post.ogImage,
width: 1200,
height: 630,
alt: post.ogImageAlt,
},
],
},
twitter: {
card: "summary_large_image",
},
};
}
export default async function Page({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug);
return <article>{/* render post */}</article>;
}metadataBase
metadataBase resolves relative image URLs in your metadata. Without it, relative paths like /images/og.jpg will not resolve to a full URL.
Set metadataBase once in your root layout. All child pages inherit it.
Common mistake: Forgetting metadataBase in development. Next.js will warn you and fall back to http://localhost:3000, which means your OG images will point to localhost in the rendered HTML. Social crawlers cannot reach localhost.
// app/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
metadataBase: new URL("https://example.com"),
openGraph: {
images: ["/images/og-default.jpg"], // resolves to https://example.com/images/og-default.jpg
},
};For environment-aware configuration:
export const metadata: Metadata = {
metadataBase: new URL(
process.env.NEXT_PUBLIC_BASE_URL ?? "https://example.com"
),
};Metadata Merging Gotcha
Next.js merges metadata from parent layouts and child pages using shallow merge. This is the most common source of OG bugs in Next.js.
// app/layout.tsx — root layout
export const metadata: Metadata = {
openGraph: {
siteName: "Dev Tutorials",
locale: "en_US",
type: "website",
images: ["/images/default-og.jpg"],
},
};
// app/blog/[slug]/page.tsx — child page
export const metadata: Metadata = {
openGraph: {
title: "My Blog Post",
description: "A great blog post.",
},
};Result: The child's openGraph completely replaces the parent's openGraph. The final output has title and description but no siteName, locale, type, or images. The child does not inherit individual properties from the parent's openGraph.
Fix: Include all required OG properties in every page that defines openGraph, or use generateMetadata to spread parent values:
// app/blog/[slug]/page.tsx
import type { Metadata, ResolvingMetadata } from "next";
export async function generateMetadata(
{ params }: Props,
parent: ResolvingMetadata
): Promise<Metadata> {
const parentMetadata = await parent;
return {
openGraph: {
...parentMetadata.openGraph,
title: "My Blog Post",
description: "A great blog post.",
images: ["/images/blog-post-og.jpg"],
},
};
}Dynamic OG Image Generation
Next.js includes ImageResponse (from next/og) for generating OG images at build time or on-demand using JSX.
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
import { getPost } from "@/lib/posts";
export const runtime = "edge";
export const alt = "Blog post cover 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={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "flex-start",
width: "100%",
height: "100%",
backgroundColor: "#0a0a0a",
padding: "60px 80px",
}}
>
<div
style={{
fontSize: 64,
fontWeight: 700,
color: "#ffffff",
lineHeight: 1.2,
marginBottom: 24,
}}
>
{post.title}
</div>
<div
style={{
fontSize: 28,
color: "#888888",
}}
>
{post.author} · {post.readTime} min read
</div>
</div>
),
{
...size,
}
);
}To use custom fonts:
export default async function Image({ params }: { params: { slug: string } }) {
const inter = await fetch(
new URL("../../assets/Inter-Bold.ttf", import.meta.url)
).then((res) => res.arrayBuffer());
return new ImageResponse(
(/* JSX */),
{
...size,
fonts: [
{
name: "Inter",
data: inter,
style: "normal",
weight: 700,
},
],
}
);
}File-Based OG Images
Next.js supports a file convention for OG images. Place an image file named opengraph-image in any route segment.
app/
opengraph-image.png # Default OG image for the entire site
blog/
[slug]/
opengraph-image.tsx # Dynamic OG image per blog post
twitter-image.tsx # Separate Twitter image (optional)Supported file extensions: .jpg, .jpeg, .png, .gif, .tsx, .ts, .jsx, .js.
Static files (.jpg, .png) are used as-is. Dynamic files (.tsx, .jsx) are executed to generate the image. Next.js automatically adds the corresponding og:image meta tags.
For Twitter-specific images, use the twitter-image file convention with the same pattern.
Pages Router: next/head
For projects still using the Pages Router, add OG tags via next/head.
// pages/blog/[slug].tsx
import Head from "next/head";
import { getPost } from "@/lib/posts";
export default function BlogPost({ post }) {
return (
<>
<Head>
<meta property="og:title" content={post.title} />
<meta property="og:description" content={post.excerpt} />
<meta property="og:type" content="article" />
<meta
property="og:url"
content={`https://example.com/blog/${post.slug}/`}
/>
<meta property="og:image" content={post.ogImage} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
</Head>
<article>{/* render post */}</article>
</>
);
}
export async function getStaticProps({ params }) {
const post = await getPost(params.slug);
return { props: { post } };
}If you are using the Pages Router, OG tags must be in the initial server-rendered HTML. Always use getStaticProps or getServerSideProps to provide the data — do not fetch it client-side.
next-seo and the Native Metadata API
The next-seo package was essential for managing meta tags in the Pages Router era. With the App Router's native Metadata API, its OG tag functionality is redundant.
When to use next-seo today: JSON-LD structured data (<script type="application/ld+json">) — next-seo provides typed helpers for this. Pages Router projects that have not migrated to the App Router.
When to skip next-seo: New App Router projects. The native metadata export and generateMetadata handle OG tags with full TypeScript support and no extra dependency.
// next-seo for JSON-LD only (App Router)
import { ArticleJsonLd } from "next-seo";
export default function BlogPost({ post }) {
return (
<>
<ArticleJsonLd
type="BlogPosting"
url={`https://example.com/blog/${post.slug}/`}
title={post.title}
images={[post.ogImage]}
datePublished={post.publishedAt}
authorName={post.author}
description={post.excerpt}
/>
<article>{/* render post */}</article>
</>
);
}Streaming Metadata (v15.2+)
Next.js 15.2 introduced streaming metadata. Instead of blocking the entire page render until generateMetadata resolves, Next.js can stream the <head> content progressively.
This is particularly useful when generateMetadata depends on slow data fetches. The page shell renders immediately, and meta tags are streamed in as the data becomes available.
Enable it with the htmlLimitedBots config to serve fully rendered HTML to bots while streaming to real users:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
htmlLimitedBots: {
// These bots receive the full HTML with metadata resolved
// before the response starts streaming
additionalBots: [
"facebookexternalhit",
"Twitterbot",
"LinkedInBot",
],
},
};
export default nextConfig;Known bots (Googlebot, Bingbot, etc.) are included by default. Add social crawlers via additionalBots to ensure they receive complete OG tags.
Key Constraints
When using ImageResponse for dynamic OG images, be aware of these limitations:
Flexbox only. CSS Grid, position: absolute, float, and other layout modes are not supported. Use display: "flex" for everything.
500 KB bundle limit. The total size of JSX, fonts, and assets used in the ImageResponse must stay under 500 KB at the Edge runtime.
Font formats: TTF and OTF are preferred. WOFF/WOFF2 support is inconsistent.
No external images from untrusted sources. Fetching images from external URLs works but may fail if the source is slow. Bundle critical assets locally.
Edge runtime. File-based opengraph-image.tsx runs on the Edge by default. If you need Node.js APIs, set export const runtime = "nodejs".
No <img> tags. Use the img element as a JSX element in ImageResponse, but note it expects a src prop with a data URL or fully qualified URL. Local file imports must be converted to data URLs or array buffers.
FAQ
Yes. The twitter key in the Metadata API is separate from openGraph. At minimum, include card: "summary_large_image" in your twitter object. The Twitter crawler will fall back to your OG values for title, description, and image, but twitter:card has no OG fallback and must be set explicitly.
You are missing metadataBase. Without it, Next.js resolves relative image paths against localhost:3000 during development, and those URLs end up in the rendered HTML. Set metadataBase in your root layout with metadataBase: new URL("https://yourdomain.com").
Yes, but generateMetadata runs at build time during static export. It cannot access request headers, cookies, or search params. All data must be available at build time through generateStaticParams and your data source.
View the page source in your browser (right-click, View Page Source — not the DevTools Elements panel, which shows the live DOM after hydration). Look for <meta property="og:..." tags in the <head>. In development, you can also check the terminal output for metadata-related warnings.
If you are on the App Router, yes. The native Metadata API provides the same OG tag functionality with TypeScript support, no extra dependency, and features like file-based OG images that next-seo cannot offer. Keep next-seo only if you need its JSON-LD helpers.