The App Router's Metadata API is a genuine improvement over stuffing tags into <Head>. It is also easy to get subtly wrong in ways that do not surface until you check what actually rendered.
Static metadata vs generateMetadata
For a page whose metadata never changes, export a static object:
export const metadata: Metadata = {
title: "Pricing — Simple Plans | Acme",
description: "Straightforward pricing with no per-seat surprises.",
};
When metadata depends on data, export generateMetadata:
export async function generateMetadata(props: PageProps): Promise<Metadata> {
const { slug } = await props.params;
const product = await getProduct(slug);
if (!product) return { title: "Not found | Acme" };
return { title: `${product.title} | Acme`, description: product.excerpt };
}
Note params is a Promise and must be awaited. Requests made in generateMetadata are deduplicated against the same request in the page body, so fetching the product twice does not mean two database queries — provided you use the same function and it is cached.
Per-entity SEO with database overrides
For anything content-managed, editors need to override the title and description — and you need a sensible value when they have not.
The pattern we use is a single helper:
export function buildDynamicMetadata(input: DynamicSeoInput): Metadata {
const title = clean(input.metaTitle) ?? `${input.name} | ${input.suffix}`;
const description =
toMetaDescription(input.metaDescription) ??
toMetaDescription(input.descriptionFallback) ??
`${input.name} — ${input.suffix}.`;
// …
}
The fallback pattern: admin value wins, else generate
The detail that matters: treat empty strings as "not set".
// Wrong — an empty string is not null, so this returns ""
const title = row.metaTitle ?? generated;
// Right
const title = (row.metaTitle ?? "").trim() || generated;
?? only falls through on null and undefined. A CMS field that was filled in and then cleared stores "", and with ?? you ship an empty <title>. This single character is responsible for a surprising number of blank titles in production.
The same applies to derived descriptions: strip HTML and markdown, collapse whitespace, and truncate on a word boundary rather than mid-word.
export function toMetaDescription(v?: string | null, max = 160) {
const raw = (v ?? "").trim();
if (!raw) return undefined;
const text = raw.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
return text.length <= max
? text
: text.slice(0, max - 1).replace(/\s+\S*$/, "").trim() + "…";
}
Canonicals and locales
Set a canonical on every page:
alternates: { canonical: `/products/${slug}` }
Relative canonicals resolve against metadataBase, which you set once in the root layout:
export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL!),
};
Without metadataBase, relative OG image URLs will not resolve and you get a warning in build output — one worth not ignoring, because social previews break silently.
For multi-language sites, add alternates.languages so each locale points at its siblings. If you use localePrefix: "as-needed", remember the default locale has no prefix — the canonical for English is /products/x, not /en/products/x.
The title template trap
The root layout can define a template:
title: { default: "Acme", template: "%s | Acme" }
Every child title then gets | Acme appended. Which is useful — until a page sets a title that already ends in the brand, and you ship:
Pricing — Simple Plans | Acme | Acme
We had exactly this across an entire site. The fix is absolute, which bypasses the template:
return { title: { absolute: seo.title } };
Decide once: either titles carry the brand and you use absolute, or they never do and the template adds it. Mixing the two is what produces duplicates.
OpenGraph and Twitter images
openGraph: {
title, description,
type: "article",
images: cover ? [{ url: cover }] : undefined,
},
twitter: { card: "summary_large_image", title, description, images: cover ? [cover] : undefined },
Two notes. OpenGraph has its own title field which is not subject to the title template — so set it explicitly. And when adding article timestamps to a spread OG object, re-assert type: "article", or TypeScript cannot narrow the union and will reject publishedTime.
Sitemap and robots
Both are code, not files:
// app/sitemap.ts
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getPublishedPosts();
return [
{ url: abs("/"), priority: 1, changeFrequency: "weekly" },
...posts.map((p) => ({ url: abs(`/blog/${p.slug}`), lastModified: p.updatedAt })),
];
}
Generate dynamic entries from the database rather than maintaining a list by hand — a hand-maintained sitemap is out of date within a month.
In robots.ts, disallow private areas: admin, API routes, cart and checkout, auth pages and account pages.
Common mistakes
Forgetting
metadataBase— relative OG images silently fail??instead of||on CMS fields — empty titlesDouble brand suffixes from the title template
Missing canonicals on filtered or paginated listings, creating duplicate content
Titles over ~60 characters — truncated in results
Indexing thin pages — category pages with no content dilute the site
Never viewing source — the only way to know what actually rendered
That last one is the real lesson. Metadata code that compiles is not metadata that is correct. Load the page, view source, read the tags.