When the tags depend on data, export an async generateMetadata. It takes the page's props — params and searchParams, promises since Next.js 16 10,514 — plus parent, resolving to what the parent segments produced.
export async function generateMetadata(
{ params }: Props, parent: Promise<Metadata>,
): Promise<Metadata> {
const book = await getBook((await params).slug);
if (!book) return { title: "Book not found" };
return {
title: book.title,
alternates: { canonical: `/books/${book.slug}` },
openGraph: { type: "article", title: book.title, authors: [book.author],
publishedTime: book.published, images: (await parent).openGraph?.images ?? [] },
};
}<title>The Pragmatic Programmer | Bookshelf</title> <link rel="canonical" href="https://example.com/books/the-pragmatic-programmer"/> <meta property="article:published_time" content="2019-09-13"/> <meta property="article:author" content="Andrew Hunt"/>
The root layout's template wrapped the record's own title. Loading the record twice costs nothing: fetch is memoized across generateMetadata, generateStaticParams, layouts and the page within one request; for a database query, wrap the loader in React 7,897 's cache.
Awaiting data here would normally hold back the response, so Next.js streams metadata: the shell goes out at once and the resolved tags are appended to <body>, where React moves them into the head. Bots running JavaScript see the finished DOM; bots reading only raw HTML would not, so Next.js inspects the User-Agent and blocks for a known list. On a page whose generateMetadata waits 200 ms and whose body waits 1.5 s, an ordinary request put the Suspense fallback at byte 971 and <title> only at 7632; fetched as Twitterbot/1.0, <title> came at byte 750, inside <head>. Override that list with htmlLimitedBots; /.*/ disables streaming for all.