Next.js · 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
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's own description, overriding the layout'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.