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.

Table 1. Ways to define metadata in the App Router
Mechanism Use it for Where it runs

The metadata object (static)

Values known at build time: site name, default description, robots rules, icons.

Exported from a layout.tsx or page.tsx; resolved at build time for prerendered routes.

The generateMetadata function

Values that depend on route params, search params or data: a book title, a canonical URL per locale.

Exported from a layout.tsx or page.tsx; runs on the server, at build time or per request.

File conventions

Icons, Open Graph and Twitter images, sitemap.xml, robots.txt, manifest.webmanifest, either as static files or as .ts/.tsx files that generate them.

Static files are served as they are; generated ones are special Route Handlers.

Four rules hold for all of them:

  • metadata and generateMetadata work only in Server Components. Exporting them from a file that starts with 'use client' fails the build with You 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 metadata and generateMetadata.

  • 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 metadata object and generateMetadata.

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.

app/[locale]/layout.tsx: site-wide metadata and viewport
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:

  • default is the fallback for child segments that define no title; it is required when you set a template.

  • 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 a page.tsx has no effect at all.

  • absolute ignores 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.

app/[locale]/books/[id]/page.tsx: generateMetadata for a book
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' },
  }
}

getBook is wrapped in React’s cache, so the page, generateMetadata and the image generator below share one lookup per request instead of three:

lib/books.ts: one lookup shared by metadata, page and image
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:

Output: the head of a prerendered book page
<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.

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:

app/[locale]/vp/page.tsx: a dynamic viewport
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.

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.

flowchart TD R[Request for a dynamic page] --> UA{User-Agent matches
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:

app/[locale]/live/page.tsx: slow, request-dependent metadata (previous model)
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:

Output: the same URL for a browser, Twitterbot and Googlebot
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:

next.config.ts: block on metadata for every visitor
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 /live page above reads searchParams in 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-based generateMetadata fails 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>:

A cached generateMetadata (Cache Components)
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:

A dynamic marker for intentionally request-time metadata (Cache Components)
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.

Table 2. File conventions for metadata (App Router)
File Result Notes

favicon.ico

<link rel="icon">

Only in the root of app; it cannot be generated with code. Use icon for finer control.

icon.(ico|jpg|png|svg) or icon.(js|ts|tsx), apple-icon.*

<link rel="icon">, <link rel="apple-touch-icon">

May appear in any segment. Number suffixes (icon1.png, icon2.png) add several icons, sorted lexically.

opengraph-image., twitter-image., with optional .alt.txt

og:image and twitter:image with type, width, height and alt

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).

sitemap.(xml|js|ts)

/sitemap.xml

Static XML or a function returning URLs; generateSitemaps splits large sets.

robots.(txt|js|ts)

/robots.txt

Root of app only.

manifest.(json|webmanifest|js|ts)

/manifest.webmanifest and <link rel="manifest">

Root of app only.

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:

app/icon.tsx: a generated 32 by 32 icon
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.

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.

app/[locale]/books/[id]/opengraph-image.tsx: one card per book
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:

Output: the OG image routes are prerendered only because of generateStaticParams
├   /[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.

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:

app/sitemap.ts: a localized sitemap
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:

Output: one entry of /sitemap.xml
<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:

app/books/sitemap.ts: split sitemaps (two entries per file for the demo)
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.

robots.ts

robots.ts returns the rules of the Robots Exclusion Standard and the sitemap location:

app/robots.ts
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:

app/manifest.ts
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:

proxy.ts: exclude extension-less metadata routes
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:

components/json-ld.tsx
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'),
      }}
    />
  )
}
app/[locale]/books/[id]/book-detail.tsx: a schema.org Book with an Offer
<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:

Output: the JSON-LD in the book page
<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:

  • canonical is the preferred URL of the content. Set it whenever the same page is reachable under several URLs (query strings, trailing slashes, www and non-www, filter parameters). In the book example it is the page’s own localized path.

  • languages lists the translations as hreflang alternates, including every language once and, by convention, an x-default entry for visitors whose language is not covered. The set must be reciprocal: each translation lists all the others (and itself), which the Object.fromEntries(locales.map(…​)) in the book example guarantees.

  • media and types add <link rel="alternate"> for mobile URLs and feeds such as RSS.

Table 3. Rules of thumb for canonical and alternates
Situation What to do

The same list is reachable as /en?q=sol and /en

Canonical points to /en; search results with free-text queries are usually also noindex.

