Overview

Pages and layouts export a metadata object or a generateMetadata function, and Next.js renders the <head> tags. Both exports work only in Server Components. There is no <Head> component and no hand-written <title> in the App Router. The same system covers Open Graph and Twitter cards, icons, robots.txt, and sitemaps.

Export metadata and set a site-wide template

Layouts supply defaults and child segments override them. Set metadataBase once in the root layout; every relative URL (canonical, OG image) resolves against it.

// app/layout.tsx
export const metadata: Metadata = {
  metadataBase: new URL("https://example.com"),
  title: { template: "%s | Example", default: "Example" },
};

Generate metadata from the same data as the page

generateMetadata runs on the server and may await loaders. params is a Promise. Wrap shared loaders in React cache() so the page and generateMetadata run one query. Set alternates.canonical on every page; Next.js does not infer one.

export async function generateMetadata(
  { params }: { params: Promise<{ slug: string }> },
): Promise<Metadata> {
  const post = await getPost((await params).slug);
  return {
    title: post.title,
    description: post.summary,
    alternates: { canonical: `/posts/${post.slug}` },
    openGraph: { title: post.title, images: [post.cover], type: "article" },
    twitter: { card: "summary_large_image" },
  };
}

On dynamically rendered pages, metadata streams after the shell, but bots such as Twitterbot, Slackbot, and Bingbot get blocking metadata in <head>; adjust the user-agent list with htmlLimitedBots. With Cache Components enabled, reading runtime data or uncached fetches in generateMetadata raises an error: cache the data with "use cache", or add a dynamic marker component to the page. Viewport settings live in a separate viewport or generateViewport export.

Generate OG images with next/og

Place opengraph-image.tsx (or twitter-image.tsx) in the route folder. It returns an ImageResponse, which converts JSX and CSS to PNG. Only flexbox and a CSS subset work; display: grid does not. In Next.js 16 params is a Promise here too. See og-images for layout rules.

// app/posts/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
 
export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
  const post = await getPost((await params).slug);
  return new ImageResponse(<div style={{ display: "flex", fontSize: 64 }}>{post.title}</div>, size);
}

Name icon files by convention

favicon.ico, icon.png, icon.svg, and apple-icon.png in app/ become the right <link> tags. Export an icon.tsx that returns an ImageResponse for dynamic icons.

Ship sitemap.ts and robots.ts

app/sitemap.ts returns MetadataRoute.Sitemap entries built from the same data as your pages. A single sitemap holds at most 50,000 URLs; for more, export generateSitemaps and split by id, which is a Promise<string> in Next.js 16. app/robots.ts returns the robots.txt rules and a sitemap URL.

// app/robots.ts
export default function robots(): MetadataRoute.Robots {
  return {
    rules: [{ userAgent: "*", allow: "/", disallow: ["/admin"] }],
    sitemap: "https://example.com/sitemap.xml",
  };
}

Serve llms-txt and ai-txt from public/ or a route handler.

Escape JSON-LD before rendering it

Render structured data as a <script type="application/ld+json"> in the page body. JSON.stringify does not guard against XSS, so replace < with its Unicode escape.

<script type="application/ld+json"
  dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }} />

See technical and structured-data.