Skip to content

Metadata and SEO

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.

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.

app/about/page.tsx
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#metadatabase

That 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.

app/layout.tsx
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>.

app/blog/[slug]/page.tsx
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.

app/[lang]/lesson/[slug]/page.tsx
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 metadata
import 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-viewport

The fix is a sibling export, not a field:

// app/layout.tsx | page.tsx
import 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 image
import { 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-image
HTTP/1.1 200 OK
content-type: image/png
cache-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-interlaced

Because 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.xml
import 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
How the Metadata API builds the head

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.

What you getBenefitCost
metadataBase set once, in the rootEvery relative OG/Twitter/canonical URL anywhere resolves correctlyEasy 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 ownHTML-limited bots still block on it, so slow metadata fetches still matter for SEO
alternates.languagesCrawlers treat translated lessons as one entity, not duplicatesMust stay in sync with the actual route structure per language
opengraph-image.tsx + ImageResponseGenerated, cached, no manual asset pipelineRenders via Satori (not a full browser) — CSS support is a subset
  • “If I forget metadataBase, the build fails so I’ll notice.” It doesn’t — it warns and falls back to http://localhost:3000, which is only visible if you read build output or inspect the rendered tag in production.
  • “viewport is just another field on the metadata object, like title.” It used to be; today it’s a separate export (viewport or generateViewport), and putting it inside metadata produces a real, visible warning and the tag is dropped.
  • “alternates.languages is 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 against metadataBase).
  • “opengraph-image.tsx runs 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.

When a generated snippet touches metadata, check for these before trusting it:

  • Is there a relative openGraph.images or twitter.images URL anywhere without a metadataBase set in the root metadata? That silently resolves to localhost in production.
  • Is viewport (or themeColor, width, initialScale) sitting inside the metadata object instead of its own viewport/generateViewport export?
  • Does a route segment export both viewport and generateViewport? Only one is allowed.
  • For a bilingual or multi-locale route, is alternates.languages present and pointing at the actual sibling routes?
  • Does generateMetadata duplicate a fetch call the page already makes, assuming it isn’t deduplicated? (It is — but only if the fetch is genuinely identical.)

🐛 Spot the bug

app/layout.tsx
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.

When should you use generateMetadata instead of a static metadata object?
What happens if metadataBase is missing and openGraph.images uses a relative path?
Where does viewport configuration belong in current Next.js, and what happens if you put it inside metadata instead?
What is alternates.languages for, and does it require separate domains per language?
What does opengraph-image.tsx with ImageResponse actually render with?
What is wrong with this export, and what does Next.js do about it?
export const metadata: Metadata = {
  title: 'Acme',
  viewport: 'width=device-width, initial-scale=1',
}