React
Dynamic metadata in the App Router
generateMetadata, params as a Promise, metadataBase and titles without duplicates: the metadata crawlers read in the first HTML.
Key points
- `generateMetadata` takes the same `params` as the page and returns a `Metadata` object.
- `metadataBase` in the root layout turns relative paths into absolute URLs.
- A child title does not repeat the site name: the layout `template` adds it.
Dynamic metadata is written with generateMetadata, an async function that receives the same params as the page and returns a Metadata object. Crawlers see the tags in the first HTML response, not after hydration.
generateMetadata, the minimum shape
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
export async function generateMetadata(
{ params }: { params: Promise },
): Promise {
const { slug } = await params;
const post = await getPost(slug);
if (!post) return { title: "Article not found" };
return {
title: post.title,
description: post.summary,
alternates: { canonical: `/blog/${slug}` },
openGraph: {
title: post.title,
description: post.summary,
images: [`/og/${slug}.png`],
},
};
}
The params value is a Promise: await it even when it looks ready. The fetch inside this function and the one inside the page are deduplicated in the same render, so the line costs no second request.
To check what actually ships, do not trust the browser preview: read the HTML of the first response.
curl -s http://localhost:3000/blog/hello | grep -E "|og:title"
Static and dynamic in the same file
With generateStaticParams, Next.js prerenders one page per slug at build time. generateMetadata runs then, so title and description land in the static HTML. A new article appears after the build or after a revalidation: that is the price of speed.
export async function generateStaticParams() {
const posts = await listPosts();
return posts.map((post) => ({ slug: post.slug }));
}
metadataBase: absolute URLs
A relative path in openGraph.images is resolved against metadataBase. Without that line Next.js falls back to the request host and warns during the build: the same page then produces different URLs depending on who visits it. Set the base once, in the layout.
// app/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
metadataBase: new URL("https://ui.eneawork.it"),
title: { default: "EneUI", template: "%s — EneUI" },
openGraph: { type: "article", siteName: "EneUI" },
};
Titles that do not repeat
The template in the layout appends the site name to every child title. A child page writes only its own title: add the suffix there too and you get Article, EneUI, EneUI. The default covers the home page, which has no parent to inherit from.
Open Graph generated, not drawn by hand
Every article on this site has two covers, one per language, produced by a script that starts from the title and the article data. The same idea ships as one opengraph-image.tsx file per route: a function returning ImageResponse. The gain is not visual: nobody forgets to update the artwork when the title changes.
Where catalogue components fit
An article list, an icon grid and a card all need composed markup: the blog blocks cover the first case, the icon grid the second and the shadcn primitives the third. Metadata stays in the route: a component cannot change it.
Mistakes you see in production
- Adding the site name to a child page title: the template already does it.
- Returning
nullfromgenerateMetadatainstead of an object with a fallback title. - Forgetting
await params: the Promise object ends up in the URL and the title turns unreadable. - Putting
metadataBaseon a page: it belongs to the layout, not to the children.
One limitation to name
Metadata reflects the state at render time. On a static route, a title changed in the database does not appear until the page is regenerated: no dynamic function gets around the build. The second limitation belongs to clients: anything that does not run JavaScript reads only the initial HTML, so what you generate there is all it sees.
Where to keep the function relative to the page is covered in structuring an App Router project. The structured data that goes with metadata is in how to add schema markup; Next.js vs Vite for a marketing site explains when static metadata is enough.
Frequently asked questions
Can I use generateMetadata in a client component?
No. The function runs on the server: a file with use client cannot export it, and the tags must exist before hydration.
Does generateMetadata cost a second data request?
No, when the same fetch uses the same arguments: React deduplicates it in the render and the page gets the result already in memory.
Where does metadataBase go?
In the root layout, once. It resolves relative paths from openGraph, twitter and canonical into absolute URLs.
Why does the title show the site name twice?
Because the layout template appends it and the page already wrote it. A child page should write only its own title.
Does metadata update without a rebuild?
Only if the route is dynamic or revalidation is configured. On a static route the title stays the one from the build.