Skip to main content
Advertisement

12.6 Metadata & SEO — metadata Object, generateMetadata, OG Tags, Sitemap

SEO and social media sharing are essential for modern web services. Next.js App Router manages metadata declaratively through the Metadata API, automatically generating HTML <head> tags at build time.


Metadata API Fundamentals

Why Use the Next.js Metadata API?

In traditional React, libraries like react-helmet were used to manipulate <head> tags. Next.js App Router lets you declare metadata statically or dynamically from server components.

ApproachDescriptionWhen to Use
export const metadataStatic metadata objectFixed page titles/descriptions
export async function generateMetadataDynamic metadata generation functionMetadata fetched from DB/API

Metadata Inheritance

App Router metadata merges from layout → page. Child levels override parent levels.

app/layout.tsx       ← Root metadata (defaults)
app/blog/layout.tsx ← Blog section metadata
app/blog/[id]/page.tsx ← Individual post metadata

Basic Examples — Static Metadata

Root Layout Metadata

// app/layout.tsx
import type { Metadata } from 'next';

export const metadata: Metadata = {
title: {
default: 'My Next.js App', // default title
template: '%s | My Next.js App', // child page title template
},
description: 'A modern web app built with Next.js 15 App Router',
keywords: ['Next.js', 'React', 'TypeScript', 'Web Development'],
authors: [{ name: 'John Doe', url: 'https://example.com' }],
creator: 'John Doe',
publisher: 'John Doe',

// robots configuration
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},

// default Open Graph
openGraph: {
type: 'website',
locale: 'en_US',
url: 'https://example.com',
siteName: 'My Next.js App',
},
};

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

Per-Page Static Metadata

// app/about/page.tsx
import type { Metadata } from 'next';

export const metadata: Metadata = {
// template produces → "About | My Next.js App"
title: 'About',
description: 'Learn about our team and services.',
openGraph: {
title: 'About — My Next.js App',
description: 'Learn about our team and services.',
images: [
{
url: 'https://example.com/og/about.jpg',
width: 1200,
height: 630,
alt: 'About page image',
},
],
},
};

export default function AboutPage() {
return (
<main>
<h1>About</h1>
<p>Meet our team.</p>
</main>
);
}

Open Graph & Social Media Metadata

Complete OG Tag Example

// app/blog/page.tsx
import type { Metadata } from 'next';

export const metadata: Metadata = {
title: 'Blog',
description: 'Stories about development, technology, and everyday life.',

// Open Graph — Facebook, LinkedIn, Kakao, etc.
openGraph: {
type: 'website',
url: 'https://example.com/blog',
title: 'Blog — My Next.js App',
description: 'Stories about development, technology, and everyday life.',
siteName: 'My Next.js App',
locale: 'en_US',
images: [
{
url: 'https://example.com/og/blog.png',
width: 1200,
height: 630,
alt: 'Blog main image',
type: 'image/png',
},
],
},

// Twitter Card — shown when sharing on X (formerly Twitter)
twitter: {
card: 'summary_large_image',
site: '@myaccount',
creator: '@myaccount',
title: 'Blog — My Next.js App',
description: 'Stories about development, technology, and everyday life.',
images: ['https://example.com/og/blog.png'],
},
};

export default function BlogPage() {
return <main><h1>Blog</h1></main>;
}

Dynamic Metadata — generateMetadata

Dynamically generate metadata by fetching data from a DB or API.

Basic Pattern

// app/blog/[slug]/page.tsx
import type { Metadata, ResolvingMetadata } from 'next';

interface Props {
params: Promise<{ slug: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}

// Data fetch function (reusable)
async function getPost(slug: string) {
const res = await fetch(`https://api.example.com/posts/${slug}`, {
next: { revalidate: 3600 },
});
if (!res.ok) return null;
return res.json();
}

// Dynamic metadata generation
export async function generateMetadata(
{ params, searchParams }: Props,
parent: ResolvingMetadata // access parent metadata
): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);

if (!post) {
return {
title: 'Post Not Found',
};
}

// Use parent OG images as fallback
const previousImages = (await parent).openGraph?.images || [];

return {
title: post.title,
description: post.excerpt,
openGraph: {
type: 'article',
title: post.title,
description: post.excerpt,
url: `https://example.com/blog/${slug}`,
publishedTime: post.createdAt,
modifiedTime: post.updatedAt,
authors: [post.author.name],
images: post.coverImage
? [
{
url: post.coverImage,
width: 1200,
height: 630,
alt: post.title,
},
...previousImages,
]
: previousImages,
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
images: post.coverImage ? [post.coverImage] : [],
},
};
}

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