A translated page with its own URL (/es/books/1)

Canonical points to itself, not to the English page; languages links the translations.

A page that exists only in one language

Omit languages rather than listing a missing translation.

URLs given as relative paths

Requires metadataBase; the build output above shows the absolute URLs.

A deleted book

Return robots: { index: false } in metadata; see the next paragraph.

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.

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.

Table 4. Rendering strategies and what a crawler sees
Strategy First HTML response SEO notes

Static generation (SSG)

Complete HTML and metadata, created at build time.

Best case: fast, cacheable, no server work. Use generateStaticParams for dynamic routes and sitemap entries for every generated URL.

Incremental static regeneration (ISR)

Complete static HTML, regenerated after a revalidation.

As SSG, with content that follows the revalidation interval. Keep lastModified in the sitemap honest.

Server-side rendering (SSR)

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.

Partial prerendering (Cache Components)

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.

Client-side rendering (CSR)

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:

pages/en/legacy.tsx: head tags in the Pages Router
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.

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:image and 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).

Pitfalls

Table 5. Common metadata and SEO mistakes
Symptom Cause and fix

You are attempting to export "metadata" from a component marked with "use client"

Metadata exports are Server Component only. Keep page.tsx a Server Component and move hooks and handlers to a child with 'use client'.

The Open Graph image URL starts with http://localhost:3000

metadataBase is not set; the build warns about it. Set it in the root layout.

A nested openGraph loses siteName on one page

Nested fields are overwritten, not merged. Repeat or spread the shared values.

title.template does nothing

It applies to child segments only, requires title.default, and has no effect in a page.tsx.

/icon or /robots.txt redirects to a locale path

The proxy matcher catches metadata files. Exclude them in config.matcher.

blocking-prerender-metadata-runtime or -dynamic during next build with Cache Components

generateMetadata reads runtime or uncached data while the page is otherwise static. Cache it with 'use cache', use a static export, or add a dynamic marker.

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 htmlLimitedBots (it is by default for the common ones), and that og:image is an absolute, public URL.

Every request renders the OG image

The file reads params without generateStaticParams; add it, or accept the cost and cache the response.

Pages are indexed that should not be

robots.txt blocks crawling, not indexing. Use robots: { index: false } on the page and keep the page crawlable so the tag is seen.

Metadata set in a useEffect is missing in previews

It exists only after hydration. Use the Metadata API.

What Changed Recently

Table 6. Metadata and SEO changes by version
Version Change

13.2

metadata and generateMetadata introduced (App Router).

13.3

File conventions introduced: favicon, icon, apple-icon, opengraph-image, twitter-image, sitemap, robots, manifest.

13.4.14

changeFrequency and priority added to the sitemap type.

14.0

viewport, themeColor and colorScheme moved from metadata to viewport and generateViewport; ImageResponse moved to next/og.

14.2

Localized sitemaps (alternates.languages in sitemap entries).

15.2

Streaming metadata for dynamic pages; bots that need metadata in the initial HTML are detected by user agent (list configurable with htmlLimitedBots).

16.0

params is a promise in opengraph-image, twitter-image, icon and apple-icon; id is a promise of a string in sitemap; cacheComponents changes how generateMetadata and generateViewport treat request-time data.

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, robots or manifest.

  • Practical Next.js for E-Commerce (Apress, 2023, Next.js 13.2) skips the App Router, mentions head.js as the first App Router option and notes its deprecation in 13.2, and claims that generateMetadata works only with TypeScript and only "for server-based components, not for pages". Both claims are wrong today: generateMetadata is plain JavaScript or TypeScript, works in any page.js or layout.js of the App Router, and is the standard approach; head.js is 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 from sitemap.ts.

  • Neither book covers streaming metadata, htmlLimitedBots, generateViewport, ImageResponse-generated cards or metadata under Cache Components.

Summary

  • Use metadata for static values and generateMetadata for data-dependent ones; both are Server Component only.

  • Set metadataBase once, define title.default and title.template in the root layout, and give each page a unique title, description and canonical URL.

  • Put themeColor and colorScheme in viewport; it is never streamed.

  • Let file conventions create icons, Open Graph images, sitemap.xml, robots.txt and the manifest; mind the proxy matcher and generateStaticParams for generated images.

  • Render JSON-LD with an escaped <script>, and make alternates.languages reciprocal.

  • 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/head does not apply and next-seo is not needed for tags; the Pages Router still uses next/head.