Metadata and SEO
The idea in one sentence
Section titled “The idea in one sentence”You describe a page’s <head> as data — a static metadata object or a dynamic generateMetadata function — and Next.js renders the correct tags on the server, so you never hand-manage <head> again.
Static metadata
Section titled “Static metadata”Export a metadata object from any layout or page and Next.js turns it into <title>, <meta>, and link tags. It runs on the server, so the tags are present in the initial HTML that crawlers read.
import type { Metadata } from 'next'
export const metadata: Metadata = { title: 'About', description: 'Who we are and what we build.', openGraph: { title: 'About Acme', images: ['/og.png'] },}
export default function AboutPage() { return <h1>About</h1>}metadataBase: the missing setting that silently breaks Open Graph images
Section titled “metadataBase: the missing setting that silently breaks Open Graph images”That openGraph.images: ['/og.png'] above is a relative path. Open Graph and Twitter crawlers need an absolute URL, so Next.js needs a base to resolve it against — metadataBase. Skip it and the framework doesn’t fail the build; it falls back to http://localhost:3000 and warns. Proven against the probe’s root layout (which, like this course’s example, has no metadataBase set):
⚠ metadataBase property in metadata export is not set for resolving social open graph or twitter images, using "http://localhost:3000". See https://nextjs.org/docs/app/api-reference/functions/generate-metadata#metadatabaseThat means every relative og:image in production silently resolves to a localhost URL unless you set it — social platforms fetching the tag get an unreachable image.
import type { Metadata } from 'next'
export const metadata: Metadata = { metadataBase: new URL('https://acme.com'), title: { template: '%s | Acme', default: 'Acme' },}metadataBase is only needed once, in the root layout (or wherever the root metadata lives) — every relative URL in openGraph.images, twitter.images, and alternates.canonical anywhere in the tree resolves against it.
Dynamic metadata, title templates, and alternates.languages
Section titled “Dynamic metadata, title templates, and alternates.languages”When the tags depend on data — a blog post, a product — export generateMetadata instead. It receives the route params, runs on the server, and returns the same Metadata shape. Fetches here are deduplicated against the ones in your page, so you do not pay twice. Since 15.2 its output is streamed: for regular browsers Next.js sends the page without waiting for generateMetadata and appends the resulting tags to the <body> once it resolves; for HTML-limited bots that cannot run JavaScript (Twitterbot, Slackbot, Bingbot — the default htmlLimitedBots list) it blocks and places them in <head>. Static metadata always lands in <head>.
import type { Metadata } from 'next'
export async function generateMetadata({ params,}: { params: Promise<{ slug: string }>}): Promise<Metadata> { const { slug } = await params const post = await fetch(`https://api.example.com/posts/${slug}`).then((r) => r.json()) return { title: post.title, description: post.excerpt }}A layout can define a title.template so every child page gets a consistent suffix. A default is required alongside the template.
// app/layout.tsx — every child title becomes "<page> | Acme"import type { Metadata } from 'next'
export const metadata: Metadata = { title: { template: '%s | Acme', default: 'Acme', },}A course that ships every lesson in two languages is exactly the case alternates.languages exists for — it emits hreflang links so search engines know the /en and /th versions are translations of one another, not duplicate content.
import type { Metadata } from 'next'
export const metadata: Metadata = { metadataBase: new URL('https://learn.example.com'), alternates: { languages: { en: '/en/lesson/images-and-fonts', th: '/th/lesson/images-and-fonts', }, },}Rendered head output — one link rel="alternate" per language, resolved against metadataBase:
<link rel="alternate" hreflang="en" href="https://learn.example.com/en/lesson/images-and-fonts" /><link rel="alternate" hreflang="th" href="https://learn.example.com/th/lesson/images-and-fonts" />generateViewport and viewport are separate from metadata
Section titled “generateViewport and viewport are separate from metadata”viewport (or generateViewport for a dynamic version) is its own export — it is not a field inside metadata, even though older Next.js versions had you set viewport that way. Put it inside metadata today and Next.js does not silently merge it; it drops it and warns. Proven against the probe:
// app/viewport-bug/page.tsx — @expect-error viewport does not belong inside metadataimport type { Metadata } from 'next'
export const metadata: Metadata = { title: 'Viewport bug demo', viewport: 'width=device-width, initial-scale=1',}⚠ Unsupported metadata viewport is configured in metadata export in /viewport-bug. Please move it to viewport export instead.Read more: https://nextjs.org/docs/app/api-reference/functions/generate-viewportThe fix is a sibling export, not a field:
// app/layout.tsx | page.tsximport type { Viewport } from 'next'
export const viewport: Viewport = { width: 'device-width', initialScale: 1, themeColor: 'black',}viewport/generateViewport are also only supported in Server Components, and a route segment cannot export both viewport and generateViewport at once — pick one.
File-based metadata, including opengraph-image built with ImageResponse
Section titled “File-based metadata, including opengraph-image built with ImageResponse”Some metadata is a file, not a field. Drop a specially named file into a route segment and Next.js wires it up: opengraph-image.tsx generates a social preview, icon.png becomes the favicon, and sitemap.ts and robots.ts become their respective routes. These special routes are cached by default.
// app/opengraph-image.tsx — generated, cached social imageimport { ImageResponse } from 'next/og'
export const size = { width: 1200, height: 630 }export const contentType = 'image/png'
export default function Image() { return new ImageResponse(<div style={{ fontSize: 96 }}>Acme</div>, size)}Built and served against the probe, this is a real generated file, not a placeholder — curl against the resulting route returns exactly the declared size and contentType:
$ curl -sD - -o /tmp/og.png http://localhost:3181/opengraph-imageHTTP/1.1 200 OKcontent-type: image/pngcache-control: public, max-age=0, must-revalidate$ file /tmp/og.png/tmp/og.png: PNG image data, 1200 x 630, 8-bit/color RGBA, non-interlacedBecause it’s a file-based route generated by ImageResponse (which renders JSX to an image using Satori, not a browser), it needs metadataBase too if it references anything relative — the same setting from earlier covers it.
// app/sitemap.ts — becomes /sitemap.xmlimport type { MetadataRoute } from 'next'
export default function sitemap(): MetadataRoute.Sitemap { return [{ url: 'https://acme.com', lastModified: new Date() }]}Because everything is data or a file, there is no <head> to keep in sync by hand — the template merges parent and child, and duplicate tags cannot drift apart.
flowchart TD A["metadata object (needs metadataBase for relative URLs)"] --> M[Merge parent + child] B["generateMetadata(params)"] --> M V["viewport / generateViewport (separate export)"] --> Head C["opengraph-image (ImageResponse) / sitemap / robots files"] --> M M --> Static["static metadata: tags in head"] M --> Stream["generateMetadata: streamed into body (HTML-limited bots: blocking, in head)"] Static --> Head[head] Stream --> Head
Mental Model
Section titled “Mental Model”Treat metadata the way you’d treat a form with a required field the validator doesn’t enforce: metadataBase is that field. Nothing stops you from shipping without it — the framework fills in a placeholder (localhost:3000) and moves on — but every relative image URL downstream is now silently wrong in production. viewport living outside metadata is the same shape of trap in reverse: the old, combined API still parses, so nothing looks broken until you notice the tag never rendered.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
metadataBase set once, in the root | Every relative OG/Twitter/canonical URL anywhere resolves correctly | Easy to forget — the fallback hides the mistake instead of failing loudly |
generateMetadata (streamed) | Page ships without waiting on it; fetches dedupe with the page’s own | HTML-limited bots still block on it, so slow metadata fetches still matter for SEO |
alternates.languages | Crawlers treat translated lessons as one entity, not duplicates | Must stay in sync with the actual route structure per language |
opengraph-image.tsx + ImageResponse | Generated, cached, no manual asset pipeline | Renders via Satori (not a full browser) — CSS support is a subset |
Common Misconceptions
Section titled “Common Misconceptions”- “If I forget
metadataBase, the build fails so I’ll notice.” It doesn’t — it warns and falls back tohttp://localhost:3000, which is only visible if you read build output or inspect the rendered tag in production. - “
viewportis just another field on themetadataobject, liketitle.” It used to be; today it’s a separate export (viewportorgenerateViewport), and putting it insidemetadataproduces a real, visible warning and the tag is dropped. - “
alternates.languagesis only for fully separate domains.” It works fine for path-based locales (/en/...,/th/...) like this course — the values just need to be resolvable URLs (relative ones resolve againstmetadataBase). - “
opengraph-image.tsxruns in a real browser, so any CSS works.” It renders through Satori, not a browser engine — layout support is closer to a constrained flexbox subset than full CSS.
Reading AI-generated code
Section titled “Reading AI-generated code”When a generated snippet touches metadata, check for these before trusting it:
- Is there a relative
openGraph.imagesortwitter.imagesURL anywhere without ametadataBaseset in the root metadata? That silently resolves tolocalhostin production. - Is
viewport(orthemeColor,width,initialScale) sitting inside themetadataobject instead of its ownviewport/generateViewportexport? - Does a route segment export both
viewportandgenerateViewport? Only one is allowed. - For a bilingual or multi-locale route, is
alternates.languagespresent and pointing at the actual sibling routes? - Does
generateMetadataduplicate afetchcall the page already makes, assuming it isn’t deduplicated? (It is — but only if the fetch is genuinely identical.)
🐛 Spot the bug
import type { Metadata } from 'next'
export const metadata: Metadata = {title: 'Acme',viewport: 'width=device-width, initial-scale=1',openGraph: { images: ['/og.png'],},}Show the answer
Two real bugs here, both silent. First, viewport is a field inside metadata — that’s the pre-16 shape; today it must be its own export const viewport (or generateViewport), and Next.js drops it with a build-time warning (“Unsupported metadata viewport is configured in metadata export… Please move it to viewport export instead”) rather than applying it. Second, openGraph.images: ['/og.png'] is relative and there is no metadataBase anywhere in this layout — proven in the probe, that combination produces “metadataBase property in metadata export is not set… using “http://localhost:3000\"", meaning the real production OG image tag points at a URL that doesn’t exist outside a developer’s machine. The fix: move viewport to its own export, and add metadataBase: new URL('https://acme.com') next to title.