if (!post) {
return <p>Post not found.</p>;
}

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

generateMetadata with DB Queries

// app/products/[id]/page.tsx
import type { Metadata } from 'next';
import { db } from '@/lib/db';
import { cache } from 'react';

// Wrap with cache() to avoid duplicate DB queries between generateMetadata and page
const getProduct = cache(async (id: string) => {
return db.product.findUnique({
where: { id },
include: { category: true, images: true },
});
});

export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}): Promise<Metadata> {
const { id } = await params;
const product = await getProduct(id);

if (!product) {
return {
title: 'Product Not Found',
robots: { index: false }, // exclude 404 pages from indexing
};
}

return {
title: product.name,
description: product.description.slice(0, 160), // 160 char limit
openGraph: {
type: 'website',
title: `${product.name}${product.category.name}`,
description: product.description.slice(0, 160),
images: product.images.map((img) => ({
url: img.url,
width: img.width,
height: img.height,
alt: img.alt || product.name,
})),
},
// Structured data (JSON-LD)
other: {
'script:ld+json': JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
description: product.description,
image: product.images[0]?.url,
offers: {
'@type': 'Offer',
price: product.price,
priceCurrency: 'USD',
availability: product.stock > 0
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock',
},
}),
},
};
}

export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await getProduct(id); // served from cache — no extra DB query

if (!product) return <p>Product not found.</p>;

return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p>Price: ${product.price}</p>
</div>
);
}

Real-World Example — Dynamic OG Image Generation

Generating Dynamic OG Images with ImageResponse

// app/og/route.tsx
import { ImageResponse } from 'next/og';
import { NextRequest } from 'next/server';

export const runtime = 'edge'; // runs on Edge Runtime

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

return new ImageResponse(
(
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#1a1a2e',
padding: '40px',
}}
>
<div
style={{
fontSize: '60px',
fontWeight: 'bold',
color: '#e94560',
textAlign: 'center',
marginBottom: '20px',
}}
>
{title}
</div>
<div
style={{
fontSize: '30px',
color: '#a8a8b3',
textAlign: 'center',
}}
>
{description}
</div>
<div
style={{
position: 'absolute',
bottom: '40px',
right: '40px',
fontSize: '20px',
color: '#444',
}}
>
example.com
</div>
</div>
),
{
width: 1200,
height: 630,
}
);
}
// Using dynamic OG image in generateMetadata
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);

const ogImageUrl = new URL('/og', 'https://example.com');
ogImageUrl.searchParams.set('title', post?.title || 'Post');
ogImageUrl.searchParams.set('description', post?.excerpt || '');

return {
title: post?.title,
openGraph: {
images: [
{
url: ogImageUrl.toString(),
width: 1200,
height: 630,
},
],
},
};
}

Sitemap Generation

Static sitemap.xml

// app/sitemap.ts
import type { MetadataRoute } from 'next';

export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: 'https://example.com',
lastModified: new Date(),
changeFrequency: 'daily',
priority: 1,
},
{
url: 'https://example.com/about',
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.8,
},
{
url: 'https://example.com/blog',
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.9,
},
];
}

Dynamic Sitemap — Generating URLs from DB

// app/sitemap.ts
import type { MetadataRoute } from 'next';
import { db } from '@/lib/db';

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = 'https://example.com';

// Fetch all posts from DB
const posts = await db.post.findMany({
where: { published: true },
select: { slug: true, updatedAt: true },
});

const products = await db.product.findMany({
where: { published: true },
select: { id: true, updatedAt: true },
});

// Static pages
const staticPages: MetadataRoute.Sitemap = [
{
url: baseUrl,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 1,
},
{
url: `${baseUrl}/about`,
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.8,
},
{
url: `${baseUrl}/blog`,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 0.9,
},
{
url: `${baseUrl}/products`,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 0.9,
},
];

// Dynamic post pages
const postPages: MetadataRoute.Sitemap = posts.map((post) => ({
url: `${baseUrl}/blog/${post.slug}`,
lastModified: post.updatedAt,
changeFrequency: 'weekly',
priority: 0.7,
}));

// Dynamic product pages
const productPages: MetadataRoute.Sitemap = products.map((product) => ({
url: `${baseUrl}/products/${product.id}`,
lastModified: product.updatedAt,
changeFrequency: 'weekly',
priority: 0.6,
}));

return [...staticPages, ...postPages, ...productPages];
}

robots.txt Generation

// app/robots.ts
import type { MetadataRoute } from 'next';

export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/api/', '/admin/', '/dashboard/'],
},
{
userAgent: 'Googlebot',
allow: '/',
disallow: ['/private/'],
},
],
sitemap: 'https://example.com/sitemap.xml',
host: 'https://example.com',
};
}

