Titles That Know the Record: The generateMetadata Contract
Pricing — Acme works as a static string. /blog/why-postgres-wins cannot: the title must say "Why Postgres Wins", and that string exists only in the database row the URL points at. The static export's boundary — build-known values — is exactly what generateMetadata crosses: an async function exported from a page or layout that receives the route's params and returns a Metadata object at render time.
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getPost } from "@/lib/posts";
export async function generateMetadata(props: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await props.params; // params is a Promise in Next 15
const post = await getPost(slug);
if (!post) notFound();
return {
title: post.title,
description: post.excerpt,
alternates: { canonical: `/blog/${post.slug}` },
};
}
The props mirror the page's own: params (a Promise in the current release line — await it, as the page does) and searchParams, with the same segment-keyed shape. The return type is the same Metadata object from The Metadata API — everything typed there applies here; the only difference is that the values can now be computed from a fetched record. Merge semantics are unchanged: what this function returns merges up the layout tree exactly like a static export, so a root layout still supplies the brand template and metadataBase while the function supplies the record-specific fields.
One rule is enforced at build time, and it trips everyone once: a file may export either metadata or generateMetadata, never both. Ship both and the build fails with a message naming the file and the conflicting exports. The design reason is inheritance, not pedantry: the static object is the file's metadata; the function is the file's metadata. To keep a shared description at the layout level and vary only the title per record, you do not combine exports — you let the layout's static metadata provide the defaults and have each record's function return only what it knows.
Timing matters for reasoning about cost. generateMetadata runs during the same request render as the page — on the server, per request for dynamic routes, at build for prerendered paths. It is not a middleware, not a hook, not something the browser ever executes. Two consequences follow. First, it can use everything server code can: databases, cookies(), secrets. Second, on a prerendered route with generateStaticParams, the function runs once per path at build, so its fetches join the build's data budget — the fan-out math of Static Generation with generateStaticParams applies to metadata as much as to content.
The Pages Router had a weaker version — getServerSideProps could set res.setHeader("og:...", ...), and getStaticProps contexts received nothing useful for titles — which is why long-form record titles were an enduring pain before the App Router. If you are maintaining a codebase where every blog title is set through a client useEffect and next/head, this function is the migration target: same data, server-rendered, statically analyzable, and it participates in caching instead of fighting it.
One Record, One Query: Deduplicating the Metadata Fetch
The obvious implementation of record-driven metadata fetches the record twice: once inside generateMetadata for the title, once in the page for the content. Two queries per request for the same row — and on a prerendered route, twice per path per build. The fix is a shared, memoized accessor:
// lib/posts.ts
import { cache } from "react";
export const getPost = cache(async (slug: string) => {
return db.post.findUnique({ where: { slug } });
});
// app/blog/[slug]/page.tsx
export async function generateMetadata(props: { params: Promise<{ slug: string }> }) {
const { slug } = await props.params;
const post = await getPost(slug); // first call: executes the query
if (!post) notFound();
return { title: post.title, description: post.excerpt };
}
export default async function PostPage(props: { params: Promise<{ slug: string }> }) {
const { slug } = await props.params;
const post = await getPost(slug); // same request: memoized — no second query
if (!post) notFound();
return <Article post={post} />;
}
React cache() memoizes per render pass, and Next.js runs generateMetadata and the page within the same pass — the two calls deduplicate into one database round trip. This is the same deduplication mechanism Server Components use for shared fetches, and it is the pattern that makes record-driven metadata affordable: the metadata no longer doubles your data-layer load. A fetch with identical URL and options dedupes the same way within one render; the explicit cache() wrapper exists precisely for the non-fetch data access (Prisma, Drizzle, filesystem reads) that the automatic dedup cannot see.
Error and empty-state handling inside the function has its own semantics, and getting them right is most of the battle with real content:
| Situation | Do | Effect |
|---|---|---|
| Record does not exist | notFound() |
same 404 as the page — one code path for missing records |
| Record exists but is a draft | return robots: { index: false } |
page renders, engines skip it |
| Data source throws | let it propagate | nearest error.tsx boundary handles it |
| Title is empty in the data | fall back: `post.title |
Calling notFound() in generateMetadata is worth internalizing: without it, a bad slug renders a page that throws a moment later — two failure paths, one of which produces a title full of undefined in the interim. With it, the missing-record decision is made once, before either the head or the body renders.
The performance envelope to state in interviews: metadata generation delays the response — the head is part of the first byte, so a slow metadata fetch delays everything, even if the page's own content would have been fast. Keep the metadata path lean (one indexed lookup, no N+1 enrichment), and where the data genuinely cannot be fast, consider deferring the heavy part: the openGraph.images generation of the next topic can point at a URL that resolves independently, while title and description — the fields crawlers act on — come from the cheap query. The instinct to treat metadata as a lightweight echo of data you already loaded is the correct one; cache() is how you make it literally true.
Open Graph Fields: Making the Shared Link Render a Card
The title and description serve search results. A different consumer — Slack, Discord, iMessage, Twitter/X, LinkedIn — reads the og: and twitter: tags to render a link preview card, and its fetcher is a plain HTTP client with no JavaScript. generateMetadata is where those tags get their per-record values:
export async function generateMetadata(props: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await props.params;
const post = await getPost(slug);
if (!post) notFound();
return {
title: post.title,
description: post.excerpt,
openGraph: {
type: "article",
url: `/blog/${post.slug}`,
title: post.title, // falls back to `title` if omitted
description: post.excerpt,
publishedTime: post.publishedAt.toISOString(),
images: [{ url: `/og/${post.slug}`, width: 1200, height: 630, alt: post.title }],
},
twitter: {
card: "summary_large_image",
title: post.title,
description: post.excerpt,
images: [`/og/${post.slug}`],
},
};
}
Read the field semantics carefully, because the two namespaces are not redundant. openGraph speaks the de-facto standard every platform half-supports; twitter overrides it on Twitter/X — omit the twitter fields and the platform falls back to the og: values, but card is the one field with no og: equivalent: summary (small square thumbnail) versus summary_large_image (the wide hero image most content sites want). Declaring both namespaces is not duplication; it is naming the fallback explicitly and overriding only where the platforms differ.
type changes which optional fields are legal: article unlocks publishedTime, authors, section; website is the safe default; product carries price fields — though a product's rich pricing preview comes from JSON-LD Structured Data, not from Open Graph alone. url sets the canonical link a card points back to — on a page with query-string variants, explicitly setting it stops ?ref=twitter from forking your previews into distinct cards.
The absolute-URL rule from the static topic applies with full force here: every image URL a card fetches must resolve publicly, and relative paths are resolved against metadataBase — without which the emitted URL points at http://localhost and every shared link renders with no image. On a prerendered route this whole function runs at build, so the card data is frozen with the page; on a dynamic route it runs per request. Either way the fetcher sees static HTML — the tags were rendered server-side before anything left the node process.
What ships next is the image itself: a per-record card can no longer be one file in /public forever — og/why-postgres.png does not exist until someone generates it. Next.js ships an image-generation route for exactly that (ImageResponse), and its constraints — which CSS subset satori accepts, which fonts load, how the generated image is cached — are the material of Open Graph and Twitter Cards. The metadata you just wrote is what will point at it.
Two field mechanics round out the set. The images array accepts multiple entries, but most scrapers take only the first — additional entries are fallbacks for platforms that reject the lead image's format or size, not a slideshow; put the 1200 × 630 card first and variants after it. And openGraph.locale ("en_US") plus locale: alternate entries declare the card's language variants, which matters on i18n sites where one URL serves several languages through negotiated or path-based routing — the locale tag tells the platform which card language matches the reader. Both mechanics share the discipline of everything in this function: the values come from the record, the URLs are absolute, and the head ships them with the response.
Quiz
1. A page exports both `metadata` and `generateMetadata`. What happens?
2. generateMetadata and the page both need the same record. What is the correct pattern?
3. A blog route's generateMetadata awaits params, queries the DB for the title, and the page renders the article. Where does the metadata fetch's latency land?
4. A record's slug does not exist. What should generateMetadata return?
Flashcards
Question
What is generateMetadata's contract?
Click to reveal answer
Answer
An async function exported from a page or layout receiving the route's props (params as a Promise in Next 15, plus searchParams) and returning a typed Metadata object at render time — build-known values stay static; per-record values come from here. It may not coexist with a static metadata export in the same file.
Question
How do you avoid fetching the same record twice — once for metadata, once for the page?
Click to reveal answer
Answer
Wrap the query in React cache(): const getPost = cache(async (slug) => db.post.findUnique(...)). generateMetadata and the page run in one render pass, so the memoized call dedupes to a single round trip. Identical fetch() calls dedupe automatically; non-fetch data access needs the explicit wrapper.
Question
How should missing, draft, and throwing records be handled inside generateMetadata?
Click to reveal answer
Answer
Missing: notFound() — one 404 path shared with the page. Draft: return robots: { index: false } so the page renders but is not indexed. Throw: let it propagate to the nearest error.tsx. Never fall back to an empty <title>.
Question
openGraph vs twitter fields — what does each control, and which field has no og: equivalent?
Click to reveal answer
Answer
openGraph is the broadly-supported preview standard; twitter:* overrides it on Twitter/X, with card ('summary' vs 'summary_large_image') having no og: equivalent — it selects small-thumbnail vs wide-hero rendering. Omitting twitter fields falls back to the og: values.
Question
When does generateMetadata execute, and what does that imply for prerendered routes and response latency?
Click to reveal answer
Answer
During the server render: at build for prerendered paths (joining the build's data budget) and per request for dynamic routes. Because its output ships in the initial head, its fetch delays the entire response — keep it to one indexed, cache()-shared query and never put N+1 enrichment on that path.
Revision Notes
Key Takeaways
- 1. generateMetadata receives route params and returns typed Metadata at render time — per-record titles without leaving the server
- 2. A file exports either metadata or generateMetadata, never both; layout-level static metadata supplies the defaults the function does not set
- 3. React cache() makes the record query run once for metadata and page together in the same render pass
- 4. notFound() inside generateMetadata unifies the missing-record 404 path; drafts get robots: { index: false }
- 5. openGraph plus twitter fields build the social card; card: summary_large_image and absolute image URLs (via metadataBase) decide whether it renders wide or not at all
Interview Tips
- • "How do you set a per-record title in the App Router?" — generateMetadata, awaiting the same params the page awaits, returning typed Metadata; add the XOR rule with static exports and the layout-inheritance design
- • "Doesn't generateMetadata double the queries?" — only if you let it: cache()-wrap the accessor so metadata and page share one round trip; this answer separates candidates who have read docs from candidates who have shipped
- • "A slow metadata fetch delays TTFB — why?" — the head is part of the first byte; the metadata path must be the cheapest query on the page, and heavy assets belong at URLs resolved independently
- • "How do you make a shared link preview look right?" — og:title/description/images with absolute URLs (metadataBase), twitter.card as the override layer; note that the fetcher is JS-less and reads the server-rendered head
Cheat Sheet
Dynamic generateMetadata
The shape:
export async function generateMetadata(props: {
params: Promise<{ slug: string }>; // await it — Promise in Next 15
}): Promise<Metadata> {
const { slug } = await props.params;
const post = await getPost(slug); // cache()-shared with the page
if (!post) notFound();
return { title: post.title, description: post.excerpt,
openGraph: { type: "article", images: [{ url: `/og/${slug}`, width: 1200, height: 630 }] } };
}
One-or-the-other rule: metadata XOR generateMetadata per file (build error otherwise) — defaults live in a parent layout; the merge rules apply to the function's return value.
Dedupe: const getPost = cache(async (slug) => ...) — metadata + page share one render pass → one query. Without it, every request runs the record query twice.
Failure paths: notFound() for missing records (one 404 path) · robots: { index: false } for drafts · propagate throws to error.tsx · fallback string for empty titles.
Cost rule: the head ships with the first byte — metadata latency delays the whole response; keep it one indexed lookup, no N+1; heavy og:images can point at an independently-resolving URL.
Cards: openGraph = cross-platform standard · twitter.card = summary_large_image for the wide card · type: "article" unlocks publishedTime/authors · relative image URLs resolve via metadataBase (localhost bug without it).