Metadata and SEO
In the App Router <title>, meta tags and Open Graph tags come from the
Metadata API: a layout or a page exports a static metadata object or
an async generateMetadata() function (only in Server Components). Next.js
merges the objects from the root layout down to the page and renders the tags
into <head>. Icons, OG images, sitemap.xml and robots.txt are files in
app/: static, or code that generates them.
Applies to: Next.js 16.4.
Details
metadata vs generateMetadata
| Export | Use |
|---|---|
metadata | Static values: the site name, a description, the root metadataBase |
generateMetadata() | Values from data: the post title, its cover as the OG image |
- Only in
layout.tsxandpage.tsx, only in Server Components. Exporting both from the same segment is an error. - Shallow merge from the root layout to the page: a field set lower replaces
the parent's. A nested object (
openGraph,robots) is replaced as a whole, so a page that setsopenGraph.titleloses the parent'sopenGraph.images; keep shared parts in a variable and spread it. generateMetadatareceivesparamsandsearchParamslike the page. Afetchwith the same URL and options runs once for metadata and the page; other data loaders are wrapped in Reactcachefor the same effect.
Title and URLs
title: { template: '%s | Acme', default: 'Acme' }in the root layout: a page withtitle: 'Blog'getsBlog | Acme; a page without a title getsAcme. The template applies to child segments, not to the segment that defines it.title: { absolute: 'Acme' }ignores the parent's template.metadataBase(new URL('https://acme.com')in the root layout) turns relative URLs inopenGraph.images,alternates.canonicaland others into absolute ones; a relative URL without it is a build error.
File-Based Metadata
File in app/ | Result |
|---|---|
favicon.ico, icon.png, apple-icon.png | <link rel="icon">, <link rel="apple-touch-icon"> |
opengraph-image.png, twitter-image.png | og:image, twitter:image for this segment and below |
opengraph-image.tsx | The image generated from JSX by ImageResponse (next/og) |
sitemap.ts | /sitemap.xml from a MetadataRoute.Sitemap array |
robots.ts | /robots.txt from a MetadataRoute.Robots object |
manifest.ts | /manifest.webmanifest |
- A file takes precedence over the
metadataobject for the same tag. ImageResponsesupports flexbox and a subset of CSS (no grid).- A large site splits the sitemap with
generateSitemaps(): one file holds at most 50,000 URLs.
Viewport
themeColor, colorScheme and the viewport meta tag are a separate export:
viewport or generateViewport(), with the same rules as metadata. Inside
metadata these fields are deprecated.
Streaming Metadata
- For a page rendered on request,
generateMetadatadoes not block the HTML: the page streams, and the tags are added when they resolve (in<body>; browsers and crawlers that run JavaScript handle it). - Bots that read only HTML (social previews:
facebookexternalhit, Twitterbot, Slackbot; detected by user agent, thehtmlLimitedBotsoption) get blocking metadata in<head>. - A prerendered page has its metadata in
<head>already. - With Cache Components, metadata that reads request data (
cookies(),headers(),params,searchParams) or uncached data, on a page that is otherwise fully prerendered, is a build error. Fix it with'use cache'ingenerateMetadataif the data is not request data, or by marking the page dynamic on purpose (a component that awaitsconnection()inside<Suspense>).
Other Ways to Set Tags
- React 19 hoists
<title>and<meta>rendered in any component to<head>. In Next.js the Metadata API is preferred: it merges segments and streams; a React<title>is the fallback where the API is not available, for example inglobal-error.tsx, which is a Client Component. - The Pages Router uses
<Head>fromnext/headon each page.
Example
app/layout.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
metadataBase: new URL('https://acme.com'),
title: { template: '%s | Acme', default: 'Acme' },
description: 'Acme online store',
};
app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getPost } from '@/lib/posts'; // wrapped in React cache()
type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
return {
title: post?.title, // → "Hello | Acme"
alternates: { canonical: `/blog/${slug}` },
openGraph: { images: post ? [post.cover] : [] },
};
}
export default async function Page({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug); // the same call: not run twice
if (!post) notFound();
return <h1>{post.title}</h1>;
}
OG image, sitemap and robots
app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og';
import { getPost } from '@/lib/posts';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
export default async function Image({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
return new ImageResponse(
<div style={{ display: 'flex', fontSize: 64, padding: 48 }}>
{post?.title}
</div>,
size
);
}
app/sitemap.ts
import type { MetadataRoute } from 'next';
import { getPosts } from '@/lib/posts';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getPosts();
return [
{ url: 'https://acme.com', lastModified: new Date() },
...posts.map((post) => ({
url: `https://acme.com/blog/${post.slug}`,
lastModified: post.updatedAt,
})),
];
}
app/robots.ts
import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: '*', allow: '/', disallow: '/admin/' },
sitemap: 'https://acme.com/sitemap.xml',
};
}
Follow-up Questions
- Why can a Client Component not export
metadata? Metadata is resolved on the server as part of rendering; a page that needs client code keeps the metadata in the page and moves the interactive part into a child component. - A page sets only
openGraph.title; why did the OG image disappear? The merge is shallow: the page'sopenGraphreplaced the layout's whole object. - How do you avoid duplicate content between
/blog?page=1and/blog?alternates.canonical. - Does
robots.txthide a page from search? No, it only asks crawlers not to crawl it; a page linked from elsewhere can still be indexed. Userobots: { index: false }(noindex) on the page. - How do you add structured data (JSON-LD)? Render
<script type="application/ld+json">in the page; the Metadata API does not cover it. - How do you link localized versions of a page?
alternates.languages(hreflanglinks), resolved againstmetadataBase.
Common Mistakes
- Exporting
metadataandgenerateMetadatafrom the same file. - Relative OG image URLs without
metadataBase. - Loading the same data separately for metadata and the page without
fetchmemoization orcache, so it runs twice. - Using
next/headin the App Router: it is not supported there.