The Metadata API
Give every Next.js page its title, description and link-preview tags with metadata and generateMetadata, title templates, merging, and the viewport.
- 01Write a page's metadataExport a metadata object or generateMetadata from a layout or a page, and read the <head> tags Next.js makes from it.
- 02Predict the merged resultUse title templates, and tell which fields a page inherits from its layouts and which it replaces whole, like openGraph.
- 03Know when metadata waitsPut themeColor in the viewport, satisfy Cache Components in generateMetadata, and tell where streamed metadata lands for browsers and bots.
The tea shop's title, written as data
Every HTML page has a <head>: the part the visitor doesn't see, with the page's title and descriptions for other programs. The browser shows the <title> in the tab. Search engines may use the title and the description in their results, and may also rewrite them. Chat apps read link-preview tags to draw a card when someone shares the page. In Next.js you don't write any of these tags. You export an object called metadata from a layout or a page, and Next.js writes the tags. This is the Metadata API.
app/layout.tsx1import type { Metadata } from "next";2 3export const metadata: Metadata = {4 title: "Tea shop",5 description: "Three teas from one small shop.",6};7 8export default function RootLayout({ children }: LayoutProps<"/">) {9 return (10 <html lang="en">11 <body>{children}</body>12 </html>13 );14}GET /200
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1x-nextjs-stale-time300X-Powered-ByNext.jsCache-Controls-maxage=31536000Content-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 14 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
<script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
<title>Tea shop</title>
<meta name="description" content="Three teas from one small shop.">
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<!--&-->
<main>
<h1>Tea shop</h1>
<p>Green tea, mint tea and lemon tea.</p>
</main>
<!--$-->
<!--/$-->
<!--/&-->
<script id="_R_">…</script>
<script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
</body>
</html>This is a real Next.js 16.4.3 project, built with next build and run with next start. In app/layout.tsx, line 1 imports the Metadata type, so your editor checks each field's name and value. Lines 3 to 6 export the object: a title on line 4 and a description on line 5. The layout's component, on lines 8 to 14, never mentions <head>.
The Network tab shows the HTML that arrived. Its <head> starts with two tags Next.js adds to every page, <meta charset="utf-8"> and <meta name="viewport" content="width=device-width, initial-scale=1">. Further down are <title>Tea shop</title> and <meta name="description" content="Three teas from one small shop.">, from lines 4 and 5. The Browser tab shows Tea shop as the tab's title.
metadata becomes a tag in the <head>: title a <title>, and description a <meta name="description">. Next.js writes the <head>; the layout never mentions it.A tea shop prints its labels from one design: a blank for the tea's name, then the shop's name. Each box only says what goes in the blank. A gift box has a special label that skips the design. A box nobody labeled gets the shop's own sticker. Nobody draws a label by hand: the printer prints what the sheets say.
- In real life: The shop's label design: a blank for the tea's name, then the shop's name
- In Next.js:
title.templatein the root layout:"%s | Tea shop" - In real life: Each box writes its own tea's name in the blank
- In Next.js: Each page's
titlefills in%s - In real life: A special box with a label of its own
- In Next.js:
title.absolute, which skips the template - In real life: The shop's sticker on a box nobody labeled
- In Next.js:
title.default, for pages without a title - In real life: The label printer, which prints what the sheets say
- In Next.js: Next.js, which writes the
<head>from your metadata
You describe each page's metadata as data, in the layouts and the page of its route, and Next.js writes the <head> tags. It starts at the root layout and goes down to the page, and a field set lower down replaces the same field from above.
The metadata object, in a layout or a page
metadata can be exported from a layout.tsx or a page.tsx. Each field it has becomes one or more tags. A few you'll use often:
title: the<title>. It can also be an object with a template for the pages below.description:<meta name="description">, text search engines may show under the title.openGraph: theog:tags that link previews read, likeog:titleandog:description. Open Graph is the name of that set of tags.icons:<link rel="icon">tags written by hand. An icon file inapp, likeapp/icon.svgfrom Static files, wins over them, and the docs suggest the file.
metadata only works in a Server Component. Next.js resolves it on the server before it renders the page, so the tags can be in the first HTML. A page that starts with "use client" can't export it, and the build says so:
app/gifts/page.tsx1"use client";2 3import { useState } from "react";4import type { Metadata } from "next";5 6export const metadata: Metadata = {7 title: "Gift boxes",8};9 10export default function GiftsPage() {11 const [count, setCount] = useState(1);12 return (13 <main>14 <h1>Gift boxes</h1>15 <button onClick={() => setCount(count + 1)}>Boxes: {count}</button>16 </main>17 );18}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ... > Build error occurredError: Turbopack build failed with 1 error:./app/gifts/page.tsx:6:14Error: You are attempting to export "metadata" from a component marked with "use client", which is disallowed. "metadata" must be resolved on the server before the page component is rendered. Keep your page as a Server Component and move Client Component logic to a separate file. Read more: https://nextjs.org/docs/app/api-reference/functions/generate-metadata#why-generatemetadata-is-server-component-only 4 | import type { Metadata } from "next"; 5 |> 6 | export const metadata: Metadata = { | ^^^^^^^^ 7 | title: "Gift boxes", 8 | }; 9 | Ecmascript file had an error Import traces: Client Component Browser: ./app/gifts/page.tsx [Client Component Browser] ./app/gifts/page.tsx [Server Component] Client Component SSR: ./app/gifts/page.tsx [Client Component SSR] ./app/gifts/page.tsx [Server Component] at ignore-listed framesLine 1 makes app/gifts/page.tsx a Client Component, because the button on its line 15 keeps a count in state. The Terminal tab shows the build stopping at line 6, with You are attempting to export "metadata" from a component marked with "use client", which is disallowed. The fix the error names: keep the page a Server Component with the metadata, and move the button into a Client Component of its own (Composing Server and Client Components shows how).
React 19 can also render a <title> inside any component and move it into the <head> (Metadata, stylesheets and resource hints). In Next.js, the Metadata API is the way for layouts and pages: it merges what each one says into one set of tags.
generateMetadata: metadata from params and data
Each tea has its own page, /teas/mint and /teas/lemon, from one file, app/teas/[id]/page.tsx (Dynamic routes teaches the folder name in brackets). Each page's title should be its tea's name, which only the data knows. For that, a layout or page exports a function instead of an object, generateMetadata:
app/teas/[id]/page.tsx1import type { Metadata } from "next";2import { getTea, getTeas } from "../../teas";3 4export async function generateStaticParams() {5 const teas = await getTeas();6 return teas.map((tea) => ({ id: tea.id }));7}8 9export async function generateMetadata({ params }: PageProps<"/teas/[id]">): Promise<Metadata> {10 const { id } = await params;11 const tea = await getTea(id);12 return {13 title: tea?.name,14 description: `${tea?.name}, $${tea?.price} a box.`,15 };16}17 18export default async function TeaPage({ params }: PageProps<"/teas/[id]">) {19 const { id } = await params;20 const tea = await getTea(id);21 return (22 <main>23 <h1>{tea?.name}</h1>24 <p>${tea?.price} a box.</p>25 </main>26 );27}GET /teas/mint200
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1x-nextjs-stale-time300X-Powered-ByNext.jsCache-Controls-maxage=31536000Content-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 15 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
<script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
<title>Mint tea</title>
<meta name="description" content="Mint tea, $3 a box.">
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<!--&-->
<!--&-->
<!--&-->
<main>
<h1>Mint tea</h1>
<p>$<!-- -->3<!-- --> a box.</p>
</main>
<!--$-->
<!--/$-->
<!--/&-->
<!--/&-->
<!--/&-->
<script id="_R_">…</script>
<script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
</body>
</html>Line 9 declares generateMetadata. Next.js calls it with the same params the page gets: a promise. This one is async because it waits for things: line 10 waits for params and takes the id. (A generateMetadata that waits for nothing can be a plain function.) Line 11 loads the tea from app/teas.ts, a stand-in for the shop's database. Lines 12 to 15 return a Metadata object, like the one in the overview: the tea's name as title, and a description made from its name and price.
In the Network tab, /teas/mint has <title>Mint tea</title> and <meta name="description" content="Mint tea, $3 a box.">. Its title replaced the layout's Tea shop. Lines 4 to 7 are generateStaticParams, which lists the teas, so in the Terminal tab each tea's page is ○: prerendered during the build, with its metadata already in the <head>.
The page loads the same tea again on line 20. With fetch, Next.js remembers the answer for the length of a request, so the second call costs nothing; with a database, the docs suggest wrapping the function in React's cache for the same effect. A second argument, parent, gives generateMetadata the metadata resolved above it, when you want to add to a layout's value instead of replacing it.
What Cache Components needs from generateMetadata
With Cache Components on, as in new projects, generateMetadata follows the same rules as a component. A cookie lives in the visitor's browser, and the server only sees its current value when a request brings it, so reading it means waiting for a request. The cart page wants the visitor's tea in its title. Try three versions, and guess first which one fails to build:
app/cart/page.tsxNext.jsapp/cart/page.tsx1import type { Metadata } from "next";2 3export async function generateMetadata(): Promise<Metadata> {4 return { title: "Your cart" };5}6 7export default function CartPage() {8 return (9 <main>10 <h1>Your cart</h1>11 <p>Check your tea, then pay.</p>12 </main>13 );14}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (3/3) Finalizing page optimization ... Route (app)┌ ○ /_not-found└ ○ /cart ○ (Static) prerendered as static contentgenerateMetadata returns the same title for everyone, so Next.js runs it during the build. The page is ○ /cart, with <title>Your cart</title> in its <head>.
In the second version, the metadata is the only part of the page that needs the request. Next.js could still prerender the rest, and metadata that waits on an otherwise static page is rare, so it asks you to choose. The error lists three fixes: plain metadata if the value isn't really per visitor, "use cache" inside generateMetadata if it reads data but not the request, or a part of the page, inside Suspense, that reads the request or calls await connection(). An empty Suspense isn't enough: what's inside it must do request-time work. The third version does the last: the cart's line reads the cookie inside Suspense, so the build passes, and the title streams in with it.
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { getTea } from "../teas";
export async function generateMetadata(): Promise<Metadata> {
const id = (await cookies()).get("cart")?.value;
const tea = id ? await getTea(id) : undefined;
return { title: tea ? `Cart: ${tea.name}` : "Your cart" };
}
export default function CartPage() {
return (
<main>
<h1>Your cart</h1>
<p>Check your tea, then pay.</p>
</main>
);
}
Line 6 reads the cookie, and line 8 builds the title. The page's component, on lines 11 to 18, reads nothing from the request. That mismatch is what the build refuses.
Title templates: one pattern for every page
Most sites end every title with their name: About us | Tea shop. Instead of writing that on every page, the root layout gives title an object with a template:
app/layout.tsx1import type { Metadata } from "next";2 3export const metadata: Metadata = {4 title: {5 template: "%s | Tea shop",6 default: "Tea shop",7 },8 description: "Three teas from one small shop.",9};10 11export default function RootLayout({ children }: LayoutProps<"/">) {12 return (13 <html lang="en">14 <body>{children}</body>15 </html>16 );17}Green tea, mint tea and lemon tea.
The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.
Line 5 is the template: %s marks the spot a page's title fills in. Line 6 is the default, for pages with no title of their own. The docs say a template needs a default. Pick each page above the Browser tab, and read the title next to the address. Each row below is one recorded page:
| Page | Its title setting | Its <title> |
|---|---|---|
/ | No title of its own. | Tea shop, the layout's default. |
/about | title: "About us" | About us | Tea shop: the template, with %s replaced. |
/checkout | title: { absolute: "Checkout" } | Checkout, with no template. |
/teas/mint | generateMetadata returns title: tea?.name. | Mint tea | Tea shop: a generated title gets the template too. |
Three rules explain the table:
- A template applies to the segments below the one that sets it. The layout's own title, its
default, isn't wrapped:/isTea shop, notTea shop | Tea shop. - Every title below fills it in, whether it's written in an object or returned by
generateMetadata:/teas/mintisMint tea | Tea shop. absoluteskips every template above. Checkout wants nothing extra in its title.
import type { Metadata } from "next";
export const metadata: Metadata = {
title: { absolute: "Checkout" },
};
export default function CheckoutPage() {
return (
<main>
<h1>Checkout</h1>
<p>Pay for your teas.</p>
</main>
);
}
Line 4 gives title an object with only absolute, and the recorded <title> is exactly Checkout. A template in a page does nothing at all, because a page has no segments below it.
How metadata merges down the tree
For each page, Next.js reads the metadata of every layout above it, starting at the root, then the page's own. Then it merges them, one top-level field at a time: a field set lower down replaces the same field from above, and a field nobody lower sets comes down as it is. The merge is shallow: a field whose value is an object, like openGraph, is replaced whole, never combined key by key. Here is the shop's layout with link-preview tags:
app/layout.tsx1import type { Metadata } from "next";2 3export const metadata: Metadata = {4 title: { template: "%s | Tea shop", default: "Tea shop" },5 description: "Three teas from one small shop.",6 openGraph: {7 siteName: "Tea shop",8 title: "Tea shop",9 description: "Loose-leaf tea, packed by hand.",10 },11};12 13export default function RootLayout({ children }: LayoutProps<"/">) {14 return (15 <html lang="en">16 <body>{children}</body>17 </html>18 );19}GET /gifts200
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1x-nextjs-stale-time300X-Powered-ByNext.jsCache-Controls-maxage=31536000Content-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 14 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
<script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
<title>Gift boxes | Tea shop</title>
<meta name="description" content="Three teas from one small shop.">
<meta property="og:title" content="Tea shop">
<meta property="og:description" content="Loose-leaf tea, packed by hand.">
<meta property="og:site_name" content="Tea shop">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Tea shop">
<meta name="twitter:description" content="Loose-leaf tea, packed by hand.">
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<!--&-->
<!--&-->
<main>
<h1>Gift boxes</h1>
<p>Three teas in one box.</p>
</main>
<!--$-->
<!--/$-->
<!--/&-->
<!--/&-->
<script id="_R_">…</script>
<script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
</body>
</html>Lines 6 to 10 give openGraph three fields: siteName, title and a description of its own. The Network tab shows them as og:site_name, og:title and og:description. It also shows three tags nobody wrote: twitter:card, twitter:title and twitter:description. Next.js fills the tags X (once Twitter) reads from openGraph when you don't set them.
The gift boxes page sets only title, so its <title> is Gift boxes | Tea shop. But its og:title is still Tea shop: the whole openGraph came down from the layout, and a link preview would show the shop's title. Change the page and watch its tags:
app/gifts/page.tsxNext.jsapp/gifts/page.tsx1import type { Metadata } from "next";2 3export const metadata: Metadata = {4 title: "Gift boxes",5};6 7export default function GiftsPage() {8 return (9 <main>10 <h1>Gift boxes</h1>11 <p>Three teas in one box.</p>12 </main>13 );14}GET /gifts200
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1x-nextjs-stale-time300X-Powered-ByNext.jsCache-Controls-maxage=31536000Content-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 14 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
<script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
<title>Gift boxes | Tea shop</title>
<meta name="description" content="Three teas from one small shop.">
<meta property="og:title" content="Tea shop">
<meta property="og:description" content="Loose-leaf tea, packed by hand.">
<meta property="og:site_name" content="Tea shop">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Tea shop">
<meta name="twitter:description" content="Loose-leaf tea, packed by hand.">
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<!--&-->
<!--&-->
<main>
<h1>Gift boxes</h1>
<p>Three teas in one box.</p>
</main>
<!--$-->
<!--/$-->
<!--/&-->
<!--/&-->
<script id="_R_">…</script>
<script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
</body>
</html>The page sets only title. Its <title> is Gift boxes | Tea shop, but every og: tag is the layout's, including og:title Tea shop: a link preview would say Tea shop.
/gifts with its own openGraph. Fields merge one at a time: description comes down from the layout, but the page's openGraph replaces the layout's whole object, so og:site_name is gone.The second version sets its own openGraph, with only a title. That replaced the layout's whole object, so og:site_name disappeared. Its og:description isn't the layout's Loose-leaf tea, packed by hand. either: when openGraph has no description, Next.js fills in the page's plain description, here inherited from the layout. The third version keeps the shared fields in a file of its own and copies them in with spread:
import type { Metadata } from "next";
import { shopOpenGraph } from "../shared-metadata";
export const metadata: Metadata = {
title: "Gift boxes",
openGraph: {
...shopOpenGraph,
title: "Gift boxes",
},
};
export default function GiftsPage() {
return (
<main>
<h1>Gift boxes</h1>
<p>Three teas in one box.</p>
</main>
);
}
Line 2 imports shopOpenGraph, an object with siteName and description from app/shared-metadata.ts. Line 7 copies both into the page's own openGraph, and line 8 adds the title. The recording has all three og: tags back. The docs suggest exactly this for fields several pages share.
The viewport and theme color
A few tags are about how the browser draws the page, not what the page is. The viewport tag says how wide the page is on a phone; theme-color colors the browser's bar on some phones; color-scheme says whether the page supports dark mode. Since Next.js 14, they have an export of their own, viewport, typed Viewport:
app/layout.tsx1import type { Metadata, Viewport } from "next";2 3export const metadata: Metadata = {4 title: "Tea shop",5};6 7export const viewport: Viewport = {8 themeColor: "#2f855a",9};10 11export default function RootLayout({ children }: LayoutProps<"/">) {12 return (13 <html lang="en">14 <body>{children}</body>15 </html>16 );17}GET /200
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1x-nextjs-stale-time300X-Powered-ByNext.jsCache-Controls-maxage=31536000Content-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 14 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
<script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
<meta name="theme-color" content="#2f855a">
<title>Tea shop</title>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<!--&-->
<main>
<h1>Tea shop</h1>
<p>Green tea, mint tea and lemon tea.</p>
</main>
<!--$-->
<!--/$-->
<!--/&-->
<script id="_R_">…</script>
<script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
</body>
</html>Lines 7 to 9 export it, with themeColor on line 8. The Network tab shows <meta name="theme-color" content="#2f855a">, right after the default viewport tag. Put themeColor in metadata instead, the way it worked before Next.js 14, and the recorded build prints a warning, and the page gets no theme-color tag at all:
app/layout.tsx1import type { Metadata } from "next";2 3export const metadata: Metadata = {4 title: "Tea shop",5 themeColor: "#2f855a",6};7 8export default function RootLayout({ children }: LayoutProps<"/">) {9 return (10 <html lang="en">11 <body>{children}</body>12 </html>13 );14}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...⚠ Unsupported metadata themeColor is configured in metadata export in /_not-found. Please move it to viewport export instead.Read more: https://nextjs.org/docs/app/api-reference/functions/generate-viewport⚠ Unsupported metadata themeColor is configured in metadata export in /. Please move it to viewport export instead.Read more: https://nextjs.org/docs/app/api-reference/functions/generate-viewport⚠ Unsupported metadata themeColor is configured in metadata export in /_not-found. Please move it to viewport export instead.Read more: https://nextjs.org/docs/app/api-reference/functions/generate-viewport⚠ Unsupported metadata themeColor is configured in metadata export in /. Please move it to viewport export instead.Read more: https://nextjs.org/docs/app/api-reference/functions/generate-viewport✓ Generating static pages (3/3) Finalizing page optimization ... Route (app)┌ ○ /└ ○ /_not-found ○ (Static) prerendered as static contentThe warning, Unsupported metadata themeColor is configured in metadata export in /. Please move it to viewport export instead., says what to do. A viewport can come from data too: generateViewport works like generateMetadata. Here each tea's page gets its own color from params:
app/teas/[id]/page.tsx1import type { Metadata, Viewport } from "next";2import { getTea, getTeas } from "../../teas";3 4export async function generateStaticParams() {5 const teas = await getTeas();6 return teas.map((tea) => ({ id: tea.id }));7}8 9export async function generateMetadata({ params }: PageProps<"/teas/[id]">): Promise<Metadata> {10 const { id } = await params;11 const tea = await getTea(id);12 return {13 title: tea?.name,14 description: `${tea?.name}, $${tea?.price} a box.`,15 };16}17 18export async function generateViewport({ params }: PageProps<"/teas/[id]">): Promise<Viewport> {19 const { id } = await params;20 const tea = await getTea(id);21 return { themeColor: tea?.color };22}23 24export default async function TeaPage({ params }: PageProps<"/teas/[id]">) {25 const { id } = await params;26 const tea = await getTea(id);27 return (28 <main>29 <h1>{tea?.name}</h1>30 <p>${tea?.price} a box.</p>31 </main>32 );33}GET /teas/mint200
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1x-nextjs-stale-time300X-Powered-ByNext.jsCache-Controls-maxage=31536000Content-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 15 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
<script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
<meta name="theme-color" content="#38a169">
<title>Mint tea | Tea shop</title>
<meta name="description" content="Mint tea, $3 a box.">
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<!--&-->
<!--&-->
<!--&-->
<main>
<h1>Mint tea</h1>
<p>$<!-- -->3<!-- --> a box.</p>
</main>
<!--$-->
<!--/$-->
<!--/&-->
<!--/&-->
<!--/&-->
<script id="_R_">…</script>
<script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
</body>
</html>Lines 18 to 22 read the id, load the tea and return its color as themeColor. The recorded /teas/mint has content="#38a169", and /teas/lemon has content="#d69e2e". One difference from metadata: the viewport can't stream, because it changes how the page is drawn from the start. A generateViewport that reads the request makes the whole page wait for it, and the docs list what Cache Components then asks of the layout.
Streaming metadata, and the bots that wait for it
The third version of the cart page reads a cookie in generateMetadata and in its cart line, so its metadata is made for each request. Since Next.js 15.2, such request-time metadata doesn't hold the page back for most visitors: Next.js sends the page's shell at once, and the tags later, when generateMetadata has finished. That's streaming metadata. Before 15.2, nothing was sent until the metadata was ready. It never applies to a page prerendered during the build, whose tags are already in its <head>, or to the bots described below. But some programs read only the first HTML and look for the tags in the <head>. The recorder asked for /cart twice, with the same cookie: once as Chrome, and once as Slackbot, the program that draws Slack's link previews. Each says who it is in its User-Agent header.
app/cart/page.tsx1import { Suspense } from "react";2import type { Metadata } from "next";3import { cookies } from "next/headers";4import { getTea } from "../teas";5 6export async function generateMetadata(): Promise<Metadata> {7 const id = (await cookies()).get("cart")?.value;8 const tea = id ? await getTea(id) : undefined;9 return { title: tea ? `Cart: ${tea.name}` : "Your cart" };10}11 12async function CartItem() {13 const id = (await cookies()).get("cart")?.value;14 const tea = id ? await getTea(id) : undefined;15 return <p>{tea ? `In your cart: ${tea.name}` : "Your cart is empty."}</p>;16}17 18export default function CartPage() {19 return (20 <main>21 <h1>Your cart</h1>22 <Suspense fallback={<p>Loading your cart…</p>}>23 <CartItem />24 </Suspense>25 </main>26 );27}GET /cart200
- Sent with the request
cookiecart=mintuser-agentMozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-stale-time300x-nextjs-prerender1x-nextjs-postponed1X-Powered-ByNext.jsCache-Controlprivate, no-cache, no-store, max-age=0, must-revalidateContent-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 15 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
<script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$?-->
<template id="B:0"></template>
<!--/$-->
</div>
<!--&-->
<!--&-->
<main>
<h1>Your cart</h1>
<!--$?-->
<template id="B:1"></template>
<p>Loading your cart…</p>
<!--/$-->
</main>
<!--$?-->
<template id="B:2"></template>
<!--/$-->
<!--/&-->
<!--/&-->
<script>…</script>
<script id="_R_">…</script>
<script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
<script>…</script>
<script>…</script>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
<title>Cart: Mint tea</title>
<meta name="description" content="Three teas from one small shop.">
<div hidden="" id="S:0"></div>
<script>…</script>
<div hidden="" id="S:1">
<p>In your cart: Mint tea</p>
</div>
<script>…</script>
<div hidden="" id="S:2"></div>
<script>…</script>
<script>…</script>
</body>
</html>Pick each response and compare the HTML in the Network tab:
- From Chrome: the
<head>has no<title>. The<body>starts withYour cartandLoading your cart…. Further down, after the shell, come<title>Cart: Mint tea</title>and the description, then the cart's line. The browser shows the title as soon as that part arrives. - From Slackbot: the
<head>has<title>Cart: Mint tea</title>and the description, and the body holdsIn your cart: Mint teastraight away, with no fallback. Next.js waited for everything and sent one complete page.
<title> arrives later, in the <body>. Slackbot, an HTML-limited bot, gets one complete page with the <title> in the <head>.Slackbot is on Next.js's list of HTML-limited bots: programs that read the HTML without running JavaScript, and may only look in the <head>. Others on it include Twitterbot, Bingbot, facebookexternalhit and Discordbot. The docs say Googlebot, which runs JavaScript and reads the whole page, reads streamed tags correctly. In real Chrome, the Steps tab shows the cart with no cookie, and the tab's title after loading: Your cart.
The list lives in htmlLimitedBots in next.config.ts. Setting it to a pattern that matches every User-Agent, /.*/, turns streaming metadata off for everyone:
next.config.ts1import type { NextConfig } from "next";2 3const nextConfig: NextConfig = {4 /* config options here */5 cacheComponents: true,6 partialPrefetching: true,7 htmlLimitedBots: /.*/,8};9 10export default nextConfig;GET /cart200
- Sent with the request
cookiecart=mintuser-agentMozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-EncodingX-Powered-ByNext.jsCache-Controlprivate, no-cache, no-store, max-age=0, must-revalidateContent-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 10 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
<script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
<title>Cart: Mint tea</title>
<meta name="description" content="Three teas from one small shop.">
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<!--&-->
<!--&-->
<main>
<h1>Your cart</h1>
<!--$-->
<p>In your cart: Mint tea</p>
<!--/$-->
</main>
<!--/&-->
<!--/&-->
<script id="_R_">…</script>
<script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
<script>…</script>
<script>…</script>
<script>…</script>
</body>
</html>Line 7 sets it. Now Chrome's response is like Slackbot's: the title in the <head>, and the whole cart at once. The docs warn that this makes responses slower, and that the default list fits most sites. A page prerendered during the build, like each tea's page, never streams its metadata: it was ready before anyone asked.
Which export does each page need?
Each row is one export: when to use it, and an example from the shop.
| Export | Use it when | In the tea shop |
|---|---|---|
export const metadata | The values are known when you write the code. | A title and description for the About page. |
export function generateMetadata, async when it waits | The values come from params, data, or the request. | A tea's name as the title of /teas/mint. |
export const viewport or generateViewport | The value is about how the browser draws the page: its width, or the color of its bar. | The shop's green as themeColor. |
Sort each piece of the shop's head into the export it belongs in:
- The About page's title,
About us - One description for the whole shop, in the root layout
- Each tea page's title, from the tea's name in the database
- The cart's title, from the visitor's cookie
- The green color of the phone browser's bar
- A different bar color for each tea's page
Sort each piece of the tea shop's head into a metadata object, generateMetadata, or the viewport.
Common mix-ups
- "A page's
openGraphadds to the layout's." No. It replaces the whole object. Copy the shared fields in with spread. - "Setting a page's
titleupdates its link preview." Not when a layout setsopenGraph.title: the page inherits that whole object, andog:titlestays the layout's. - "A template applies to the layout's own title." No. It applies to the segments below. The layout's
defaultstays as it is. - "
themeColorgoes inmetadata." Not since Next.js 14. It goes inviewport; inmetadata, it prints a warning and adds no tag. - "Streaming metadata hides the title from search engines." HTML-limited bots get it in the
<head>, and the docs say Googlebot reads streamed tags. Prerendered pages don't stream at all. - "A Client Component page can export
metadata." No. The build stops. Keep the page a Server Component, and move the interactive part into a Client Component.
Practice exercises
These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.
This is app/about/page.tsx in the shop with the title template. What's its <title>?
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "About us",
};
export default function AboutPage() {
return (
<main>
<h1>About us</h1>
<p>We sell three teas from one small shop.</p>
</main>
);
}
About us | Tea shop. The page is below the root layout, so its title fills in the layout's template. The recorded /about has exactly that <title>.
The build fails with this page. What do you remove?
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "About us",
};
export async function generateMetadata(): Promise<Metadata> {
return { title: "About the shop" };
}
export default function AboutPage() {
return (
<main>
<h1>About us</h1>
</main>
);
}
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "About us",
};
export default function AboutPage() {
return (
<main>
<h1>About us</h1>
<p>We sell three teas from one small shop.</p>
</main>
);
}
Delete one of them; here the function, since nothing depends on data. The recorded build stopped with: "metadata" and "generateMetadata" cannot be exported at the same time, please keep one of them.
What's the description of /teas/lemon?
import type { Metadata } from "next";
import { getTea, getTeas } from "../../teas";
export async function generateStaticParams() {
const teas = await getTeas();
return teas.map((tea) => ({ id: tea.id }));
}
export async function generateMetadata({ params }: PageProps<"/teas/[id]">): Promise<Metadata> {
const { id } = await params;
const tea = await getTea(id);
return {
title: tea?.name,
description: `${tea?.name}, $${tea?.price} a box.`,
};
}
export default async function TeaPage({ params }: PageProps<"/teas/[id]">) {
const { id } = await params;
const tea = await getTea(id);
return (
<main>
<h1>{tea?.name}</h1>
<p>${tea?.price} a box.</p>
</main>
);
}
Lemon tea, $3 a box. The recorded /teas/lemon has <meta name="description" content="Lemon tea, $3 a box.">.
The About page tries a template of its own. Under the shop's layout, what's its <title>?
import type { Metadata } from "next";
export const metadata: Metadata = {
title: { template: "%s | Our story", default: "About us" },
};
export default function AboutPage() {
return (
<main>
<h1>About us</h1>
<p>We sell three teas from one small shop.</p>
</main>
);
}
About us | Tea shop. The page's template has no segments to apply to, so it does nothing. Its default is the page's title, and the root layout's template wraps it, as the recording shows.
The gift boxes page lost og:site_name. Fix it without losing its own og:title. Which tag comes back?
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Gift boxes",
openGraph: {
title: "Gift boxes",
},
};
export default function GiftsPage() {
return (
<main>
<h1>Gift boxes</h1>
<p>Three teas in one box.</p>
</main>
);
}
import type { Metadata } from "next";
import { shopOpenGraph } from "../shared-metadata";
export const metadata: Metadata = {
title: "Gift boxes",
openGraph: {
...shopOpenGraph,
title: "Gift boxes",
},
};
export default function GiftsPage() {
return (
<main>
<h1>Gift boxes</h1>
<p>Three teas in one box.</p>
</main>
);
}
og:site_name comes back, and so does the shared og:description. The fields live in app/shared-metadata.ts, and line 7 spreads them into the page's own openGraph.
With Cache Components on, this page fails to build. It needs the cookie in its title. What does the page need so the build passes?
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { getTea } from "../teas";
export async function generateMetadata(): Promise<Metadata> {
const id = (await cookies()).get("cart")?.value;
const tea = id ? await getTea(id) : undefined;
return { title: tea ? `Cart: ${tea.name}` : "Your cart" };
}
export default function CartPage() {
return (
<main>
<h1>Your cart</h1>
<p>Check your tea, then pay.</p>
</main>
);
}
import { Suspense } from "react";
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { getTea } from "../teas";
export async function generateMetadata(): Promise<Metadata> {
const id = (await cookies()).get("cart")?.value;
const tea = id ? await getTea(id) : undefined;
return { title: tea ? `Cart: ${tea.name}` : "Your cart" };
}
async function CartItem() {
const id = (await cookies()).get("cart")?.value;
const tea = id ? await getTea(id) : undefined;
return <p>{tea ? `In your cart: ${tea.name}` : "Your cart is empty."}</p>;
}
export default function CartPage() {
return (
<main>
<h1>Your cart</h1>
<Suspense fallback={<p>Loading your cart…</p>}>
<CartItem />
</Suspense>
</main>
);
}
A part inside Suspense that does request-time work, like reading the request or calling await connection(); an empty Suspense isn't enough. Here CartItem reads the cookie inside the Suspense on lines 22 to 24. The recorded build passes with ◐ /cart, and the title streams in.
Check your understanding
For each question, start at the root layout, go down to the page, and replace each field a lower file sets.
Question 1 of 8This is
app/about/page.tsx. The root layout's title is{ template: "%s | Tea shop", default: "Tea shop" }. What's the page's<title>?Read the code, then predictTSXimport type { Metadata } from "next"; export const metadata: Metadata = { title: "About us", }; export default function AboutPage() { return ( <main> <h1>About us</h1> <p>We sell three teas from one small shop.</p> </main> ); }Choose an answer to see the explanation.
Question 2 of 8And this is
app/checkout/page.tsx, under the same layout. What's its<title>?Read the code, then predictTSXimport type { Metadata } from "next"; export const metadata: Metadata = { title: { absolute: "Checkout" }, }; export default function CheckoutPage() { return ( <main> <h1>Checkout</h1> <p>Pay for your teas.</p> </main> ); }Choose an answer to see the explanation.
Question 3 of 8The root layout sets
openGraphwithsiteName,titleanddescription. This page setsopenGraphtoo. Whichog:tags does the page get from the layout'sopenGraph?Read the code, then predictTSXimport type { Metadata } from "next"; export const metadata: Metadata = { title: "Gift boxes", openGraph: { title: "Gift boxes", }, }; export default function GiftsPage() { return ( <main> <h1>Gift boxes</h1> <p>Three teas in one box.</p> </main> ); }Choose an answer to see the explanation.
Question 4 of 8A page has
title: "Gift boxes"and noopenGraph. The layout'sopenGraph.titleisTea shop. What does a link preview show as the title?Choose an answer to see the explanation.
Question 5 of 8With Cache Components on,
generateMetadatareads a cookie, and the rest of the page reads nothing from the request. What happens?Choose an answer to see the explanation.
Question 6 of 8A browser asks for a page whose metadata streams. Where is the
<title>in the HTML that arrives?Choose an answer to see the explanation.
Question 7 of 8Why does Slackbot get the
<title>in the<head>?Choose an answer to see the explanation.
Question 8 of 8This page has a title template. What's the
<title>of/about, under the shop's layout?Read the code, then predictTSXimport type { Metadata } from "next"; export const metadata: Metadata = { title: { template: "%s | Our story", default: "About us" }, }; export default function AboutPage() { return ( <main> <h1>About us</h1> <p>We sell three teas from one small shop.</p> </main> ); }Choose an answer to see the explanation.
Key takeaways
- Export
metadatafrom a layout or a page, in a Server Component, and Next.js writes the<head>tags. It always adds the charset and viewport tags. generateMetadatagetsparamsand can load data; it's async when it waits for something. With Cache Components, reading the request in it needs request-time work insideSuspenseon the page, or another fix the error names.title.templatewraps the titles of the segments below;defaultis for pages without one;absoluteskips templates.- Metadata merges from the root layout down, one top-level field at a time. An object field like
openGraphis replaced whole. themeColorand the viewport go inviewportorgenerateViewport, which can't stream.- Since Next.js 15.2, request-time metadata streams into the
<body>for clientshtmlLimitedBotsdoesn't match. Matching bots get it in the<head>, and prerendered pages never stream it.
Remember the one-liner.
Describe the head as data; Next.js merges it from the root down and writes the tags.
These links point at release v16.4.3, the version every example on this page was built with. accumulateMetadata, at lines 480 to 592 of resolve-metadata.ts, walks from the root layout to the page. Each step is mergeMetadata, at lines 215 to 452 of metadata-resolution-primitives.ts, which sets each field it's given, whole. The list of HTML-limited bots is html-bots.ts.
Coming next: Open Graph images. It gives each page the picture link previews show, from an image file or from JSX.