Metadata and SEO
By the end of this lesson
Set titles, descriptions and social metadata per route.
Metadata is the information about a page rather than in it: its title, a short description, the image that appears when someone shares the link, and the canonical address of the page.
It is easy to treat as an afterthought because it changes nothing on screen. It shows up in four places that matter: the browser tab, search results, the preview card in a chat or social app, and the accessible page title a screen reader announces on navigation.
In Next.js you set it by exporting from the same file as the page, which keeps a route's metadata next to the route.
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Employee directory",
description:
"Search the employee directory by name, department or role, and open a profile for contact details.",
alternates: { canonical: "/employees" },
openGraph: {
title: "Employee directory",
description: "Search by name, department or role.",
url: "/employees",
},
};
export default async function EmployeesPage() {
// ...
}- The export sits in the page file and Next.js reads it when rendering the route. Nothing has to be registered anywhere else.
- The Metadata type means a misspelled field is a compile error rather than a tag that silently never appears.
- The description is a complete sentence written for a person deciding whether to click. Search engines may show it or may use text from the page, and either way it is what the preview card uses.
- alternates.canonical states the address this page should be indexed under. The relative path works because the root layout sets metadataBase, which supplies the domain.
- A root layout usually sets a title template and defaults, so each page supplies its own part and the site name is appended once, in one place.
import type { Metadata } from "next";
import { getCourseBySlug } from "@/data/courses";
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const course = await getCourseBySlug(slug);
// A URL with no course: return nothing rather than inventing a title.
if (!course) return {};
return {
title: course.title,
description: course.shortDescription,
alternates: { canonical: "/courses/" + course.slug },
openGraph: {
title: course.title + " | Anvi Learning Academy",
description: course.shortDescription,
url: "/courses/" + course.slug,
type: "article",
},
};
}- generateMetadata replaces the static export when the values depend on the route. It receives the same params as the page and can await data.
- The data call is deduplicated with the identical call in the page component during the same render, so this does not double your queries.
- Returning an empty object for an unknown slug avoids a title like "undefined". The page itself calls notFound(), and the metadata should not contradict it.
- The Open Graph title includes the site name because a preview card appears with no other context. The page title does not need it repeated if the layout's template already adds it.
- Use generateMetadata only when the values genuinely vary. For a fixed page the static export is less code and less to go wrong.
What each field is actually for:
- title
- The browser tab, the search result heading, and what a screen reader announces after navigation. Put the distinguishing words first — "Employee directory" before the site name, because the end gets truncated.
- description
- One or two sentences, written to help someone decide whether this page answers their question. Roughly 150 characters survive in most search results. Not a keyword list.
- alternates.canonical
- The one address this content should be indexed under. It matters most where the same content is reachable by several URLs — query parameters for filters, sort orders, tracking codes — so crawlers do not treat them as separate pages competing with each other.
- openGraph
- The title, description, image and type used to build the preview card in chat apps and social platforms. Without it a shared link shows a bare URL, which people are markedly less likely to open.
- metadataBase
- Set once in the root layout. It gives relative URLs in metadata a domain to resolve against, which social platforms require because they cannot resolve a relative image path.
- robots
- Whether a page should be indexed. Useful for pages that exist but should not appear in results, such as an internal admin screen or a preview route.
The reason to do this per route rather than once for the site is that a title and description identify a page, and an identical one on every page identifies nothing.
Consider what a duplicated title costs. In search results, twenty pages look the same and a searcher cannot tell which one answers their question. When someone shares a lesson link in a team chat, the card says the site name and a generic tagline rather than the lesson's subject. A user with fifteen tabs open cannot find yours. And a screen reader user navigating between pages hears the same announcement each time, with no confirmation that anything changed.
None of that is about ranking. It is about a link being self-describing wherever it appears, which is worth the three lines per route it takes.
Summary
- Export metadata from a page for fixed values, or generateMetadata when they depend on the route
- Titles and descriptions identify a page in tabs, search results, shared links and screen reader announcements
- Put the distinguishing words first, because titles are truncated from the end
- A canonical URL stops filtered and sorted variants of a page competing with each other
- Open Graph fields build the preview card; metadataBase in the root layout makes relative URLs resolvable
- Metadata is generated with the HTML, so it cannot be set from a client component or an effect
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Try it yourself
Add metadata to a lesson route at /courses/[slug]/lessons/[lessonSlug]. Use the lesson's title and its objective, and set a canonical URL.
Then decide what should happen for a lesson that exists in the data but is not yet published.
Show solution
Both params are available, so the lesson can be looked up by course and slug. The objective doubles as the description, which is why it is written as a full sentence in the content model rather than as a fragment.
For an unpublished lesson, the route should not exist — notFound() in the page, and an empty metadata object here. Returning a real title for a page that 404s tells a crawler the page is there when it is not, and makes the site look broken in search results after the content is removed.
If you wanted unpublished lessons to be viewable by editors but not indexed, the answer is robots: { index: false } rather than omitting the metadata. Absent metadata is not an instruction; it just leaves the decision to the crawler.
The title puts the lesson first and the course second. In a tab strip or a list of search results, the lesson name is what distinguishes this page from its siblings.
import type { Metadata } from "next";
import { getLesson } from "@/data/lessons";
interface LessonParams {
slug: string;
lessonSlug: string;
}
export async function generateMetadata({
params,
}: {
params: Promise<LessonParams>;
}): Promise<Metadata> {
const { slug, lessonSlug } = await params;
const lesson = await getLesson(slug, lessonSlug);
// Not published means no route, so no metadata to give.
if (!lesson || lesson.status !== "published") return {};
const path = "/courses/" + slug + "/lessons/" + lessonSlug;
return {
title: lesson.title,
description: lesson.objective,
alternates: { canonical: path },
openGraph: {
title: lesson.title + " | Anvi Learning Academy",
description: lesson.objective,
url: path,
type: "article",
},
};
}Saved in this browser only.