The Metadata API: static and per-page SEO
Every page in this series so far has shared whatever <title> the browser defaults to — nothing has set one. Next.js's Metadata API generates <title>, <meta name="description">, Open Graph tags, and more, directly from exported objects and functions instead of hand-written <head> JSX.
Global defaults in the root layout
// app/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: {
default: "devnotes",
template: "%s — devnotes",
},
description: "Notes on building with the App Router.",
};An exported metadata object in any layout.tsx or page.tsx is how Next.js generates the actual <head> tags — nothing renders this by hand. title.template is what turns a page's own title into "Post Title — devnotes" automatically wherever %s appears — every page below only needs to provide its own piece, not the site name every single time.
A static override for one page
// app/posts/page.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "All posts",
description: "Every post on devnotes, newest first.",
};This page's <title> renders as "All posts — devnotes" — the root layout's template still applies, since only the page-specific piece is being overridden here, not the whole title strategy.
Dynamic metadata: generateMetadata
A static metadata export can't know which specific post is being viewed — [slug]/page.tsx needs a metadata function instead, one that reads the same route params the page component does.
// app/posts/[slug]/page.tsx
import type { Metadata } from "next";
import { getAllPosts } from "@/lib/posts";
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post = getAllPosts().find((p) => p.slug === slug);
if (!post) return {};
return {
title: post.title,
description: post.excerpt,
};
}generateMetadata runs before the page component itself, with the exact same params shape — same await params pattern from part 6. Each post now gets its own real <title> and description instead of every post sharing one generic tag, which is exactly what a search result snippet and a shared social link both actually read.
Open Graph and Twitter cards
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post = getAllPosts().find((p) => p.slug === slug);
if (!post) return {};
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
type: "article",
publishedTime: post.date,
},
};
}openGraph controls how a link preview renders on platforms that read it — Slack, Twitter/X, LinkedIn. Without it, a shared link to a post typically falls back to whatever generic preview the site's root metadata provides, if anything at all.
Defining metadata as a plain static export on a dynamic route instead of using generateMetadata. A static metadata object has no access to params at all — it can't know which post is being viewed, so every post under [slug] would silently share the exact same title and description.
Next: structured data — the JSON-LD markup that goes beyond title and description, aimed specifically at how search engines and AI answer engines parse a page.