One Export, a Complete <head>: Declaring Title and Description Statically
The browser tab, the Google result, and the Slack preview all read from the same place: the document's <head>. In the Pages Router you managed it by importing next/head and sprinkling <Head> components through pages — imperative, easy to duplicate, easy to break by rendering two of them. The App Router replaces that with a declarative export: a metadata object exported from a page or layout file, which Next renders into the server-sent <head> for you.
// app/pricing/page.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Pricing — Acme",
description: "Simple per-seat pricing with a free tier. No credit card required.",
};
export default function PricingPage() { /* ... */ }
Two rules govern where it can live, and both are compile-time facts: only page.tsx, layout.tsx, and route.ts-adjacent special files may export metadata — a component file exporting metadata simply has it ignored, which is the silent failure to recognize when someone moves a page's title into a shared component and the tab goes back to the default. And the export must be statically analyzable: a plain object (or an async function returning one, the next topic). let title = ...; export const metadata = { title } is exactly the pattern the docs warn about — Next reads these exports statically at build, and computed values are invisible to that pass.
The Metadata type is the real API surface, and it is worth browsing once in your editor. The fields you will use weekly:
| Field | Renders as | Notes |
|---|---|---|
title |
<title> |
string, object, or template form (below) |
description |
<meta name="description"> |
the snippet Google shows under the link |
keywords |
<meta name="keywords"> |
largely ignored by engines; exists because people ask |
robots |
<meta name="robots"> |
{ index: false, follow: true } for staging pages |
alternates.canonical |
<link rel="canonical"> |
the duplicate-URL consolidator |
icons |
favicon/apple-touch links | file conventions still work automatically |
openGraph / twitter |
og: / twitter: tags |
social previews — two dedicated topics ahead |
verification |
Google/Bing site-verification tags | beats editing <head> by hand |
title has three forms, and the difference matters for the merging rules in the next chapter:
export const metadata: Metadata = {
title: "Pricing", // <title>Pricing</title>
title: { absolute: "Acme Pricing" }, // <title>Acme Pricing</title> — ignores parent templates
title: { default: "Acme", template: "%s — Acme" }; // layout form: children fill %s
};
The template form is a layout's job: the root layout declares template: "%s — Acme", and every descendant page that exports title: "Pricing" renders as Pricing — Acme without repeating the brand. A page that needs to escape the template (the home page, typically) uses absolute. You never concatenate the brand by hand in twenty files — you declare it once, in one place, which is the same design instinct as a layout for UI.
One version note that saves debugging time: viewport, themeColor, and colorScheme moved out of metadata into a separate viewport export (Next 14+). If a viewport-ish key you set in metadata has no effect, it is not a caching problem — it belongs in export const viewport, and the type system will steer you there.
Merge Semantics: Why Your Child Page Only Overrides What It Differs On
A route is a stack of layouts plus a page, and each level may export metadata. Next.js does not make you restate the whole object at the leaf — metadata merges with parent layouts, field by field, so a child only declares what differs. The root layout says the brand; the pricing page says its title. Both end up in one <head>.
The merge is a shallow, field-scoped combination of the tree, root-first:
// app/layout.tsx
export const metadata: Metadata = {
title: { default: "Acme", template: "%s — Acme" },
description: "Acme builds tools for teams.",
metadataBase: new URL("https://acme.com"),
openGraph: { siteName: "Acme" },
};
// app/pricing/page.tsx
export const metadata: Metadata = {
title: "Pricing", // → <title>Pricing — Acme</title> (template applied)
robots: { index: false }, // adds robots; everything else inherited
};
The result for /pricing carries the layout's metadataBase, openGraph.siteName, and description, the page's title (run through the template) and robots tag. Study the rules that produce it:
| Field shape | Merge behavior |
|---|---|
Primitives (description, robots object) |
child replaces parent when present; inherits when absent |
title |
special-cased: the child's string fills the parent's %s template; absolute bypasses it |
Nested objects (openGraph, twitter) |
shallow-merged per property — a child's openGraph.images does not wipe the parent's siteName |
Arrays (authors, openGraph.images) |
replaced wholesale by the child — no concatenation |
The array rule is the one that surprises: there is no "append my images to the layout's" behavior. If two levels both set openGraph.images, the deeper level's array is the output. Plan the ownership of each field deliberately — one level owns the canonical site name; another owns per-page images — rather than discovering the replacement at runtime.
Where the merge stops is as instructive as where it applies: metadata is collected from the matched route's own segment files — the page and the layouts above it. It does not cross into unrelated branches, and it does not come from imported modules. This is also why dynamic routes can have file-level metadata only for what the file knows statically; a [slug] page that needs the record's name in the title must move from the static export to the async function of Dynamic generateMetadata.
A layout's metadata applies to every page beneath it unless overridden — which makes the root layout the correct home for the invariant fields (brand, site name, metadataBase, default OG image) and the page the home for the varying ones (title, description, canonical, per-page OG image). Two maintenance properties fall out of this that interviewers appreciate: a brand rename is a one-line change in one file, and a page that exports nothing still produces correct, branded metadata — the failure mode of the <head>-by-hand era, where a forgotten component meant an empty description on 40 routes.
The static export cannot express per-record data — that is its one boundary. Everything up to this point assumes the values are known at build; the moment the title must contain the product's name or the description must come from the CMS record, you are in the async function world, where the same merge rules still apply to whatever the function returns.
What the Crawler Actually Sees: Server-Rendered Tags, metadataBase, and Verification
The claim that matters: metadata tags are rendered on the server and arrive in the initial HTML response, readable by any client that does not run JavaScript. View-source proves it — the <head> you see is what a crawler gets, with no render queue, no second pass, no hydration dependency. That is the whole reason the Metadata API exists as a server feature rather than a client-side effect of the framework.
Verify it the way you would verify anything on the wire:
curl -s http://localhost:3000/pricing | grep -E '<title>|name="description"|og:'
View-source in devtools shows the same. The subtle trap of the elements panel is that it displays the live DOM, post-hydration — if a client component were mutating head tags (it should not, in the App Router), the panel would show values the initial HTML never contained. Grep the raw response, not the inspector, when the question is "what did the crawler receive."
Real errors you will meet in this area, and what they mean:
| Symptom | Cause | Fix |
|---|---|---|
Warning: metadataBase property in metadata export is not set (plus a fallback to localhost) |
relative image/URL fields need a base to resolve against | set metadataBase: new URL(process.env.SITE_URL) in the root layout |
<title> is the raw filename or app default |
no metadata export on the page or any ancestor layout | export it from page.tsx/layout.tsx — a component's export is ignored |
viewport fields do nothing |
viewport moved to a separate export (Next 14+) | export const viewport: Viewport = { ... } |
| Staging page indexed anyway | metadata alone cannot block a crawler that was already told to fetch | combine robots: { index: false } with auth or robots.txt (next topics) |
metadataBase deserves its own paragraph because it underpins social sharing: openGraph.images with a relative path (/og/home.png) must be resolved to an absolute URL (https://acme.com/og/home.png) before any platform can fetch it. Without a base, Next warns and substitutes http://localhost:3000 — and a preview card pointing at localhost is a preview card that renders nothing for every sharer outside your laptop. Set it from an env var, once, in the root layout.
A note on the static export's reach into head content: verification fields (google, bing) emit the meta tags verification flows expect, which is genuinely easier than hand-editing <head> — but nothing stops you from serving other head content through special files: favicon.ico conventions, opengraph-image file conventions, and robots.txt/sitemap generators all live beside the pages they describe. The Metadata API is the typed, merging, per-segment layer of that system; the file conventions are the zero-config layer; both render on the server, and both stop at the same boundary — anything requiring a fetched record needs the async function form, covered next in Dynamic generateMetadata.
For the interview, the one-sentence version: the Metadata API is a statically-analyzable export from page/layout files that Next merges up the layout tree and renders into the server's <head>, so crawlers and social fetchers see titles, descriptions, and robots directives in the raw HTML with no JavaScript involved — which is also why per-record values require generateMetadata rather than this static form.
Quiz
1. A shared <HeadBlock> component exports metadata = { title: "Docs" }. What renders in the page that imports it?
2. The root layout exports title: { default: "Acme", template: "%s — Acme" } and the pricing page exports title: "Pricing". What does <title> show for /pricing?
3. Both a layout and its page export openGraph.images. What is the result?
4. A crawler fetches your page with JavaScript disabled. Which statement about the Metadata API output is true?
Flashcards
Question
Where may a metadata export live, and what happens if a component exports one?
Click to reveal answer
Answer
Only in special route files — page.tsx and layout.tsx (metadata is read statically from them). A component file's export is silently ignored: the route keeps its inherited or default metadata. Values must be statically analyzable — no computed variables.
Question
The three title forms and what each produces.
Click to reveal answer
Answer
String: used as-is (filled into a parent template if one exists). Object with template (layout form): "%s — Brand" fills child titles. Object with absolute: bypasses the parent template entirely — right for the home page or special routes.
Question
How does metadata merging with parent layouts work, field by field?
Click to reveal answer
Answer
Matched segment files merge root-first: primitives are replaced by a child when present, inherited when absent; title strings fill parent templates; nested objects (openGraph, twitter) shallow-merge per property; arrays are replaced wholesale. A page exporting nothing still renders full branded metadata.
Question
Why does metadataBase matter, and what goes wrong without it?
Click to reveal answer
Answer
Relative URLs (og:image paths) must resolve to absolute URLs for social fetchers. Without metadataBase, Next warns and falls back to http://localhost:3000 — every preview image URL points at the developer's machine and renders for nobody. Set it once in the root layout from an env var.
Question
viewport/themeColor moved out of metadata — to where, and how do you verify what crawlers see?
Click to reveal answer
Answer
They belong in a separate export: export const viewport: Viewport (Next 14+); setting them in metadata has no effect. To verify crawler-visible tags, inspect the raw response (view-source or curl | grep) — the devtools Elements panel shows the post-hydration DOM, not the initial HTML.
Revision Notes
Key Takeaways
- 1. Metadata is a statically-analyzable export from page/layout files; component exports are ignored without error
- 2. Title templates live in layouts and child titles fill them — branding declared once, applied everywhere
- 3. The merge is field-scoped: primitives replace-or-inherit, nested objects shallow-merge, arrays replace wholesale
- 4. Tags are server-rendered into the initial HTML, so JS-less crawlers see them — verify the raw response, not the hydrated DOM
- 5. metadataBase turns relative image paths into absolute URLs; without it, social previews point at localhost
Interview Tips
- • "How do you set titles in the App Router?" — the metadata export, typed by next's Metadata, merged up the layout tree with the title-template mechanism; contrast with next/head to show you know both eras
- • "Why is my description not appearing?" — walk the checkable causes: export in a component file (ignored), computed value (not statically analyzable), parent overriding — then show the curl-and-grep verification step
- • "Static metadata vs generateMetadata" — static is build-known values; any per-record or request-dependent value needs the async function; state that merge rules apply to its return value identically
- • "What do crawlers actually receive?" — everything is in the server's initial HTML response; mention metadataBase/localhost as the classic social-preview bug and robots:false plus auth as the real noindex story
Cheat Sheet
The Metadata API
Declare it: export const metadata: Metadata from page.tsx / layout.tsx only (component exports are silently ignored; values must be statically analyzable).
export const metadata: Metadata = {
title: "Pricing", // or { template: "%s — Acme" } / { absolute: "..." }
description: "...",
robots: { index: false },
alternates: { canonical: "/pricing" },
metadataBase: new URL(process.env.SITE_URL!),
};
Merge rules (root-first, per segment):
| Shape | Behavior |
|---|---|
| primitives | child replaces, else inherits |
title |
child string fills parent %s template; absolute bypasses |
| nested objects | shallow-merged per property |
| arrays | replaced wholesale by the child |
Own fields per level: root layout = brand, siteName, metadataBase, default OG image · page = title, description, canonical, per-page images. viewport/themeColor → separate viewport export (Next 14+).
Crawler truth: tags render on the server into the initial HTML — verify with curl -s URL | grep -E '<title>|name="description"|og:', never the Elements panel (post-hydration DOM).
Common errors: relative og:image without metadataBase → localhost warning and dead previews · forgotten title → filename default · staging indexed → robots: { index: false } + real access control.
Boundary: per-record titles/descriptions need the async generateMetadata form — same merge rules apply to whatever it returns.