Metadata & SEO
Title, description, preview image – and who computes them
You export metadata from a page or a layout. When the values only emerge after fetching, you compute them in generateMetadata.
Because Next.js renders your pages on the server, the content is already in the HTML – half the SEO work is done. That leaves the other half: title, description and preview image.
The simple case
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "About us",
description: "Who we are and what we work on.",
};
export default function Page() {
return <h1>About us</h1>;
} export const metadata: Metadata = {
metadataBase: new URL("https://example.com"),
title: {
default: "Example Ltd",
template: "%s | Example Ltd", // the page title is slotted in
},
description: "Default description for anything without its own.",
openGraph: { type: "website", locale: "en_US" },
}; Without it, image and canonical addresses stay relative – and relative addresses do not work in social preview cards. Set once in the root layout, it applies everywhere.
When the values come from the data
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post = await loadPost(slug);
if (!post) return { title: "Not found" };
return {
title: post.title,
description: post.excerpt,
alternates: { canonical: "/blog/" + slug },
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.image],
type: "article",
publishedTime: post.date,
},
};
} generateMetadata and the page often fetch the same data. Thanks to Request memoizationThe same `fetch` used several times while building one page only really runs once.→ the request still goes out only once – so you can write the call twice without a second thought.
The files around it
app/
icon.png → favicon
apple-icon.png → icon for iOS
opengraph-image.png → preview image for shared links
robots.ts → robots.txt
sitemap.ts → sitemap.xml import type { MetadataRoute } from "next";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await loadAllPosts();
return [
{ url: "https://example.com", changeFrequency: "weekly", priority: 1 },
...posts.map((p) => ({
url: "https://example.com/blog/" + p.slug,
lastModified: p.updatedAt,
})),
];
} What gets forgotten
- +
A distinct, descriptive
descriptionper page – not a repeat of the title - +
alternates.canonicalas soon as addresses with parameters exist - +
robots: { index: false }for pages with no content in the HTML – account, internal tools - +
Structured data via JSON-LD for articles, products, FAQs
- −
Metadata in a client component – the export does nothing there
- −
Titles over 60 characters; they get cut off in results
- −
The same text on every page
The metadata export is only read in server components. If the file says "use client", nothing happens – with no error message. When your title does not arrive, that is the first thing to check.
▸ In more depth: structured data for search and AI answers optional
Beyond metadata, JSON-LD pays off. It describes in machine-readable form what is on the page – and answer engines like to quote exactly that, because they do not have to guess it from the prose.
export default async function Page({ params }) {
const { slug } = await params;
const post = await loadPost(slug);
const jsonLd = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: post.title,
datePublished: post.date,
author: { "@type": "Person", name: post.author },
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article>{post.body}</article>
</>
);
} Instead of producing an image for every post, an opengraph-image.tsx can generate one at runtime – with the title and author on it. It is a small component that Next.js turns into an image.
Does it stick?
4 questions on this lesson. Wrong answers show up in your stats.