Metadata & SEO
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Next.js 16.3.8 (the latest release on 2026-10-03, when the facts and examples below were
checked; the official documentation reports @doc-version 16.3.8), React 19.3.0, Node.js 20.9.0 or later and
TypeScript 5.1 or later. It covers the App Router, which has a dedicated Metadata API, and the Pages Router, which
does not and still relies on next/head; the last sections explain why. Caching matters in two places: whether a
metadata file is generated at build time or per request, and what happens when generateMetadata reads request data.
Both are shown under the previous model (the default) and under Cache Components (cacheComponents: true), and the
text says which one an example uses. Every example was built with next build and served with next start (on
Node.js 24.16, macOS) in a scratch copy of the Bookshelf catalogue that this section grows, then inspected with curl; each
one is followed by the official page it derives from.
Search engines and social networks read the HTML your server sends, not the page you see after hydration. Titles,
descriptions, canonical links, Open Graph cards, structured data and sitemaps are therefore a server-side concern, and
Next.js turns them into typed exports and file names instead of hand-written <head> markup. If the underlying HTML
and ranking ideas (title length, headings, internal links) are new to you, read
SEO fundamentals first; this page is about how Next.js produces them.
The Three Mechanisms
The App Router has three ways to produce <head> content. They can be combined, and Next.js merges them into one set of
tags per route.
| Mechanism | Use it for | Where it runs |
|---|---|---|
The |
Values known at build time: site name, default description, robots rules, icons. |
Exported from a |
The |
Values that depend on route params, search params or data: a book title, a canonical URL per locale. |
Exported from a |
File conventions |
Icons, Open Graph and Twitter images, |
Static files are served as they are; generated ones are special Route Handlers. |
Four rules hold for all of them:
-
metadataandgenerateMetadatawork only in Server Components. Exporting them from a file that starts with'use client'fails the build withYou are attempting to export "metadata" from a component marked with "use client", which is disallowed. Keep the page a Server Component and move interactive parts into a child Client Component (see Server and Client Components). -
A route segment cannot export both
metadataandgenerateMetadata. -
Metadata is evaluated from the root layout down to the page, and later segments overwrite earlier ones field by field (see Ordering and Merging).
-
File-based metadata has the highest priority and overrides the
metadataobject andgenerateMetadata.
Two tags are always present even when a route defines nothing: <meta charset="utf-8"> and
<meta name="viewport" content="width=device-width, initial-scale=1">.
The Static metadata Object
The root layout of the scratch app sets the defaults for every route below it.
import type { Metadata, Viewport } from 'next'
import { siteUrl } from '@/lib/site' // 'https://bookshelf.example.com'
export const metadata: Metadata = {
metadataBase: new URL(siteUrl),
title: { default: 'Bookshelf', template: '%s | Bookshelf' },
description: 'A small catalogue of books, authors and reviews.',
openGraph: { siteName: 'Bookshelf', type: 'website' },
}
export const viewport: Viewport = {
themeColor: [
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
{ media: '(prefers-color-scheme: dark)', color: '#111827' },
],
colorScheme: 'light dark',
}
Three fields in this snippet deserve a closer look.
title as an object. A string is the title of the route. An object has three keys:
-
defaultis the fallback for child segments that define no title; it is required when you set atemplate. -
template('%s | Bookshelf') wraps the title of child segments. It does not apply to the segment that defines it, so a template in a layout never changes that same layout’s title, and a template in apage.tsxhas no effect at all. -
absoluteignores any parent template, for a page that must use its title verbatim.
metadataBase. Open Graph images, canonical links and alternates must be absolute URLs. With metadataBase set,
every URL-based field in that segment and below may be a relative path, and Next.js composes the full URL
(/en/books/1 becomes https://bookshelf.example.com/en/books/1, and duplicate slashes are normalised). Without it,
the build prints a warning and resolves social image URLs against http://localhost:3000, which is wrong in production:
⚠ metadataBase property in metadata export is not set for resolving social open graph or
twitter images, using "http://localhost:3000".
Set it once, in the root layout, from a constant or an environment variable that holds the canonical origin of your site.
viewport is not part of metadata. Theme colour, colour scheme and the viewport meta tag moved to their own export
in Next.js 14; see Viewport: viewport and generateViewport.
generateMetadata: Metadata That Depends on Data
generateMetadata receives the same params and searchParams promises as the page, plus a parent argument that
resolves the metadata of the parent segments (useful to extend, say, the parent’s openGraph.images). Since Next.js 15
params is a promise, so it is awaited. The book page of the scratch app uses it for the title, the description, the
canonical URL, the language alternates and the Open Graph card.
import type { Metadata } from 'next'
import { locales, defaultLocale } from '@/lib/i18n' // ['en', 'es'], 'en'
import { getBook } from '@/lib/books'
export async function generateMetadata({
params,
}: PageProps<'/[locale]/books/[id]'>): Promise<Metadata> {
const { locale, id } = await params
const book = await getBook(id)
if (!book) return { title: 'Book not found', robots: { index: false } }
const path = (l: string) => `/${l}/books/${id}`
return {
title: book.title, // "Dune | Bookshelf" through the layout template
description: book.summary,
alternates: {
canonical: path(locale),
languages: {
...Object.fromEntries(locales.map((l) => [l, path(l)])),
'x-default': path(defaultLocale),
},
},
openGraph: {
type: 'book',
title: book.title,
description: book.summary,
url: path(locale),
locale,
authors: [book.author],
},
twitter: { card: 'summary_large_image' },
}
}
Source: generateMetadata
getBook is wrapped in React’s cache, so the page, generateMetadata and the image generator below share one lookup
per request instead of three:
import 'server-only'
import { cache } from 'react'
export const getBook = cache(async (id: string) => {
return books.find((b) => b.id === id)
})
fetch calls with identical arguments are memoised automatically across generateMetadata, generateStaticParams,
layouts and pages; cache is the tool for anything that is not fetch (a database client, an ORM).
Source: React: cache
next build prerenders the book pages with generateStaticParams, and the resulting head is plain HTML. This is the
relevant part of curl -s localhost:3703/en/books/1, trimmed to the tags this section is about:
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)"/>
<meta name="theme-color" content="#111827" media="(prefers-color-scheme: dark)"/>
<meta name="color-scheme" content="light dark"/>
<title>Dune | Bookshelf</title>
<meta name="description" content="A desert planet, a spice, and a prophecy."/>
<link rel="manifest" href="/manifest.webmanifest"/>
<link rel="canonical" href="https://bookshelf.example.com/en/books/1"/>
<link rel="alternate" hrefLang="en" href="https://bookshelf.example.com/en/books/1"/>
<link rel="alternate" hrefLang="es" href="https://bookshelf.example.com/es/books/1"/>
<link rel="alternate" hrefLang="x-default" href="https://bookshelf.example.com/en/books/1"/>
<meta property="og:title" content="Dune"/>
<meta property="og:url" content="https://bookshelf.example.com/en/books/1"/>
<meta property="og:locale" content="en"/>
<meta property="og:image" content="https://bookshelf.example.com/en/books/1/opengraph-image?b800678922d9a028"/>
<meta property="og:image:type" content="image/png"/>
<meta property="og:image:width" content="1200"/>
<meta property="og:image:height" content="630"/>
<meta property="og:type" content="book"/>
<meta property="book:author" content="Frank Herbert"/>
<meta name="twitter:card" content="summary_large_image"/>
<meta name="twitter:title" content="Dune"/>
<meta name="twitter:image" content="https://bookshelf.example.com/en/books/1/opengraph-image?b800678922d9a028"/>
<link rel="icon" href="/favicon.ico?favicon.2vob68tjqpejf.ico" sizes="256x256" type="image/x-icon"/>
<link rel="icon" href="/icon?98e596b4e7d02b9e" type="image/png" sizes="32x32"/>
Things to read off that output: the layout’s template produced the title; the twitter:title and twitter:image
were inherited from openGraph (Next.js falls back to the Open Graph values when twitter does not set them); the
og:image and its size and type came from the opengraph-image file (see File-Based Metadata); the manifest and the
icons were added by file conventions without any code in the layout. The same build and the same curl were repeated
with cacheComponents: true and produced the same head.
Ordering and Merging
Metadata is evaluated from the root segment to the page. Fields are shallowly merged: a field defined in a later
segment replaces the earlier one as a whole. That matters for nested objects: a page that defines
openGraph: { title: 'Dune' } drops the layout’s openGraph.siteName, because openGraph is replaced, not merged.
Either repeat the fields you need, or hoist the shared values into a module and spread them
(openGraph: { …sharedOpenGraph, title }). A field that a page does not define at all is inherited.
Source: generateMetadata: merging
Viewport: viewport and generateViewport
Since Next.js 14, themeColor, colorScheme and the viewport meta tag are no longer part of metadata; they have their
own exports. The root layout above shows the static viewport object. When the value depends on the route, export
generateViewport:
import type { Viewport } from 'next'
export function generateViewport({ params }: PageProps<'/[locale]/vp'>): Viewport {
return { themeColor: 'orange', width: 'device-width', initialScale: 1 }
}
curl -s localhost:3703/en/vp returns <meta name="theme-color" content="orange"/>. As with metadata, a segment cannot
export both viewport and generateViewport, and both are Server Component only. If the value does not depend on
request data, prefer the static object. A codemod (metadata-to-viewport-export) migrates old metadata.themeColor
and metadata.viewport exports.
Unlike metadata, viewport cannot be streamed, because it affects the first paint. Under Cache Components, a
generateViewport that reads cookies(), headers(), params or searchParams forces the page to wait for it. The
docs offer three fixes: cache the data with 'use cache', wrap the document <body> in a <Suspense> boundary so
the shell ships first, or opt a segment out with instant = false.
Source: generateViewport
Streaming Metadata
If generateMetadata is slow (a CMS call, say), blocking the whole response on it would delay the first byte of every
dynamically rendered page. Since Next.js 15.2, for dynamically rendered pages Next.js therefore sends the UI first and
streams the metadata once generateMetadata resolves; React then moves the tags into <head> on the client. Crawlers
that execute JavaScript and read the final DOM, such as Googlebot, see correct metadata. Crawlers that only read the
initial HTML, for example facebookexternalhit, Twitterbot, Slackbot or Bingbot, would miss it, so Next.js detects
them by User-Agent (the HTML-limited bots) and blocks rendering until the metadata is ready, putting it in
<head> as before. Prerendered pages are not affected: their metadata is resolved at build time.
htmlLimitedBots?} UA -- "yes: Twitterbot, Slackbot, Bingbot, ..." --> B[Wait for generateMetadata] B --> H[Send HTML with title and meta tags in head] UA -- "no: browsers, Googlebot" --> S[Send the UI shell immediately] S --> M[generateMetadata resolves] M --> T[Metadata tags streamed into the body, moved to head by React]
To see it, the scratch app has a deliberately slow dynamic route whose metadata depends on searchParams:
import type { Metadata } from 'next'
export async function generateMetadata({
searchParams,
}: PageProps<'/[locale]/live'>): Promise<Metadata> {
const { q } = await searchParams // request-time data
await new Promise((r) => setTimeout(r, 500)) // stands in for a slow CMS call
return { title: q ? `Search: ${q}` : 'Search', robots: { index: false } }
}
Requesting it with three user agents and measuring when the first byte arrives and where <title> sits in the HTML:
UA Mozilla/5.0 Chrome/120 first byte 0.075 s </head> at 1034 <title> at 8429 (after </head>, in the body)
UA Twitterbot/1.0 first byte 0.509 s </head> at 1874 <title> at 961 (inside head)
UA Googlebot/2.1 first byte 0.004 s </head> at 1034 <title> at 8429 (streamed)
The browser and Googlebot get the shell in milliseconds with the title streamed later; Twitterbot waits 500 ms and gets
the title in <head>.
Tuning It with htmlLimitedBots
htmlLimitedBots in next.config.ts takes a regular expression that replaces the default user-agent list (which
includes Google’s AdsBot and similar crawlers, Bingbot, Twitterbot and Slackbot). Passing /.*/ disables streaming
metadata for everyone:
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
htmlLimitedBots: /.*/,
}
export default nextConfig
With this setting the same curl as above, using a plain browser user agent, took 0.54 s to the first byte and returned
<title> inside <head>. The docs warn that overriding the list can lengthen response times and call the default
sufficient for most sites; use the override only when a crawler you care about is missing from the default list.
Source: htmlLimitedBots
Metadata Under Cache Components
With cacheComponents: true, generateMetadata follows the rules of any other component (see
Partial Prerendering and Cache Components). If it
reads runtime data (cookies(), headers(), params of a route without generateStaticParams, searchParams) or
does uncached I/O, it is deferred to request time. Two cases follow:
-
The rest of the page is deferred too (the
/livepage above readssearchParamsin a<Suspense>child): the static shell is prerendered and the metadata streams in with the other dynamic content. The build succeeded and the route is listed as◐ Partial Prerender. -
The page is otherwise fully prerenderable: Next.js refuses to guess and fails the build, because blocking only the metadata is rarely what you meant. A page with a static body and the same
searchParams-basedgenerateMetadatafails with:
Route "/[locale]/cc-meta": Next.js encountered uncached or runtime data in `generateMetadata()`.
This route's metadata is blocked, but the rest of its content can be prerendered.
Ways to fix this:
- [static] Use a static metadata export instead of `generateMetadata()`
- [cache] Cache the metadata with `"use cache"` in `generateMetadata()` (does not apply to `connection()`)
- [dynamic] Render a marker component that calls `await connection()` inside `<Suspense>` on the page
Learn more: https://nextjs.org/docs/messages/blocking-prerender-metadata-runtime
The cache fix is one directive. The result is part of the prerendered shell (the route table shows a 15m revalidate
and a 1y expire for it) and the <title> is in <head>:
import type { Metadata } from 'next'
import { getBook } from '@/lib/books'
export async function generateMetadata(): Promise<Metadata> {
'use cache'
const book = await getBook('1')
return { title: book?.title ?? 'Bookshelf' }
}
When the metadata really needs runtime data, add a dynamic marker: a component that renders nothing but awaits
connection() inside <Suspense>, which tells Next.js that deferring is intentional, so the static content still
prerenders and the metadata streams:
import { Suspense } from 'react'
import { connection } from 'next/server'
async function Connection() {
await connection()
return null
}
function DynamicMarker() {
return (
<Suspense>
<Connection />
</Suspense>
)
}
export default function Page() {
return (
<main>
<h1>Static body</h1>
<DynamicMarker />
</main>
)
}
Both variants were built with cacheComponents: true; in the marker case the browser receives the title after
</head>, and Twitterbot receives it in <head>. One caveat from the docs: a 'use cache' function must return
serialisable values, and URL instances are not serialisable, so return strings (for example metadataBase as
url.toString()) from a cached generateMetadata. Place the marker beside the static content, not above it:
awaiting connection() directly in the page would also remove the static content from the shell.
File-Based Metadata
Special files in a route segment generate the <head> tags for you. Each can be a static file or a .js/.ts/.tsx
file that generates the content. Next.js serves static files with a hash in production so they can be cached for a
long time, and it adds the matching <link> and <meta> tags with the correct URL, type and size.
| File | Result | Notes |
|---|---|---|
|
|
Only in the root of |
|
|
May appear in any segment. Number suffixes ( |
|
|
Per segment; the most specific file wins. Size limits: 8 MB for Open Graph and 5 MB for Twitter images (the build fails above them). |
|
|
Static XML or a function returning URLs; |
|
|
Root of |
|
|
Root of |
Generated metadata files are special Route Handlers (see
Route Handlers and Backend for Frontend) and are cached by
default: they are produced at build time unless they use a request-time API, uncached data or a dynamic route
segment config. This is why a sitemap.ts that reads a database is still a static file after next build.
Source: Metadata Files
Icons
For a static icon, put favicon.ico in app/ and, if needed, icon.png, apple-icon.png or SVG files in any
segment. To draw an icon in code, export a default function from icon.tsx (or apple-icon.tsx) that returns an
ImageResponse, with size and contentType exports:
import { ImageResponse } from 'next/og'
export const size = { width: 32, height: 32 }
export const contentType = 'image/png'
export default function Icon() {
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#111827',
color: 'white',
fontSize: 22,
}}
>
B
</div>
),
size,
)
}
The build lists ○ /icon (static) and the head gained <link rel="icon" href="/icon?98e596b4e7d02b9e" type="image/png"
sizes="32x32"/>. generateImageMetadata produces several variants (sizes, themes) from one file.
Source: favicon, icon, and apple-icon
Open Graph and Twitter Images with ImageResponse
An opengraph-image.tsx (or twitter-image.tsx) next to a page generates a card per route. The default export gets
params as a promise (since Next.js 16), and exports alt, size and contentType become the matching meta tags.
import { ImageResponse } from 'next/og'
import { getBook } from '@/lib/books'
export const alt = 'Book card'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export function generateStaticParams() {
return ['en', 'es'].flatMap((locale) =>
['1', '2', '3'].map((id) => ({ locale, id })),
)
}
export default async function Image({
params,
}: {
params: Promise<{ locale: string; id: string }>
}) {
const { id } = await params
const book = await getBook(id)
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
padding: 80,
background: '#111827',
color: 'white',
}}
>
<div style={{ fontSize: 96, fontWeight: 700 }}>{book?.title ?? 'Bookshelf'}</div>
<div style={{ fontSize: 48, marginTop: 24, color: '#9ca3af' }}>{book?.author}</div>
</div>
),
size,
)
}
Built and requested, the endpoint answers with a PNG:
$ curl -s -o /dev/null -w '%{http_code} %{content_type}\n' localhost:3703/en/books/2/opengraph-image
200 image/png
The route table of the previous model shows how generateStaticParams matters here:
├ /[locale]/books/[id]/opengraph-image
│ ├ ● /en/books/1/opengraph-image
│ ├ ● /en/books/2/opengraph-image
│ ├ ● /en/books/3/opengraph-image
│ └ ● [+3 more paths]
Without generateStaticParams in that file, the first build of the scratch app listed
ƒ /[locale]/books/[id]/opengraph-image: reading params of a dynamic segment is a request-time access, so every
request would render the image. A page’s own generateStaticParams does not apply to its opengraph-image file.
ImageResponse (from next/og, since Next.js 14; it was importable from next/server in 13.x) turns JSX and CSS into
a PNG using Satori and resvg. It supports flexbox, absolute positioning, custom fonts (load the font file and pass it in
the fonts option), text wrapping and nested images, but not display: grid or most advanced CSS. Because the
image is fetched by crawlers, keep it light, and remember it is public: never render private data into a card.
Source: opengraph-image and twitter-image and ImageResponse
When you only need a fixed card, drop opengraph-image.png in app/ or a segment; that and the .alt.txt companion
need no code. A file in a segment takes precedence over files above it.
sitemap.ts
A sitemap tells crawlers which URLs exist and, optionally, when they changed and where translations live. The scratch app
generates one with hreflang alternates for each book:
import type { MetadataRoute } from 'next'
import { listBooks } from '@/lib/books'
import { locales } from '@/lib/i18n'
import { siteUrl } from '@/lib/site'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const books = await listBooks()
return books.map((b) => ({
url: `${siteUrl}/en/books/${b.id}`,
lastModified: new Date('2026-09-01'),
changeFrequency: 'monthly',
priority: 0.8,
alternates: {
languages: Object.fromEntries(
locales.map((l) => [l, `${siteUrl}/${l}/books/${b.id}`]),
),
},
}))
}
curl -s localhost:3703/sitemap.xml returned content-type: application/xml and, for each book:
<url>
<loc>https://bookshelf.example.com/en/books/1</loc>
<xhtml:link rel="alternate" hreflang="en" href="https://bookshelf.example.com/en/books/1" />
<xhtml:link rel="alternate" hreflang="es" href="https://bookshelf.example.com/es/books/1" />
<lastmod>2026-09-01T00:00:00.000Z</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
Source: sitemap.xml
A sitemap is limited to 50,000 URLs (Google’s limit). For larger catalogues export generateSitemaps from a
segment’s sitemap.ts to produce several files. Since Next.js 16 the id argument of the sitemap function is a
promise of a string, so convert it explicitly:
import type { MetadataRoute } from 'next'
import { listBooks } from '@/lib/books'
import { siteUrl } from '@/lib/site'
const PAGE_SIZE = 2 // 50000 in a real site
export async function generateSitemaps() {
const total = (await listBooks()).length
return Array.from({ length: Math.ceil(total / PAGE_SIZE) }, (_, id) => ({ id }))
}
export default async function sitemap(props: {
id: Promise<string>
}): Promise<MetadataRoute.Sitemap> {
const id = Number(await props.id)
const books = (await listBooks()).slice(id * PAGE_SIZE, (id + 1) * PAGE_SIZE)
return books.map((b) => ({ url: `${siteUrl}/en/books/${b.id}` }))
}
The build listed ● /books/sitemap/0.xml and ● /books/sitemap/1.xml, and curl localhost:3703/books/sitemap/0.xml
returned the first two books. The parts are served at /…/sitemap/[id].xml; list each of them in robots.ts or
submit them in your search engine’s console. Sitemap entries can also describe images and videos.
Source: generateSitemaps
robots.ts
robots.ts returns the rules of the Robots Exclusion Standard and the sitemap location:
import type { MetadataRoute } from 'next'
import { siteUrl } from '@/lib/site'
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: '*', allow: '/', disallow: ['/api/', '/live'] },
sitemap: `${siteUrl}/sitemap.xml`,
}
}
$ curl -s localhost:3703/robots.txt
User-Agent: *
Allow: /
Disallow: /api/
Disallow: /live
Sitemap: https://bookshelf.example.com/sitemap.xml
rules may be an array to give different user agents different rules, and the other field passes non-standard
directives such as Request-Rate or Clean-param. Remember that robots.txt is advice to well-behaved crawlers and
publicly readable: it does not protect anything. Pages that must not be reachable need authentication on the server,
and pages that may be fetched but must not appear in results need robots: { index: false } in their metadata (a page
that is blocked in robots.txt cannot be crawled, so crawlers never see its noindex).
Source: robots.txt
manifest.ts
manifest.ts returns a Web App Manifest, which browsers use when a site is installed as a progressive web app:
import type { MetadataRoute } from 'next'
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'Bookshelf',
short_name: 'Bookshelf',
description: 'A small catalogue of books, authors and reviews.',
start_url: '/',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#111827',
icons: [{ src: '/icon', sizes: '32x32', type: 'image/png' }],
}
}
The response was 200, content-type: application/manifest+json, served from /manifest.webmanifest and linked from
every page head. A static manifest.json or manifest.webmanifest file works the same way.
Source: manifest.json
Do Not Let the Proxy Redirect Metadata Files
If your proxy.ts rewrites or redirects every path (a locale redirect, for example), crawlers and browsers asking for
/icon get redirected too. The scratch app’s proxy adds a locale prefix to anything that lacks one, and the first
test returned:
/icon -> 307 http://localhost:3703/en/icon
The default matcher already skips paths with a dot (/sitemap.xml, /robots.txt, /manifest.webmanifest,
/favicon.ico), but /icon and /apple-icon have no extension. The docs recommend configuring the proxy matcher to
exclude metadata files; here:
export const config = {
matcher: ['/((?!api|_next|icon$|apple-icon$|.*\\..*).*)'],
}
After the change, requesting /icon returned 200 image/png. Routes under a locale segment such as /en/books/1/opengraph-image were never affected, because they already start
with a locale. See Redirects, Rewrites and Proxy for matcher syntax.
Source: Proxy: matcher
JSON-LD Structured Data
Titles and descriptions describe a page to people. Structured data describes the entities on it to machines: a book
with an author and a price, an article, an event. The format recommended by search engines is JSON-LD with the
schema.org vocabulary. Next.js has no API for it: the docs recommend rendering a plain
<script type="application/ld+json"> tag from a layout or a page. A native <script> is correct here: next/script is
for executable code, and JSON-LD is data.
The one trap is JSON.stringify, which does not escape <. A title such as </script><script>alert(1)</script>
stored in your database would close the tag and inject script. Replace < with its Unicode escape. A small component
keeps that in one place:
export function JsonLd({ data }: { data: Record<string, unknown> }) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
// "<" -> "\u003c" so data can never close the script tag
__html: JSON.stringify(data).replace(/</g, '\\u003c'),
}}
/>
)
}
<JsonLd
data={{
'@context': 'https://schema.org',
'@type': 'Book',
name: book.title,
author: { '@type': 'Person', name: book.author },
description: book.summary,
url: `${siteUrl}/en/books/${book.id}`,
offers: {
'@type': 'Offer',
price: (book.priceCents / 100).toFixed(2),
priceCurrency: 'EUR',
availability: 'https://schema.org/InStock',
},
}}
/>
The prerendered HTML contains it:
<script type="application/ld+json">{"@context":"https://schema.org","@type":"Book","name":"Dune","author":{"@type":"Person","name":"Frank Herbert"},"description":"A desert planet, a spice, and a prophecy.","url":"https://bookshelf.example.com/en/books/1","offers":{"@type":"Offer","price":"9.99","priceCurrency":"EUR","availability":"https://schema.org/InStock"}}</script>
Take the values from the same server-side source as the visible page (the price here is rendered from the book record
on the server, never from client input), so that the structured data cannot disagree with what the user sees. Validate
output with Google’s Rich Results Test or the Schema Markup Validator; for type safety, the community package
schema-dts types the objects. The <script> can sit in the body, as here, where the component streams in; JSON-LD is valid
anywhere in the document.
Source: JSON-LD
Canonical URLs and Language Alternates
alternates in the metadata produces the link tags that deal with duplicate and translated content:
-
canonicalis the preferred URL of the content. Set it whenever the same page is reachable under several URLs (query strings, trailing slashes,wwwand non-www, filter parameters). In the book example it is the page’s own localized path. -
languageslists the translations ashreflangalternates, including every language once and, by convention, anx-defaultentry for visitors whose language is not covered. The set must be reciprocal: each translation lists all the others (and itself), which theObject.fromEntries(locales.map(…))in the book example guarantees. -
mediaandtypesadd<link rel="alternate">for mobile URLs and feeds such as RSS.
| Situation | What to do |
|---|---|
The same list is reachable as |
Canonical points to |
A translated page with its own URL ( |
Canonical points to itself, not to the English page; |
A page that exists only in one language |
Omit |
URLs given as relative paths |
Requires |
A deleted book |
Return |
A caveat from the scratch app that the /en/books/99 request showed: notFound() thrown inside a <Suspense> boundary
after the shell was flushed cannot change the status code, so the response is 200 with <title>Book not found | Bookshelf</title>
and <meta name="robots" content="noindex"/>. That noindex from generateMetadata is what keeps such URLs out of the
index. If the status must be 404, call notFound() before any streaming starts (see
Streaming and Suspense). The localisation side, with the proxy and
the app/[locale] layout, is in Internationalization.
Source: generateMetadata: alternates
SEO per Rendering Strategy
Crawlers fetch HTML, and some (Google) also run JavaScript in a second pass, later and with a rendering budget. What matters for SEO is therefore what is in the first HTML response. The Metadata API puts titles and tags in the first response for every server-rendered strategy and for prerendered pages; client-side rendering is the exception.
| Strategy | First HTML response | SEO notes |
|---|---|---|
Complete HTML and metadata, created at build time. |
Best case: fast, cacheable, no server work. Use |
|
Complete static HTML, regenerated after a revalidation. |
As SSG, with content that follows the revalidation interval. Keep |
|
Complete HTML per request; metadata blocks or streams (see Streaming Metadata). |
Fine for personalized or fast-changing pages. Indexable content must not depend on a login or cookie. |
|
Static shell with the metadata, dynamic holes streamed later. |
Keep content that must be indexed in the static shell or in cached components, not in a Suspense hole that only fills for logged-in visitors. |
|
An empty shell; the content arrives after JavaScript runs. |
Weakest for SEO and sharing: social crawlers do not run JavaScript, so they never see client-set tags. Use CSR for authenticated or interactive areas, not for landing and catalogue pages. |
Two practical consequences follow. First, document.title = … or useEffect in a Client Component is not a
substitute for the Metadata API, since it runs after hydration (see
Hydration). Second, speed is part of SEO, because search engines take page experience (Core Web Vitals) into account:
the performance page of this section covers them, and so does the SEO fundamentals page for
the markup side (semantic HTML, headings, internal links, clean URLs). For the rendering modes themselves read
Rendering Fundamentals, and for the React ideas behind them
Server Rendering, SSG and React Server Components.
The Pages Router and next/head
The Pages Router has no Metadata API. Tags are set with the Head component from next/head, in a page, inside
_app or in components. This was built in the scratch app as pages/en/legacy.tsx:
import Head from 'next/head'
export default function Legacy() {
return (
<>
<Head>
<title>Legacy | Bookshelf</title>
<meta name="description" content="Pages Router page" />
<link rel="canonical" href="https://bookshelf.example.com/legacy" />
</Head>
<h1>Pages Router</h1>
</>
)
}
The prerendered HTML contains the tags with a data-next-head attribute, which Next.js uses to manage them during
client-side navigations. Rules that cause most bugs: give duplicate tags the same key (the last one wins), keep the
tags as direct children of Head (or one fragment level), do not set attributes of <html> or <body> with it, and
remember that its contents are cleared when the component unmounts. Scripts belong in next/script. Head content
is rendered where the page is rendered, so it follows getStaticProps/getServerSideProps and is complete in the
first HTML. For sitemaps and robots.txt the Pages Router has no convention: use a static file in public/, or a
page or API route that returns XML or text.
Source: Pages Router: Head
Why next/head and next-seo Are Legacy in the App Router
next/head does not work in the app directory. The migration guide states that in the App Router it "is replaced"
by the built-in metadata support, and moving a page means converting each <Head> into a metadata object or a
generateMetadata function. The reasons are structural:
-
Metadata is resolved on the server before the page renders, merged across layouts, and deduplicated by field instead of by
key. -
It is typed (
Metadata), so a misspelled field is a compile error, not a silently missing tag. -
It can be streamed, cached and validated by the framework, which free-form JSX cannot.
-
File conventions replace the hand-made
<link rel="icon">,og:imageand sitemap plumbing.
React 19 also renders <title>, <meta> and <link> elements placed anywhere in a component tree and hoists them to
<head>. That is a React feature that works, but in a Next.js App Router project the documented, merge-aware way is
still the Metadata API; mixing both makes tags harder to reason about.
next-seo was the usual way to get typed SEO components in the Pages Router (NextSeo, DefaultSeo, JSON-LD
components). Its current line (7.3.0 on npm in 2026-10) is oriented to the App Router and provides JSON-LD components
(for example ArticleJsonLd), while the NextSeo component for the Pages Router is documented separately. For
meta tags, titles, canonical links and Open Graph in the App Router it duplicates the Metadata API, so a new project
does not need it for them. For JSON-LD it is optional: a ten-line component, as shown above, does the same without a
dependency. Keep it only if an existing Pages Router codebase relies on it, and plan the move to the Metadata API when
you migrate to the App Router.
Verifying Your Metadata
Check what crawlers receive, not what the browser shows after hydration. A few commands are enough:
# the head of a page, as a crawler without JavaScript sees it
curl -s -A 'Twitterbot/1.0' http://localhost:3000/en/books/1 | grep -oE '<(title|meta|link)[^>]*>'
# generated files
curl -s http://localhost:3000/sitemap.xml
curl -s http://localhost:3000/robots.txt
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' http://localhost:3000/en/books/1/opengraph-image
Always test against next build && next start, because next dev renders every route on demand and does not show the
build-time resolution. Use the browser’s developer tools to inspect <head> after hydration, Google’s Rich Results
Test for structured data, and the sharing debuggers of the social networks for cards (they cache aggressively, so
re-scrape after a change).
Source: Metadata and OG images and generateMetadata
Pitfalls
| Symptom | Cause and fix |
|---|---|
|
Metadata exports are Server Component only. Keep |
The Open Graph image URL starts with |
|
A nested |
Nested fields are overwritten, not merged. Repeat or spread the shared values. |
|
It applies to child segments only, requires |
|
The proxy matcher catches metadata files. Exclude them in |
|
|
Facebook or X shows no card, but Google shows the title |
The crawler reads only the first HTML. Check that its user agent is in |
Every request renders the OG image |
The file reads |
Pages are indexed that should not be |
|
Metadata set in a |
It exists only after hydration. Use the Metadata API. |
What Changed Recently
| Version | Change |
|---|---|
13.2 |
|
13.3 |
File conventions introduced: |
13.4.14 |
|
14.0 |
|
14.2 |
Localized sitemaps ( |
15.2 |
Streaming metadata for dynamic pages; bots that need metadata in the initial HTML are detected by user agent (list
configurable with |
16.0 |
|
In 16.x the documentation also describes generateMetadata and generateViewport under Cache Components, including
the blocking-prerender-metadata-runtime, blocking-prerender-metadata-dynamic and
blocking-prerender-viewport-runtime errors and the dynamic marker pattern used above.
Where the Books Are Outdated
-
Real-World Next.js (Packt, 2022, Next.js 12, Pages Router) sets titles and Open Graph tags with
next/head, which was the only option then. Everything it shows works in the Pages Router today, but in the App Router it is replaced by the Metadata API, and the book has nothing on file-based metadata,sitemap,robotsormanifest. -
Practical Next.js for E-Commerce (Apress, 2023, Next.js 13.2) skips the App Router, mentions
head.jsas the first App Router option and notes its deprecation in 13.2, and claims thatgenerateMetadataworks only with TypeScript and only "for server-based components, not for pages". Both claims are wrong today:generateMetadatais plain JavaScript or TypeScript, works in anypage.jsorlayout.jsof the App Router, and is the standard approach;head.jsis not part of the current API. The book builds SEO with the next-seo plug-in on the Pages Router; the book itself notes that it did not have room to cover a sitemap, which Next.js now generates fromsitemap.ts. -
Neither book covers streaming metadata,
htmlLimitedBots,generateViewport,ImageResponse-generated cards or metadata under Cache Components.
Summary
-
Use
metadatafor static values andgenerateMetadatafor data-dependent ones; both are Server Component only. -
Set
metadataBaseonce, definetitle.defaultandtitle.templatein the root layout, and give each page a unique title, description and canonical URL. -
Put
themeColorandcolorSchemeinviewport; it is never streamed. -
Let file conventions create icons, Open Graph images,
sitemap.xml,robots.txtand the manifest; mind the proxy matcher andgenerateStaticParamsfor generated images. -
Render JSON-LD with an escaped
<script>, and makealternates.languagesreciprocal. -
Rely on the first HTML response for SEO: prefer SSG, ISR, SSR or partial prerendering over CSR for indexable pages.
-
In the App Router,
next/headdoes not apply and next-seo is not needed for tags; the Pages Router still usesnext/head.