Skip to main content
Next.js · lesson 10 of 11
Next.js intermediate Lesson 10 of 11

The Metadata API

A layout's metadata export merges into every page below it with a title template; generateMetadata reads params; robots.ts/sitemap.ts generate real files.

By SkillByExample · Updated

nextjs metadata seo open-graph robots sitemap

A layout’s metadata export is the fallback every page below it inherits; generateMetadata on a dynamic route is a function instead of an object, because the title and description depend on which params came in. The project setup reuses the Docker pattern from the first lesson — the node:26.13 image, the nextjs-modules volume, MSYS_NO_PATHCONV=1 on Git Bash — see it for why each flag is there.

A Layout’s metadata Merges Up Through Nested Pages

Six files set up the project: package.json, next.config.ts, and tsconfig.json are unchanged from the first lesson; app/layout.tsx now sets a title template and a default description, app/page.tsx sets neither, and app/about/page.tsx sets its own.

{
  "name": "next-app",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "next": "16.4.3",
    "react": "19.3.0",
    "react-dom": "19.3.0"
  },
  "devDependencies": {
    "typescript": "5.9.3",
    "@types/node": "20.19.43",
    "@types/react": "19.3.0",
    "@types/react-dom": "19.3.0"
  }
}
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
};

export default nextConfig;
{
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "react-jsx",
    "incremental": true,
    "plugins": [{ "name": "next" }],
    "paths": { "@/*": ["./*"] }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
  "exclude": ["node_modules"]
}
// app/layout.tsx — a title template every page below it can fill in
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: {
    template: "%s | Site",
    default: "Site",
  },
  description: "The default site description, used when a page sets none of its own.",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}
// app/page.tsx — no metadata of its own; inherits the layout's title and description
export default function HomePage() {
  return <h1>Home</h1>;
}
// app/about/page.tsx — a plain string title, plus its own description
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "About",
  description: "The about page's own description, overriding the layout's default.",
};

export default function AboutPage() {
  return <h1>About</h1>;
}

Predict the <title> text for each route before running — does / get the layout’s bare "Site", or something built from the template with nothing to fill in?

$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npm install --no-audit --no-fund

added 28 packages in 37s
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 118ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 5.6s
  Running TypeScript ...
  Finished TypeScript in 3.8s ...
  Finalizing page optimization ...

Route (app)
┌ ○ /
├ ○ /_not-found
└ ○ /about


○  (Static)  prerendered as static content
$ docker run -d --name nextjs-metadata-1 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
43fa1cfffa1b52593e5ff207dc54cd49f433760a87877f7a174bcc43478a490e
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s http://localhost:3100/ | grep -oE '<title>[^<]*</title>|<meta name="description" content="[^"]*"/>'
<title>Site</title>
<meta name="description" content="The default site description, used when a page sets none of its own."/>
$ curl -s http://localhost:3100/about | grep -oE '<title>[^<]*</title>|<meta name="description" content="[^"]*"/>'
<title>About | Site</title>
<meta name="description" content="The about page&#x27;s own description, overriding the layout&#x27;s default."/>
$ docker rm -f nextjs-metadata-1
nextjs-metadata-1

/ renders <title>Site</title> — the layout’s bare default, since there’s no page title to slot into %s. /about renders <title>About | Site</title>: about/page.tsx exports a plain string, "About", and the layout’s template wraps it. The description tells the same story at the other field: / keeps the layout’s default sentence word for word, /about replaces it outright with its own.

generateMetadata Reads params Like Any Other Request API

A dynamic route can’t know its title at build time — it depends on which slug a request carries. generateMetadata is a function instead of a plain object for exactly that reason. This first version’s page component renders a fixed heading and never reads params at all — only generateMetadata does, and there’s no generateStaticParams yet. Dynamic Routes and generateStaticParams already showed a page component’s own read of params failing a build; predict whether generateMetadata’s read is exempt from that same rule just because the page component itself never touches params:

// app/posts/[slug]/page.tsx — only generateMetadata reads params; the page component does not
import type { Metadata } from "next";

