Metadata API: SEO and Social Sharing Best Practices
Why Metadata Must Be Generated Server-Side
In the traditional SPA era, <title> and <meta> tags were frequently set dynamically by client-side JavaScript. The problem with this approach: search engine crawlers (especially non-Google ones) and social media crawlers (Twitter, Facebook, WeChat, LinkedIn) mostly do not execute JavaScript — they only read raw HTML. If the page title and description only appear after JavaScript executes, these platforms see either an empty page or generic placeholder content.
Next.js App Router's Metadata API generates all metadata on the server and injects it into the HTML <head>, ensuring crawlers see complete, correct metadata on their very first request.
Static Metadata: export const metadata
The simplest approach is to export a metadata object from a page file or layout file:
// app/page.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'YiteAI Tools - Intelligent AI Tool Platform',
description: 'A one-stop AI tool platform for global businesses, offering market analysis, content translation, SEO optimization, and more.',
keywords: ['AI tools', 'global expansion', 'market analysis', 'SEO optimization'],
authors: [{ name: 'YiteAI Team', url: 'https://dev.yiteai.com' }],
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
}
The Metadata Inheritance and Override Model
Next.js metadata uses a cascading inheritance model: the root layout (app/layout.tsx) defines base metadata, and child layouts and pages can selectively override it. Fields that are not overridden are inherited from the parent.
// app/layout.tsx — Global defaults in the root layout
export const metadata: Metadata = {
title: {
default: 'YiteAI', // Default when no title is set
template: '%s | YiteAI', // Template for child page titles
},
description: 'AI tool platform',
metadataBase: new URL('https://dev.yiteai.com'), // Base URL for relative paths
}
// app/tools/seo/page.tsx — Child page only specifies what differs
export const metadata: Metadata = {
title: 'SEO Analysis Tool', // Final result: SEO Analysis Tool | YiteAI
description: 'Deeply analyze target market SEO opportunities, generate keyword reports and content suggestions.',
// Other fields inherited from the root layout
}
The %s in title.template is replaced by the child page's title string. This mechanism ensures all pages have a consistent title format while letting child pages focus only on their own core title without repeating the brand name.
metadataBase is an important but commonly overlooked configuration. For Open Graph images, canonical URLs, and other places that require absolute URLs, Next.js concatenates relative paths onto metadataBase. Not setting metadataBase causes development builds to generate URLs starting with http://localhost:3000, making images fail to display when shared to social media.
Open Graph: Social Sharing Cards
The Open Graph protocol (proposed by Facebook) is the standard for social media sharing previews. Twitter/X, LinkedIn, Slack, WeChat, and many other platforms all support OG tags:
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
import { getPost } from '@/lib/blog'
interface 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,
type: 'article',
publishedTime: post.publishedAt.toISOString(),
authors: [post.author.name],
images: [
{
url: post.coverImage, // Relative paths are auto-combined with metadataBase
width: 1200,
height: 630,
alt: post.title,
},
],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
images: [post.coverImage],
creator: '@yiteai',
},
}
}
The standard OG image size is 1200x630 pixels (aspect ratio 1.91:1). The twitter:card value affects how Twitter displays the content: summary shows a small thumbnail, while summary_large_image shows a large banner image, which typically sees around 30% higher click-through rates.
generateMetadata: Dynamic Metadata Generation
For metadata that depends on database queries or external APIs, use the async generateMetadata function:
// app/products/[id]/page.tsx
import type { Metadata } from 'next'
import { getProduct } from '@/lib/products'
import { notFound } from 'next/navigation'
interface Props {
params: Promise<{ id: string }>
searchParams: Promise<{ variant?: string }>
}
export async function generateMetadata(
{ params, searchParams }: Props
): Promise<Metadata> {
const { id } = await params
const { variant } = await searchParams
const product = await getProduct(id)
if (!product) {
return {
title: 'Product Not Found',
description: 'The product you are looking for has been removed or does not exist.',
}
}
const variantInfo = variant
? product.variants.find(v => v.id === variant)
: product.variants[0]
const imageUrl = variantInfo?.image ?? product.defaultImage
return {
title: product.name,
description: product.description,
openGraph: {
title: product.name,
description: product.description,
type: 'website',
images: [{ url: imageUrl, width: 800, height: 800, alt: product.name }],
},
other: {
'product:price:amount': String(product.price),
'product:price:currency': 'USD',
},
}
}
Next.js automatically deduplicates data requests made inside generateMetadata — if both generateMetadata and the page component call the same getProduct(id) function, the actual database query only executes once (implemented via React's cache() function).
Dynamic OG Images: next/og ImageResponse
Static OG images cannot dynamically display content like article titles or author names. next/og provides ImageResponse, which lets you define image content with JSX and render it as a real image at request time:
// app/og/route.tsx
import { ImageResponse } from 'next/og'
import type { NextRequest } from 'next/server'
export const runtime = 'edge'
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl
const title = searchParams.get('title') ?? 'YiteAI'
const description = searchParams.get('desc') ?? ''
const author = searchParams.get('author') ?? 'YiteAI Team'
// Load font in Edge Runtime
const fontData = await fetch(
new URL('../../../public/fonts/Inter-Bold.ttf', import.meta.url)
).then(res => res.arrayBuffer())
return new ImageResponse(
(
<div
style={{
display: 'flex',
flexDirection: 'column',
width: '1200px',
height: '630px',
backgroundColor: '#0f172a',
padding: '80px',
justifyContent: 'space-between',
}}
>
{/* Logo area */}
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{
width: 48, height: 48,
backgroundColor: '#6366f1',
borderRadius: 12,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<span style={{ color: 'white', fontSize: 24, fontWeight: 700 }}>Y</span>
</div>
<span style={{ color: '#94a3b8', fontSize: 24 }}>YiteAI</span>
</div>
{/* Title */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<h1 style={{
color: 'white',
fontSize: title.length > 30 ? 48 : 64,
fontWeight: 700,
lineHeight: 1.2,
margin: 0,
}}>
{title}
</h1>
{description && (
<p style={{ color: '#94a3b8', fontSize: 28, margin: 0 }}>
{description}
</p>
)}
</div>
{/* Bottom author info */}
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ color: '#64748b', fontSize: 20 }}>{author}</span>
</div>
</div>
),
{
width: 1200,
height: 630,
fonts: [
{
name: 'Inter',
data: fontData,
weight: 700,
style: 'normal',
},
],
}
)
}
Reference this dynamic OG image in a page's generateMetadata:
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const post = await getPost(slug)
const ogImageUrl = new URL('/og', 'https://dev.yiteai.com')
ogImageUrl.searchParams.set('title', post.title)
ogImageUrl.searchParams.set('desc', post.excerpt)
ogImageUrl.searchParams.set('author', post.author.name)
return {
title: post.title,
openGraph: {
images: [{ url: ogImageUrl.toString(), width: 1200, height: 630 }],
},
}
}
ImageResponse runs on the Edge Runtime — no canvas or Puppeteer needed. It uses the Satori library to convert React styles to SVG, then resvg to convert SVG to PNG. Render time is typically 50–200ms, making on-demand generation practical.
robots.txt and sitemap.xml: Three Implementation Approaches
Approach 1: Static Files
Place robots.txt and sitemap.xml in the public/ directory, suitable when dynamic generation is not required:
public/
robots.txt
sitemap.xml
Approach 2: Route Handler for Dynamic Generation
When dynamic content is needed (such as fetching URL lists from a database), use a Route Handler:
// app/sitemap.xml/route.ts
import { getAllPostSlugs } from '@/lib/db'
export async function GET() {
const posts = await getAllPostSlugs()
const baseUrl = 'https://dev.yiteai.com'
const staticUrls = [
{ loc: baseUrl, priority: '1.0', changefreq: 'daily' },
{ loc: `${baseUrl}/tools`, priority: '0.9', changefreq: 'weekly' },
]
const postUrls = posts.map(slug => ({
loc: `${baseUrl}/blog/${slug}`,
priority: '0.7',
changefreq: 'monthly',
lastmod: new Date().toISOString(),
}))
const allUrls = [...staticUrls, ...postUrls]
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${allUrls.map(url => ` <url>
<loc>${url.loc}</loc>
<priority>${url.priority}</priority>
<changefreq>${url.changefreq}</changefreq>
${url.lastmod ? `<lastmod>${url.lastmod}</lastmod>` : ''}
</url>`).join('\n')}
</urlset>`
return new Response(xml, {
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
},
})
}
Approach 3: Native Next.js Sitemap Configuration
Next.js 15 supports generating a sitemap by exporting a function from app/sitemap.ts:
// app/sitemap.ts
import type { MetadataRoute } from 'next'
import { getAllPosts } from '@/lib/blog'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts()
const postUrls: MetadataRoute.Sitemap = posts.map(post => ({
url: `https://dev.yiteai.com/blog/${post.slug}`,
lastModified: post.updatedAt,
changeFrequency: 'monthly',
priority: 0.7,
}))
return [
{
url: 'https://dev.yiteai.com',
lastModified: new Date(),
changeFrequency: 'daily',
priority: 1,
},
...postUrls,
]
}
Similarly, app/robots.ts generates robots.txt:
// app/robots.ts
import type { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/api/', '/admin/', '/_next/'],
},
],
sitemap: 'https://dev.yiteai.com/sitemap.xml',
}
}
Canonical URLs: Avoiding Duplicate Content Penalties
When the same content is accessible through multiple URLs (such as /products/shoe and /products/shoe?color=red), specify a canonical URL to tell search engines which is the authoritative version:
export async function generateMetadata({ params, searchParams }: Props): Promise<Metadata> {
const { id } = await params
// canonical always points to the base URL without query parameters
return {
alternates: {
canonical: `https://dev.yiteai.com/products/${id}`,
languages: {
'zh-CN': `https://dev.yiteai.com/zh/products/${id}`,
'en-US': `https://dev.yiteai.com/en/products/${id}`,
},
},
}
}
JSON-LD Structured Data
Structured data helps search engines understand page content and can trigger Rich Results — star ratings, prices, Q&A boxes, and more. Injecting JSON-LD in a Server Component is the simplest approach:
// app/blog/[slug]/page.tsx
import { getPost } from '@/lib/blog'
export default async function BlogPost({ params }: Props) {
const { slug } = await params
const post = await getPost(slug)
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
description: post.excerpt,
author: {
'@type': 'Person',
name: post.author.name,
url: `https://dev.yiteai.com/authors/${post.author.slug}`,
},
datePublished: post.publishedAt.toISOString(),
dateModified: post.updatedAt.toISOString(),
image: post.coverImage,
publisher: {
'@type': 'Organization',
name: 'YiteAI',
logo: {
'@type': 'ImageObject',
url: 'https://dev.yiteai.com/logo.png',
},
},
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article>
<h1>{post.title}</h1>
{/* ... */}
</article>
</>
)
}
When using dangerouslySetInnerHTML to inject JSON-LD, the data source is trusted server-side data, so there is no XSS risk. However, if any user-generated content flows into JSON-LD, the escaping provided by JSON.stringify ensures safety — do not manually concatenate strings.
Summary
Next.js's Metadata API elevates SEO from an afterthought to a first-class architectural concern. The metadata object inheritance chain keeps all page metadata in a consistent format across the site; generateMetadata ensures dynamic content always has metadata synchronized with the page content; ImageResponse gives every article a polished, dynamic sharing card; and structured data opens the door to Rich Results in search. Together these capabilities form a complete SEO and social sharing solution — generated entirely on the server and fully visible to crawlers.