🔍 Chapter 19: Metadata, SEO & OG Images
Getting your pages indexed properly and making shared links look good — without ever touching
<head>.
📖 Plain English Explanation
Two audiences never see your CSS.
Search engines read your <title>, <meta name="description">, your canonical URL, your structured data, and your sitemap. That's how you get found.
Social platforms — Slack, X, LinkedIn, iMessage, Discord — read your Open Graph tags and render a preview card. That's how a shared link looks like a product instead of a naked URL.
Both live in <head>, which in the App Router you never write directly. Instead you export metadata and Next.js assembles the tags, deduplicates them, and injects them at the right point in the stream.
Two ways to do it:
metadata— a static object, for values known at build timegenerateMetadata— an async function, for values from your data
Plus a set of file conventions that turn a correctly-named file into the right tag automatically.
🏷️ Static metadata
// app/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Acme',
description: 'The best widgets on the internet.',
}
// app/layout.js
export const metadata = {
title: 'Acme',
description: 'The best widgets on the internet.',
}
Which produces:
<title>Acme</title>
<meta name="description" content="The best widgets on the internet." />
Title templates
The pattern you want in your root layout:
// app/layout.tsx
export const metadata: Metadata = {
title: {
default: 'Acme — Widgets for everyone',
template: '%s | Acme',
},
}
// app/blog/page.tsx
export const metadata: Metadata = {
title: 'Blog', // renders as "Blog | Acme"
}
To escape the template on one page:
export const metadata: Metadata = {
title: { absolute: 'Just this exact title' },
}
A realistic root layout
// app/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
metadataBase: new URL('https://acme.com'), // ← makes relative URLs work
title: {
default: 'Acme — Widgets for everyone',
template: '%s | Acme',
},
description: 'Buy well-made widgets, shipped worldwide.',
applicationName: 'Acme',
authors: [{ name: 'Acme Inc.', url: 'https://acme.com' }],
keywords: ['widgets', 'gadgets', 'hardware'],
openGraph: {
type: 'website',
locale: 'en_US',
url: 'https://acme.com',
siteName: 'Acme',
title: 'Acme — Widgets for everyone',
description: 'Buy well-made widgets, shipped worldwide.',
images: [{ url: '/og.png', width: 1200, height: 630, alt: 'Acme' }],
},
twitter: {
card: 'summary_large_image',
site: '@acme',
creator: '@acme',
},
robots: {
index: true,
follow: true,
googleBot: { index: true, follow: true, 'max-image-preview': 'large' },
},
alternates: {
canonical: '/',
languages: { 'en-US': '/en', 'fr-FR': '/fr' },
},
icons: {
icon: '/favicon.ico',
apple: '/apple-touch-icon.png',
},
}
metadataBase is the one people forget. Without it, relative URLs in openGraph.images don't resolve and social platforms fetch nothing. Set it once in the root layout.
Merging
Metadata merges down the tree, deeper segments overriding shallower ones:
app/layout.tsx → title template, description, OG defaults
app/blog/layout.tsx → overrides description
app/blog/[slug]/page.tsx → overrides title, description, OG image
Merging is shallow. Redefine openGraph in a page and you replace the whole object, not merge into it — you'll need to restate siteName, type, and so on.
🔄 generateMetadata
For anything derived from data:
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getPost } from '@/lib/posts'
export async function generateMetadata(
props: PageProps<'/blog/[slug]'>
): Promise<Metadata> {
const { slug } = await props.params
const post = await getPost(slug)
if (!post) {
return { title: 'Post not found' }
}
return {
title: post.title,
description: post.excerpt,
authors: [{ name: post.author.name }],
openGraph: {
type: 'article',
title: post.title,
description: post.excerpt,
publishedTime: post.publishedAt.toISOString(),
authors: [post.author.name],
images: [{ url: post.coverImage, width: 1200, height: 630, alt: post.title }],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
alternates: { canonical: `/blog/${slug}` },
}
}
export default async function Page(props: PageProps<'/blog/[slug]'>) {
const { slug } = await props.params
const post = await getPost(slug)
if (!post) notFound()
return <article>{post.body}</article>
}
// app/blog/[slug]/page.js
import { notFound } from 'next/navigation'
import { getPost } from '@/lib/posts'
export async function generateMetadata(props) {
const { slug } = await props.params
const post = await getPost(slug)
if (!post) return { title: 'Post not found' }
return {
title: post.title,
description: post.excerpt,
openGraph: {
type: 'article',
title: post.title,
description: post.excerpt,
images: [{ url: post.coverImage, width: 1200, height: 630, alt: post.title }],
},
alternates: { canonical: `/blog/${slug}` },
}
}
export default async function Page(props) {
const { slug } = await props.params
const post = await getPost(slug)
if (!post) notFound()
return <article>{post.body}</article>
}
The duplicate fetch isn't duplicated
getPost(slug) runs in both generateMetadata and the page. That's fine — fetch deduplicates automatically, and for a database client you wrap it in cache():
// lib/posts.ts
import { cache } from 'react'
import { db } from '@/lib/db'
export const getPost = cache(async (slug: string) =>
db.post.findUnique({ where: { slug } })
)
One query per request, regardless of how many callers.
Inheriting the parent
import type { Metadata, ResolvingMetadata } from 'next'
export async function generateMetadata(
props: PageProps<'/blog/[slug]'>,
parent: ResolvingMetadata
): Promise<Metadata> {
const { slug } = await props.params
const post = await getPost(slug)
const previousImages = (await parent).openGraph?.images ?? []
return {
title: post.title,
openGraph: {
images: [post.coverImage, ...previousImages], // add, don't replace
},
}
}
You cannot export both metadata and generateMetadata from the same file. Pick one.
🖼️ File-based metadata
Certain filenames become tags automatically. No code required.
Icons
app/
├── favicon.ico → <link rel="icon">
├── icon.png → <link rel="icon">
├── icon.svg
└── apple-icon.png → <link rel="apple-touch-icon">
Drop the files in; Next.js reads their dimensions and emits the right tags.
Static OG images
app/
├── opengraph-image.png → <meta property="og:image">
├── opengraph-image.alt.txt → <meta property="og:image:alt">
└── twitter-image.png → <meta name="twitter:image">
Place them in a route folder to scope them to that route:
app/blog/opengraph-image.png → applies to /blog and everything under it
🎨 Generated OG images
The good part. Generate a unique image per page, at request time, from JSX.
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { getPost } from '@/lib/posts'
export const alt = 'Blog post cover'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export default async function Image(props: {
params: Promise<{ slug: string }>
}) {
const { slug } = await props.params // ← Promise in Next.js 16
const post = await getPost(slug)
return new ImageResponse(
(
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
background: 'linear-gradient(135deg, #0f172a 0%, #1e3a8a 100%)',
padding: 64,
}}
>
<div style={{ display: 'flex', color: '#93c5fd', fontSize: 28 }}>
acme.com/blog
</div>
<div
style={{
display: 'flex',
color: 'white',
fontSize: 68,
fontWeight: 700,
lineHeight: 1.1,
}}
>
{post?.title ?? 'Acme Blog'}
</div>
<div style={{ display: 'flex', color: '#cbd5e1', fontSize: 30 }}>
{post?.author.name} · {post?.readingMinutes} min read
</div>
</div>
),
{ ...size }
)
}
// app/blog/[slug]/opengraph-image.js
import { ImageResponse } from 'next/og'
import { getPost } from '@/lib/posts'
export const alt = 'Blog post cover'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export default async function Image(props) {
const { slug } = await props.params
const post = await getPost(slug)
return new ImageResponse(
(
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
background: 'linear-gradient(135deg, #0f172a 0%, #1e3a8a 100%)',
padding: 64,
}}
>
<div style={{ display: 'flex', color: '#93c5fd', fontSize: 28 }}>
acme.com/blog
</div>
<div style={{ display: 'flex', color: 'white', fontSize: 68, fontWeight: 700 }}>
{post?.title ?? 'Acme Blog'}
</div>
</div>
),
{ ...size }
)
}
⚠️ Changed in Next.js 16
The image-generating function now receives
paramsandidas Promises — matching the async Request APIs change.jsx// ❌ Next.js 15 export default function Image({ params, id }) { const slug = params.slug const imageId = id // string } // ✅ Next.js 16 export default async function Image({ params, id }) { const { slug } = await params const imageId = await id // Promise<string> }Note the asymmetry:
generateImageMetadatastill receives synchronousparams. Only theImagefunction's props became Promises.
The CSS constraints
ImageResponse uses Satori, not a browser. It supports a subset of CSS, and the limits are strict:
✅ display: flex | display: none
✅ flexbox layout, absolute positioning
✅ colors, gradients, borders, border-radius, shadows
✅ font-size, font-weight, letter-spacing, line-height
✅ <img> with an absolute URL
❌ display: grid | display: block | display: inline
❌ float, position: sticky
❌ CSS variables, media queries
❌ pseudo-elements (::before, ::after)
❌ external stylesheets or Tailwind classes (unless you use tw="…")
Every element with more than one child needs an explicit display: flex. This is the error you'll hit first:
Error: Expected <div> to have explicit "display: flex"
Custom fonts
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export default async function Image(props: { params: Promise<{ slug: string }> }) {
const { slug } = await props.params
const post = await getPost(slug)
const fontData = await readFile(
path.join(process.cwd(), 'assets/Inter-Bold.ttf')
)
return new ImageResponse(
(
<div style={{ display: 'flex', fontFamily: 'Inter', fontSize: 64 }}>
{post?.title}
</div>
),
{
...size,
fonts: [{ name: 'Inter', data: fontData, style: 'normal', weight: 700 }],
}
)
}
Multiple images per route
// app/product/[id]/opengraph-image.tsx
export async function generateImageMetadata({
params,
}: {
params: { id: string } // synchronous — unchanged in Next.js 16
}) {
return [
{ id: 'square', size: { width: 600, height: 600 }, alt: 'Square' },
{ id: 'wide', size: { width: 1200, height: 630 }, alt: 'Wide' },
]
}
export default async function Image({
params,
id,
}: {
params: Promise<{ id: string }>
id: Promise<string> // ← Promise in Next.js 16
}) {
const { id: productId } = await params
const variant = await id
const product = await getProduct(productId)
const size = variant === 'square'
? { width: 600, height: 600 }
: { width: 1200, height: 630 }
return new ImageResponse(
<div style={{ display: 'flex' }}>{product.name}</div>,
size
)
}
🗺️ Sitemaps
// app/sitemap.ts
import type { MetadataRoute } from 'next'
import { db } from '@/lib/db'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const base = 'https://acme.com'
const posts = await db.post.findMany({
where: { published: true },
select: { slug: true, updatedAt: true },
})
const staticRoutes = [
{ url: base, lastModified: new Date(), changeFrequency: 'daily' as const, priority: 1 },
{ url: `${base}/blog`, lastModified: new Date(), changeFrequency: 'daily' as const, priority: 0.8 },
{ url: `${base}/pricing`, lastModified: new Date(), changeFrequency: 'monthly' as const, priority: 0.8 },
]
const postRoutes = posts.map((post) => ({
url: `${base}/blog/${post.slug}`,
lastModified: post.updatedAt,
changeFrequency: 'weekly' as const,
priority: 0.6,
}))
return [...staticRoutes, ...postRoutes]
}
// app/sitemap.js
import { db } from '@/lib/db'
export default async function sitemap() {
const base = 'https://acme.com'
const posts = await db.post.findMany({
where: { published: true },
select: { slug: true, updatedAt: true },
})
return [
{ url: base, lastModified: new Date(), changeFrequency: 'daily', priority: 1 },
{ url: `${base}/blog`, lastModified: new Date(), changeFrequency: 'daily', priority: 0.8 },
...posts.map((post) => ({
url: `${base}/blog/${post.slug}`,
lastModified: post.updatedAt,
changeFrequency: 'weekly',
priority: 0.6,
})),
]
}
Served at /sitemap.xml.
Large sitemaps
The limit is 50,000 URLs per file. Split with generateSitemaps:
// app/product/sitemap.ts
export async function generateSitemaps() {
const count = await db.product.count()
const pages = Math.ceil(count / 50000)
return Array.from({ length: pages }, (_, i) => ({ id: i }))
}
export default async function sitemap({ id }: { id: Promise<string> }) {
const resolvedId = await id // ← Promise in Next.js 16
const start = Number(resolvedId) * 50000
const products = await db.product.findMany({
skip: start,
take: 50000,
select: { slug: true, updatedAt: true },
})
return products.map((p) => ({
url: `https://acme.com/product/${p.slug}`,
lastModified: p.updatedAt,
}))
}
⚠️ Changed in Next.js 16
The
idpassed to thesitemapfunction is now a Promise.js// ❌ Next.js 15 export default async function sitemap({ id }) { const start = id * 50000 // id was a number } // ✅ Next.js 16 export default async function sitemap({ id }) { const start = Number(await id) * 50000 }
generateSitemapsitself still receives synchronous params.
🤖 robots.txt
// app/robots.ts
import type { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/admin/', '/api/', '/dashboard/'],
},
{
userAgent: 'GPTBot',
disallow: '/',
},
],
sitemap: 'https://acme.com/sitemap.xml',
}
}
// app/robots.js
export default function robots() {
return {
rules: [
{ userAgent: '*', allow: '/', disallow: ['/admin/', '/api/', '/dashboard/'] },
{ userAgent: 'GPTBot', disallow: '/' },
],
sitemap: 'https://acme.com/sitemap.xml',
}
}
robots.txt is a request to well-behaved crawlers, not access control. Nothing in it protects /admin — that's Chapter 18's job.
📐 JSON-LD structured data
What produces rich results — star ratings, prices, recipe cards, FAQ accordions in Google.
// app/product/[id]/page.tsx
import { getProduct } from '@/lib/products'
export default async function Page(props: PageProps<'/product/[id]'>) {
const { id } = await props.params
const product = await getProduct(id)
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
description: product.description,
image: product.images,
sku: product.sku,
brand: { '@type': 'Brand', name: product.brand },
offers: {
'@type': 'Offer',
url: `https://acme.com/product/${id}`,
priceCurrency: 'USD',
price: (product.priceInCents / 100).toFixed(2),
availability:
product.stock > 0
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock',
},
aggregateRating: product.reviewCount
? {
'@type': 'AggregateRating',
ratingValue: product.averageRating,
reviewCount: product.reviewCount,
}
: undefined,
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<ProductDetails product={product} />
</>
)
}
JSON.stringify on data you control is safe here. If any field can contain user input, escape < to avoid breaking out of the script tag:
const safe = JSON.stringify(jsonLd).replace(/</g, '\\u003c')
Validate with Google's Rich Results Test.
📱 generateViewport
Viewport settings moved out of metadata:
// app/layout.tsx
import type { Viewport } from 'next'
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 5, // don't set 1 — it blocks pinch-zoom (a11y failure)
themeColor: [
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
{ media: '(prefers-color-scheme: dark)', color: '#0f172a' },
],
}
Or dynamically:
export async function generateViewport(props: PageProps<'/[theme]'>): Promise<Viewport> {
const { theme } = await props.params
return { themeColor: theme === 'dark' ? '#0f172a' : '#ffffff' }
}
📋 An SEO checklist
Every page
□ unique <title> under ~60 characters
□ unique description, 120–160 characters
□ canonical URL set
□ one <h1>
Root layout
□ metadataBase set ← the most commonly missed
□ title template
□ default OG image (1200×630)
□ twitter card type
Site
□ sitemap.xml, submitted to Search Console
□ robots.txt pointing at the sitemap
□ HTTPS with valid certificate
□ mobile responsive
□ Core Web Vitals in the green
Content
□ descriptive alt text on every image
□ JSON-LD where a rich result exists for your type
□ internal links between related pages
□ no accidental noindex in production
⚠️ Common Pitfalls
1. Missing metadataBase
Warning: metadataBase property in metadata export is not set
Relative OG image URLs don't resolve, so social previews come back blank. One line in the root layout fixes it.
2. 'use client' on a page with metadata
Metadata exports are silently ignored in Client Components. Keep the page a Server Component and push interactivity into a child.
3. Exporting both metadata and generateMetadata
Build error. Pick one per file.
4. Missing display: flex in ImageResponse
Any element with multiple children needs it explicitly.
5. Tailwind classes in ImageResponse
className="flex text-white" does nothing. Use inline style, or the tw prop:
<div tw="flex text-white text-6xl">{title}</div>
6. Synchronous params or id in image/sitemap functions
The Next.js 16 change. await them.
7. Testing OG images by looking at them
Platforms cache aggressively. Use the official debuggers:
Each has a "scrape again" button. You'll need it.
8. A stray noindex in production
robots: { index: false } // ❌ shipped from a staging config
Check /robots.txt and the meta tag on your live site after every deploy. This one silently deletes your traffic.
9. Forgetting the OG image is public
opengraph-image.tsx is fetched by anonymous crawlers with no session. Don't render private data into it.
🎯 When & Why to Use
metadata (static) → values known at build time
generateMetadata → values from your data
opengraph-image.tsx → per-page share cards worth generating
opengraph-image.png → one image for the whole site
sitemap.ts → always, for any indexable site
robots.ts → always
JSON-LD → products, articles, recipes, events, FAQs
generateViewport → theme color, viewport settings
🏋️ Mini Practice Problems
Problem 1: Fix the metadata
'use client'
export const metadata = {
title: 'Dashboard',
}
export default function Page() {
const [tab, setTab] = useState('overview')
return <Tabs value={tab} onChange={setTab} />
}
Two problems. Explain and rewrite.
Problem 2: Migrate to Next.js 16
// app/product/[id]/opengraph-image.js
export function generateImageMetadata({ params }) {
return [{ id: 'a' }, { id: 'b' }]
}
export default function Image({ params, id }) {
const product = getProduct(params.id)
return new ImageResponse(<div>{product.name} — {id}</div>)
}
Problem 3: Debug the preview
A blog post's OG image is blank in Slack but the file loads fine when you visit /blog/hello/opengraph-image directly. List four possible causes.
Problem 4: Build it
An e-commerce product page with:
- Title
"<product> | Acme"via the template - Description from the product's short copy
- A generated OG image showing name, price, and rating
- JSON-LD
Productwith offers and aggregate rating - Canonical URL
noindexwhen the product is discontinued
💼 Interview Notes
Common Questions
Q: How does metadata work in the App Router?
You export a metadata object or a generateMetadata function from a layout or page. Next.js resolves the tree, merges shallowly with deeper segments winning, and injects the tags into <head> during server rendering. You never write <head> yourself.
Q: metadata or generateMetadata?
metadata for static values known at build time. generateMetadata when the values come from data — it's async and receives params and searchParams. You can't export both from one file.
Q: What is metadataBase and why does it matter?
The absolute base URL used to resolve relative URLs in metadata — especially OG images. Without it, relative image paths don't resolve and social previews are blank. It's the single most commonly missed setting.
Q: How do you generate a dynamic OG image?
An opengraph-image.tsx file exporting a function that returns an ImageResponse built from JSX. It renders through Satori — flexbox only, no grid, no external CSS, explicit display: flex on multi-child elements.
Q: Does dynamic metadata slow down rendering?
generateMetadata runs before the page streams, so a slow fetch there delays the first byte. Keep it fast and rely on request deduplication so it shares queries with the page.
Q: How do you handle a site with a million URLs in a sitemap?
generateSitemaps splits into chunks of 50,000. In Next.js 16 the id passed to the sitemap function is a Promise and must be awaited.
Q: What's JSON-LD for?
Structured data that lets search engines produce rich results — star ratings, prices, FAQ accordions. Embed it as a <script type="application/ld+json"> in the page.
🏢 Asked at Companies
- Vercel: "Design per-post OG images for a blog with 10,000 posts. What are the performance implications?"
- Shopify: "How do you avoid duplicate content penalties on a store with filtered product listings?"
- HubSpot: "A marketing site's traffic drops 90% overnight after a deploy. What do you check first?"
- Medium: "Explain how a shared link becomes a preview card, end to end."
📊 Visual Memory Aid
METADATA SOURCES
app/layout.tsx
export const metadata = { title: { template: '%s | Acme' } }
│ merges down (shallow — deeper wins)
▼
app/blog/[slug]/page.tsx
export async function generateMetadata() → "Post Title | Acme"
FILE CONVENTIONS
favicon.ico → <link rel="icon">
icon.png → <link rel="icon">
apple-icon.png → <link rel="apple-touch-icon">
opengraph-image.tsx → <meta property="og:image">
twitter-image.tsx → <meta name="twitter:image">
sitemap.ts → /sitemap.xml
robots.ts → /robots.txt
manifest.ts → /manifest.webmanifest
NEXT.JS 16 ASYNC CHANGES
Image({ params, id }) both are Promises ← await them
generateImageMetadata({params}) synchronous ← unchanged
sitemap({ id }) Promise ← await it
generateSitemaps() synchronous ← unchanged
ImageResponse CSS
✅ display: flex flexbox colors gradients
borders shadows font-size <img src="absolute">
❌ grid block inline float CSS vars
media queries ::before Tailwind classNames
⚠️ multiple children? explicit display: flex REQUIRED
🎯 Key Takeaways
- Export metadata, never write
<head>.metadatafor static values,generateMetadatafor data-driven ones — and never both in the same file. - Set
metadataBasein the root layout. Without it, relative OG image URLs don't resolve and every social preview is blank. ImageResponserenders through Satori, not a browser. Flexbox only, inline styles only, and explicitdisplay: flexon anything with multiple children.- Next.js 16 made
paramsandidPromises inopengraph-image,icon, andsitemapfunctions — whilegenerateImageMetadataandgenerateSitemapsstayed synchronous. generateMetadataand the page share queries via request deduplication, so fetching the same data twice costs one query. Wrap non-fetchsources incache().
Next Chapter: Images & Fonts →
Practice: Add complete metadata to a blog — root template, metadataBase, per-post generateMetadata, a generated OG image with a custom font, a sitemap, and robots.ts. Then paste a post URL into Slack and the Facebook debugger and confirm the card renders.