type Params = { slug: string };

const posts: Record<string, string> = {
  "hello-world": "Hello World",
  "second-post": "Second Post",
};

export async function generateMetadata({
  params,
}: {
  params: Promise<Params>;
}): Promise<Metadata> {
  const { slug } = await params;
  const title = posts[slug] ?? "Unknown post";
  return {
    title,
    description: `Everything about ${title}.`,
  };
}

export default function PostPage() {
  return <h1>Post</h1>;
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 93ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 4.0s
  Running TypeScript ...
  Finished TypeScript in 3.9s ...
Route "/posts/[slug]": Next.js encountered uncached or runtime data in `generateMetadata()`.

This route's metadata is blocked, but the rest of its content can be prerendered.

Ways to fix this:
  - [static] Use a static metadata export instead of `generateMetadata()`
  - [cache] Cache the metadata with `"use cache"` in `generateMetadata()` (does not apply to `connection()`)
  - [dynamic] Render a marker component that calls `await connection()` inside `<Suspense>` on the page

Learn more: https://nextjs.org/docs/messages/blocking-prerender-metadata-runtime
Error occurred prerendering page "/posts/[slug]". Read more: https://nextjs.org/docs/messages/prerender-error
Export encountered an error on /posts/[slug]/page: /posts/[slug], exiting the build.
⨯ Next.js build worker exited with code: 1 and signal: null

Not exempt, and this is a dedicated error — Next.js encountered uncached or runtime data in \generateMetadata()`names the function directly, a different message from the generic per-page error [Dynamic Routes and generateStaticParams](/nextjs/dynamic-routes-and-generatestaticparams/) showed, even though the page component here never readsparamsat all.generateMetadata's own read of paramsis sufficient on its own to block the route — proof that it's a request-API consumer in its own right, not exempt just because it isn't the page component itself. A real post page would also want to render the resolved title, so bringparamsback into the page component and addgenerateStaticParams` to give both functions a known set of slugs:

// app/posts/[slug]/page.tsx — generateStaticParams supplies the known slugs
import type { Metadata } from "next";

type Params = { slug: string };

const posts: Record<string, string> = {
  "hello-world": "Hello World",
  "second-post": "Second Post",
};

export async function generateMetadata({
  params,
}: {
  params: Promise<Params>;
}): Promise<Metadata> {
  const { slug } = await params;
  const title = posts[slug] ?? "Unknown post";
  return {
    title,
    description: `Everything about ${title}.`,
  };
}

export async function generateStaticParams() {
  return Object.keys(posts).map((slug) => ({ slug }));
}

export default async function PostPage({ params }: { params: Promise<Params> }) {
  const { slug } = await params;
  const title = posts[slug] ?? "Unknown post";
  return <h1>Post: {title}</h1>;
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 120ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 2.8s
  Running TypeScript ...
  Finished TypeScript in 2.9s ...
  Finalizing page optimization ...

Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /about
└   /posts/[slug]
  ├ ◐ /posts/[slug]
  ├ ○ /posts/hello-world
  └ ○ /posts/second-post


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
$ docker run -d --name nextjs-metadata-2 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
4dde3a52a8ff1f92dc85cc7425a46cfaf321c45a8c5d516b815f03ad398e06ad
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s http://localhost:3100/posts/hello-world | grep -oE '<title>[^<]*</title>|<meta name="description" content="[^"]*"/>'
<title>Hello World | Site</title>
<meta name="description" content="Everything about Hello World."/>
$ docker rm -f nextjs-metadata-2
nextjs-metadata-2

The build succeeds now, and /posts/hello-world renders <title>Hello World | Site</title> — the same layout template from the previous section wraps generateMetadata’s returned title exactly like it wrapped about/page.tsx’s plain string. A function and a plain object feed the same template the same way.

Open Graph and Twitter Fields Produce Their Own Meta Tags

Extend the same generateMetadata function with openGraph and twitter fields — no new file, no new route:

// app/posts/[slug]/page.tsx — openGraph and twitter fields added, each with its own description
import type { Metadata } from "next";

type Params = { slug: string };

const posts: Record<string, string> = {
  "hello-world": "Hello World",
  "second-post": "Second Post",
};

export async function generateMetadata({
  params,
}: {
  params: Promise<Params>;
}): Promise<Metadata> {
  const { slug } = await params;
  const title = posts[slug] ?? "Unknown post";
  return {
    title,
    description: `Everything about ${title}.`,
    openGraph: {
      title,
      description: `${title}, as seen on the social card.`,
      type: "article",
    },
    twitter: {
      card: "summary_large_image",
      title,
    },
  };
}

export async function generateStaticParams() {
  return Object.keys(posts).map((slug) => ({ slug }));
}

export default async function PostPage({ params }: { params: Promise<Params> }) {
  const { slug } = await params;
  const title = posts[slug] ?? "Unknown post";
  return <h1>Post: {title}</h1>;
}

The top-level description and openGraph.description are now deliberately different sentences, so the result can actually show which one wins. twitter sets only card and title, not description — predict whether the rendered twitter:description tag is simply missing, filled in from the top-level description, or filled in from openGraph.description:

$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 112ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 4.0s
  Running TypeScript ...
  Finished TypeScript in 2.8s ...
  Finalizing page optimization ...

Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /about
└   /posts/[slug]
  ├ ◐ /posts/[slug]
  ├ ○ /posts/hello-world
  └ ○ /posts/second-post


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
$ docker run -d --name nextjs-metadata-3 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
b8bf27c50bbeb79dcd30349215b3c04bdca870ebca2469a061d4de75eef6b86c
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s http://localhost:3100/posts/hello-world | grep -oE '<meta name="description" content="[^"]*"/>|<meta property="og:[a-z]+" content="[^"]*"/>|<meta name="twitter:[a-z]+" content="[^"]*"/>'
<meta name="description" content="Everything about Hello World."/>
<meta property="og:title" content="Hello World"/>
<meta property="og:description" content="Hello World, as seen on the social card."/>
<meta property="og:type" content="article"/>
<meta name="twitter:card" content="summary_large_image"/>
<meta name="twitter:title" content="Hello World"/>
<meta name="twitter:description" content="Hello World, as seen on the social card."/>
$ OG_DESC=$(curl -s http://localhost:3100/posts/hello-world | grep -oE '<meta property="og:description" content="[^"]*"' | sed -E 's/.*content="//'); TW_DESC=$(curl -s http://localhost:3100/posts/hello-world | grep -oE '<meta name="twitter:description" content="[^"]*"' | sed -E 's/.*content="//'); TOP_DESC=$(curl -s http://localhost:3100/posts/hello-world | grep -oE '<meta name="description" content="[^"]*"' | sed -E 's/.*content="//'); [ "$TW_DESC" = "$OG_DESC" ] && echo 'twitter:description MATCHES openGraph.description'; [ "$TW_DESC" != "$TOP_DESC" ] && echo 'twitter:description DIFFERS FROM the top-level description'
twitter:description MATCHES openGraph.description
twitter:description DIFFERS FROM the top-level description
$ docker rm -f nextjs-metadata-3
nextjs-metadata-3

Each openGraph field becomes its own <meta property="og:..."> tag, each twitter field its own <meta name="twitter:..."> tag — og:type carries the literal "article" string passed in. twitter:description isn’t missing, and it isn’t the top-level description either: Next.js fills it in from openGraph.description first, falling back to the top-level description only when openGraph itself sets none. The assertion above confirms both halves of that at once — the Twitter card’s text matches the Open Graph sentence, not the plain description sentence.

File-Based Metadata: robots.ts and sitemap.ts

app/robots.ts and app/sitemap.ts are file conventions, not components — each exports a default function that returns a plain object, and Next.js serves the result as a real robots.txt or sitemap.xml file:

import type { MetadataRoute } from "next";

export default function robots(): MetadataRoute.Robots {
  return {
    rules: {
      userAgent: "*",
      allow: "/",
      disallow: "/secret",
    },
    sitemap: "https://example.com/sitemap.xml",
  };
}
import type { MetadataRoute } from "next";

export default function sitemap(): MetadataRoute.Sitemap {
  return [
    { url: "https://example.com/", lastModified: "2026-01-01" },
    { url: "https://example.com/about", lastModified: "2026-01-01" },
  ];
}

Predict whether these two files get their own rows in the build’s route table, the way page.tsx files do:

$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 108ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 6.4s
  Running TypeScript ...
  Finished TypeScript in 4.1s ...
  Finalizing page optimization ...

Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /about
├   /posts/[slug]
│ ├ ◐ /posts/[slug]
│ ├ ○ /posts/hello-world
│ └ ○ /posts/second-post
├ ○ /robots.txt
└ ○ /sitemap.xml


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
$ docker run -d --name nextjs-metadata-4 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
fe0acbf01e53dc47f5f656927619f51dac0aaad5e9db0826c0434f5a88d26f57
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s http://localhost:3100/robots.txt
User-Agent: *
Allow: /
Disallow: /secret

Sitemap: https://example.com/sitemap.xml
$ curl -s http://localhost:3100/sitemap.xml
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://example.com/</loc>
<lastmod>2026-01-01</lastmod>
</url>
<url>
<loc>https://example.com/about</loc>
<lastmod>2026-01-01</lastmod>
</url>
</urlset>
$ docker rm -f nextjs-metadata-4
nextjs-metadata-4

○ /robots.txt and ○ /sitemap.xml land in the route table as ordinary static rows, right next to /about and /posts/[slug]’s own rows — nothing marks them as a special category. curl against each path returns exactly what each function returned, shaped into the real file format: a plain-text robots.txt with User-Agent/Allow/Disallow/Sitemap lines, and an XML sitemap.xml with one <url> element per array entry.

A Real Failure: metadata From a Client Component

Next.js resolves metadata entirely on the server, before any page renders — add "use client" to the top of about/page.tsx, keeping the same metadata export, and predict whether a working export under a Client Component still builds:

// app/about/page.tsx — "use client" added, metadata export left in place
"use client";

import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "About",
  description: "The about page's own description, overriding the layout's default.",
};

export default function AboutPage() {
  return <h1>About</h1>;
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | sed 's/\x1b\[[0-9;]*m//g'
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 83ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...

> Build error occurred
Error: Turbopack build failed with 1 error:
./app/about/page.tsx:6:14
Error: 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: "About",
  8 |   description: "The about page's own description, overriding the layout's default.",
  9 | };

Ecmascript file had an error

Import traces:
  Client Component Browser:
    ./app/about/page.tsx [Client Component Browser]
    ./app/about/page.tsx [Server Component]

  Client Component SSR:
    ./app/about/page.tsx [Client Component SSR]
    ./app/about/page.tsx [Server Component]


    at ignore-listed frames

The build fails outright: "You are attempting to export \"metadata\" from a component marked with \"use client\", which is disallowed." TypeScript never objects — metadata is a perfectly valid export either way — but Next.js’s own build step checks for this specific combination and names the fix directly: keep the page as a Server Component and move the Client Component logic into a separate, imported file. Restore the Server Component version to get back to a clean build:

// app/about/page.tsx — "use client" removed, back to a Server Component
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "About",
  description: "The about page's own description, overriding the layout's default.",
};

export default function AboutPage() {
  return <h1>About</h1>;
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 81ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 4.2s
  Running TypeScript ...
  Finished TypeScript in 3.8s ...
  Finalizing page optimization ...

Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /about
├   /posts/[slug]
│ ├ ◐ /posts/[slug]
│ ├ ○ /posts/hello-world
│ └ ○ /posts/second-post
├ ○ /robots.txt
└ ○ /sitemap.xml


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content

Practice

Each task below builds on the files the previous ones (and the main lesson above) left in place — do them in order.

1. Add app/contact/page.tsx with a default export and no metadata export of its own. Confirm it inherits both the layout’s default title and its default description.

Solution
// app/contact/page.tsx — no metadata export; inherits the layout's defaults
export default function ContactPage() {
  return <h1>Contact</h1>;
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 108ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 5.0s
  Running TypeScript ...
  Finished TypeScript in 3.1s ...
  Finalizing page optimization ...

Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /about
├ ○ /contact
├   /posts/[slug]
│ ├ ◐ /posts/[slug]
│ ├ ○ /posts/hello-world
│ └ ○ /posts/second-post
├ ○ /robots.txt
└ ○ /sitemap.xml


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
$ docker run -d --name nextjs-metadata-5 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
d71adc2280bda71bd038b7f4b2da0a4d332ad2d39fa7ebe33095240eb623c40d
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s http://localhost:3100/contact | grep -oE '<title>[^<]*</title>|<meta name="description" content="[^"]*"/>'
<title>Site</title>
<meta name="description" content="The default site description, used when a page sets none of its own."/>
$ docker rm -f nextjs-metadata-5
nextjs-metadata-5

/contact renders <title>Site</title> and the layout’s exact default description sentence — identical to the home page’s result in the first section, because neither route exports any metadata of its own.

2. Request /posts/not-a-real-post, a slug generateStaticParams never returned. Dynamic Routes and generateStaticParams showed this slug still renders on demand rather than 404ing — confirm whether generateMetadata runs for it too, and what title it produces.

Solution

No file changes — this reuses app/posts/[slug]/page.tsx exactly as the main lesson left it.

$ docker run -d --name nextjs-metadata-6 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
9da185627c991cd49f6fc8e1c615e1e7b1dd6713b08b2d3f7e0d6786ff06082e
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s http://localhost:3100/posts/not-a-real-post | grep -oE '<title>[^<]*</title>|<meta name="description" content="[^"]*"/>'
<title>Unknown post | Site</title>
<meta name="description" content="Everything about Unknown post."/>
$ docker rm -f nextjs-metadata-6
nextjs-metadata-6

generateMetadata runs for this slug exactly like it does for the two known ones — it has no special awareness that generateStaticParams never listed it, it just receives whatever params a real request carries. title falls back to the function’s own "Unknown post" default, rendered as <title>Unknown post | Site</title>, the same template from every earlier section.

3. Add an alternates.canonical field to about/page.tsx’s metadata. Confirm it renders as a <link rel="canonical"> tag, a shape none of this lesson’s earlier fields produced.

Solution
// app/about/page.tsx — alternates.canonical added
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "About",
  description: "The about page's own description, overriding the layout's default.",
  alternates: {
    canonical: "https://example.com/about",
  },
};

export default function AboutPage() {
  return <h1>About</h1>;
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 116ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 4.6s
  Running TypeScript ...
  Finished TypeScript in 3.5s ...
  Finalizing page optimization ...

Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /about
├ ○ /contact
├   /posts/[slug]
│ ├ ◐ /posts/[slug]
│ ├ ○ /posts/hello-world
│ └ ○ /posts/second-post
├ ○ /robots.txt
└ ○ /sitemap.xml


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
$ docker run -d --name nextjs-metadata-7 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
92498896d102bc6aceca64676828cae07f8fa97a233ba3737c8f283c97b59680
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s http://localhost:3100/about | grep -oE '<link rel="canonical" href="[^"]*"/>'
<link rel="canonical" href="https://example.com/about"/>
$ docker rm -f nextjs-metadata-7
nextjs-metadata-7

<link rel="canonical" href="https://example.com/about"/> shows up in <head>, a tag none of this lesson’s title/description/openGraph/twitter fields produced — alternates is its own metadata field, rendered as a <link> element rather than a <meta> tag.

4. Add a generateMetadata function to about/page.tsx, leaving its existing metadata export in place. Confirm the real build error this produces, distinct from the Client Component error earlier in this lesson, then remove generateMetadata again and remove every container and volume this lesson created.

Solution
// app/about/page.tsx — generateMetadata added alongside the existing metadata export
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "About",
  description: "The about page's own description, overriding the layout's default.",
  alternates: {
    canonical: "https://example.com/about",
  },
};

export async function generateMetadata(): Promise<Metadata> {
  return { title: "About (dynamic)" };
}

export default function AboutPage() {
  return <h1>About</h1>;
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | sed 's/\x1b\[[0-9;]*m//g'
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 83ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...

> Build error occurred
Error: Turbopack build failed with 1 error:
./app/about/page.tsx:12:23
Error: "metadata" and "generateMetadata" cannot be exported at the same time, please keep one of them. Read more: https://nextjs.org/docs/app/api-reference/file-conventions/metadata
  10 | };
  11 |
> 12 | export async function generateMetadata(): Promise<Metadata> {
     |                       ^^^^^^^^^^^^^^^^
  13 |   return { title: "About (dynamic)" };
  14 | }
  15 |

Ecmascript file had an error


    at ignore-listed frames

"metadata" and "generateMetadata" cannot be exported at the same time, please keep one of them. — a dedicated error, not the generic Client Component one, because this is a different mistake: both exports are individually valid, but Next.js refuses to decide which one wins. Remove generateMetadata again, confirm a clean build, and tear down everything this lesson started:

// app/about/page.tsx — back to metadata only
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "About",
  description: "The about page's own description, overriding the layout's default.",
  alternates: {
    canonical: "https://example.com/about",
  },
};

export default function AboutPage() {
  return <h1>About</h1>;
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 141ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 3.1s
  Running TypeScript ...
  Finished TypeScript in 2.2s ...
  Finalizing page optimization ...

Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /about
├ ○ /contact
├   /posts/[slug]
│ ├ ◐ /posts/[slug]
│ ├ ○ /posts/hello-world
│ └ ○ /posts/second-post
├ ○ /robots.txt
└ ○ /sitemap.xml


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
$ docker rm -f nextjs-metadata-1 nextjs-metadata-2 nextjs-metadata-3 nextjs-metadata-4 nextjs-metadata-5 nextjs-metadata-6 nextjs-metadata-7
$ docker volume rm -f nextjs-modules
nextjs-modules

The last two commands remove every container this lesson started (most already removed by earlier sections, which is harmless) and the shared node_modules volume.

Frequently Asked Questions

How does a title template in a layout's metadata combine with a page's own title?
A layout's metadata can set title to an object like { template: "%s | Site", default: "Site" }. A page with no title export of its own gets the plain default string. A page that exports its own title as a plain string (not an object) has that string substituted into the %s placeholder — this lesson's about page sets title: "About" and the rendered <title> reads "About | Site", built from the layout's template plus the page's own title.
Does generateMetadata on a dynamic route need generateStaticParams the same way the page component does?
Yes, but the failure looks different. Reading params inside generateMetadata with no generateStaticParams raises a dedicated error naming the function directly — Route "/posts/[slug]": Next.js encountered uncached or runtime data in generateMetadata() — distinct from the generic per-page error a page component's own unguarded read of params produces. This happens even when the page component itself never reads params at all, which confirms generateMetadata is its own request-API consumer and needs generateStaticParams in its own right, not just because the page component does.
Why does exporting metadata from a Client Component fail the build, but exporting it from a Server Component works?
Next.js resolves metadata entirely on the server, before the page component renders, so it can write the <head> tags into the initial HTML. A Client Component's exports are also bundled for the browser, and metadata has no meaning there — the real build error names this directly: "metadata must be resolved on the server before the page component is rendered." Moving the "use client" directive to a child component, rather than the page file, fixes it without losing the metadata export.
Do app/robots.ts and app/sitemap.ts show up as routes in a Next.js build's route table?
Yes. This lesson's own build output lists them as ordinary static routes, ○ /robots.txt and ○ /sitemap.xml, right alongside page routes — Next.js runs each file's default export function at build time and serves the returned object as the real text/XML file at that exact path.
Can a route export both metadata and generateMetadata from the same file?
No — Next.js rejects it at build time with a dedicated error, "metadata and generateMetadata cannot be exported at the same time, please keep one of them," distinct from the Client Component error. A route needs exactly one of the two: metadata for a value known at build time, generateMetadata for a value that depends on params or other request data.