JSON-LD Structured Data

Add structured data to help search engines better understand page content.

// components/JsonLd.tsx
interface ArticleJsonLdProps {
title: string;
description: string;
author: string;
publishedAt: string;
updatedAt: string;
imageUrl: string;
url: string;
}

export function ArticleJsonLd({
title,
description,
author,
publishedAt,
updatedAt,
imageUrl,
url,
}: ArticleJsonLdProps) {
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: title,
description,
image: imageUrl,
datePublished: publishedAt,
dateModified: updatedAt,
author: {
'@type': 'Person',
name: author,
},
publisher: {
'@type': 'Organization',
name: 'My Site',
logo: {
'@type': 'ImageObject',
url: 'https://example.com/logo.png',
},
},
mainEntityOfPage: {
'@type': 'WebPage',
'@id': url,
},
};

return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
);
}
// app/blog/[slug]/page.tsx
import { ArticleJsonLd } from '@/components/JsonLd';

export default async function BlogPostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);

return (
<article>
{/* JSON-LD in <body> is recognized by Google */}
<ArticleJsonLd
title={post.title}
description={post.excerpt}
author={post.author.name}
publishedAt={post.createdAt}
updatedAt={post.updatedAt}
imageUrl={post.coverImage}
url={`https://example.com/blog/${slug}`}
/>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}

Pro Tips

1. Type-Safe Metadata Helpers

// Custom metadata helper function
import type { Metadata } from 'next';

interface SeoProps {
title: string;
description: string;
image?: string;
noIndex?: boolean;
}

export function createMetadata({
title,
description,
image = 'https://example.com/og/default.png',
noIndex = false,
}: SeoProps): Metadata {
return {
title,
description,
robots: noIndex ? { index: false, follow: false } : undefined,
openGraph: {
title,
description,
images: [{ url: image, width: 1200, height: 630 }],
},
twitter: {
card: 'summary_large_image',
title,
description,
images: [image],
},
};
}

// Usage
export const metadata = createMetadata({
title: 'Products',
description: 'Browse our latest products.',
image: 'https://example.com/og/products.png',
});

2. Internationalized (i18n) Metadata

// app/[lang]/layout.tsx
import type { Metadata } from 'next';

type Lang = 'ko' | 'en' | 'ja';

const siteMetadata: Record<Lang, Metadata> = {
ko: {
title: { default: '내 사이트', template: '%s | 내 사이트' },
description: '한국어 설명',
openGraph: { locale: 'ko_KR' },
},
en: {
title: { default: 'My Site', template: '%s | My Site' },
description: 'English description',
openGraph: { locale: 'en_US' },
},
ja: {
title: { default: '私のサイト', template: '%s | 私のサイト' },
description: '日本語の説明',
openGraph: { locale: 'ja_JP' },
},
};

export async function generateMetadata({
params,
}: {
params: Promise<{ lang: Lang }>;
}): Promise<Metadata> {
const { lang } = await params;
return siteMetadata[lang] || siteMetadata.en;
}

3. Setting Canonical URLs

// app/products/[id]/page.tsx
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}): Promise<Metadata> {
const { id } = await params;

return {
title: 'Product Detail',
// Prevent duplicate content issues — set canonical URL
alternates: {
canonical: `https://example.com/products/${id}`,
languages: {
'ko-KR': `https://example.com/ko/products/${id}`,
'en-US': `https://example.com/en/products/${id}`,
},
},
};
}

4. Debugging Metadata

# Ways to verify metadata
# 1. Browser DevTools → Elements → inspect <head> tag
# 2. Verify server-rendered output with curl
curl -s https://example.com/blog/my-post | grep -i '<meta\|<title'

# 3. Use OG debugger tools
# - Facebook: https://developers.facebook.com/tools/debug/
# - Twitter: https://cards-dev.twitter.com/validator
# - LinkedIn: https://www.linkedin.com/post-inspector/

5. Performance — Combining generateMetadata with generateStaticParams

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

// Generate static paths at build time
export async function generateStaticParams() {
const posts = await db.post.findMany({
where: { published: true },
select: { slug: true },
});

return posts.map((post) => ({ slug: post.slug }));
}

// Metadata is also generated at build time for each static path
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug); // executed at build time

return {
title: post?.title,
description: post?.excerpt,
};
}

6. Testing Social Media Previews

// Quickly verify OG images in development
// Add test parameters to app/og/route.tsx

// Check directly in the browser:
// http://localhost:3000/og?title=Test Title&description=Test Description

// Or expose local server with ngrok, then use OG debugger tools
// npx ngrok http 3000
Advertisement