# Next.js SEO

You are working on search and sharing for a Next.js 16 App Router project. Write metadata, metadata files and markup for the version in `package.json`, not the one in your training data: the Metadata API and file conventions replace hand-written `<head>` tags, and several props and signatures changed in Next.js 16.

## Before you write code

- Read `node_modules/next/dist/docs/01-app/01-getting-started/14-metadata-and-og-images.md`, `03-api-reference/04-functions/generate-metadata.md`, `03-api-reference/03-file-conventions/01-metadata/` and `02-guides/json-ld.md`.
- Check the root layout for `metadataBase`, the title template and the shared `openGraph`; `next.config.ts` for `trailingSlash`, `redirects` and `htmlLimitedBots`; `app/` for `sitemap.ts`, `robots.ts`, `opengraph-image.*` and `icon.*`.
- Find the production URL (an env var such as `NEXT_PUBLIC_SITE_URL`) and the URL form the site uses (trailing slash or not). Every absolute URL you write uses both.

## Metadata API

- Export `metadata` (static) or `generateMetadata` (data-dependent) from Server Component layouts and pages only: never both in one segment, never from a Client Component, never hand-written `<title>` or `<meta>` tags.
- Root layout: `metadataBase: new URL(siteUrl)`, `title: { default: "Brand", template: "%s | Brand" }`, a default `description`, `openGraph` with `siteName`, `type` and `locale`, and `twitter: { card: "summary_large_image" }`. Pages set `title` and `description`; use `title.absolute` for a title that should skip a parent's template (a template never applies to the `page.tsx` beside its layout, so the home page needs no override).
- Metadata merges shallowly: a page that sets `openGraph` replaces the parent's whole object. Spread a shared base (`{ ...baseOpenGraph, title }`) or leave `openGraph` out: an `openGraph` without a title or description inherits the page's, and `twitter` fills itself from `openGraph`.
- In `generateMetadata`, await `params`, and read data through the same `cache()`-wrapped function the page uses, so it loads once. Call `notFound()` there too.
- With Cache Components, `generateMetadata` that reads uncached data on an otherwise prerenderable page is an error: add `"use cache"` to it (cached metadata must be serializable: `metadataBase` as a string).
- `themeColor` and `colorScheme` go in `export const viewport`, not in `metadata`.

```tsx
export async function generateMetadata(props: PageProps<"/blog/[slug]">): Promise<Metadata> {
  const { slug } = await props.params;
  const post = await getPost(slug); // cache()-wrapped, shared with the page
  if (!post) notFound();
  return {
    alternates: { canonical: `/blog/${post.slug}` },
    description: post.excerpt,
    openGraph: { ...baseOpenGraph, publishedTime: post.publishedAt, type: "article" },
    title: post.title,
  };
}
```

## Titles, descriptions and canonical URLs

- Titles about 60 characters including the template's suffix, the page's subject first. Descriptions about 155 characters, whole sentences saying what the page offers. Unique on every indexable page.
- Set `alternates.canonical` on every indexable page as a path relative to `metadataBase`, without tracking or sort parameters. Paginated pages canonicalize to themselves, not to page 1.
- Pick one URL form. Next.js redirects `/about/` to `/about` unless `trailingSlash: true`. Metadata URLs follow that setting; URLs you build by hand (sitemap, JSON-LD, links) must match it too.
- Filtered, sorted, internal search, cart, account and thank-you pages get `robots: { follow: true, index: false }`. Keep them crawlable: a page blocked in robots.txt never shows its noindex.
- Resolve records and call `notFound()` before the first Suspense boundary, so the status is a real 404. When it fires inside streamed content, Next.js can only add a `noindex` meta to a 200.

## Social images

- Use the file conventions: `opengraph-image.tsx` (plus `twitter-image.tsx` only if it differs) in the segment, exporting `alt`, `size = { width: 1200, height: 630 }` and `contentType = "image/png"`, returning `new ImageResponse(jsx, { ...size, fonts })` from `next/og`. A deeper segment's image overrides its parent's.
- The image function receives `params` as a promise in Next.js 16; with `generateImageMetadata`, `id` is a promise too.
- `ImageResponse` renders flexbox and a subset of CSS (no grid), takes `ttf`, `otf` or `woff` fonts (not `woff2`), and caps the bundle at 500 KB. Read fonts from the project with `readFile`.
- Static `opengraph-image.png` files work too: under 8 MB (5 MB for `twitter-image`), with an `opengraph-image.alt.txt`.

## Sitemaps, robots and JSON-LD

```ts
// app/sitemap.ts
import type { MetadataRoute } from "next";

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await getPublishedPosts();
  return posts.map((post) => ({ lastModified: post.updatedAt, url: `${SITE_URL}/blog/${post.slug}` }));
}
```

- List only canonical, indexable URLs that return 200: no redirects, noindex pages or parameters. Set `lastModified` from the content's real date, never `new Date()`. Google ignores `changeFrequency` and `priority`.
- Past 50,000 URLs, split with `generateSitemaps()`. The default export then receives `id` as a `Promise<string>` in Next.js 16: `const id = Number(await props.id)`. Each file is served at `/…/sitemap/[id].xml` and Next.js writes no index, so list them in `robots.ts`'s `sitemap` array.
- `app/robots.ts` returns `{ rules: { allow: "/", disallow: [...], userAgent: "*" }, sitemap }`. Disallow only what must not be crawled (API routes, admin); robots.txt does not remove pages from the index. Disallow everything on preview deployments.
- JSON-LD: a native `<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, "\\u003c") }} />` in the page or layout, not `next/script`. Type it with `schema-dts` if the project has it. Describe only what the page shows: `Organization` and `WebSite` in the root layout; `BreadcrumbList`, `Article`, `Product` where they fit.

## Redirects

- Moved URLs get permanent redirects: `redirects()` in `next.config.ts` with `permanent: true` (308) for known paths, `permanentRedirect()` in a page when a slug changed. `redirect()` (307, or 303 after a form post) is for temporary moves. Large, data-driven maps go in `proxy.ts` with a lookup.
- Redirect in one hop to the final canonical URL. Never redirect removed content to the home page: return a 404.

## Rendering, bots and Core Web Vitals

- Content that should rank renders on the server: text, headings, links. Never load primary content in `useEffect`. Internal links use `<Link href>`, so crawlers see real anchors.
- Streaming metadata: on dynamic pages Next.js may send metadata after the first bytes; HTML-limited bots (detected by user agent) get it in the `<head>`. Keep the default; extend `htmlLimitedBots` only for a crawler that misses tags.
- LCP: the hero `next/image` gets `fetchPriority="high"` (or `preload` when it is the LCP element at every viewport); `priority` is deprecated in Next.js 16. Never lazy-load it. Every image has `width` and `height`, or `fill` with `sizes`.
- Fonts come from `next/font`, self-hosted with fallback metrics that prevent layout shift, applied once on `<html>`.
- CLS: reserve space for images, embeds and ads, and make Suspense fallbacks the size of what replaces them.
- INP: keep Client Components small, wrap heavy updates in `useTransition`, and load third-party scripts with `next/script` and a lazy `strategy`.
- Report field data with `useReportWebVitals` from `next/web-vitals` in a small `"use client"` component in the root layout, with a stable callback. Aim, at the 75th percentile, for LCP under 2.5 s, INP under 200 ms, CLS under 0.1.

## Internationalization

- Locale routes live under `app/[lang]/`, prerendered with `generateStaticParams`, with `<html lang={lang}>`.
- Every localized page lists all its versions, itself included, in `alternates.languages` (`{ en: "/en/pricing", fr: "/fr/pricing", "x-default": "/en/pricing" }`) and canonicalizes to its own locale. Mirror the alternates in the sitemap.
- Translate titles, descriptions and Open Graph text per locale. Detect the locale in `proxy.ts` only for the bare `/`; never redirect a locale URL by `Accept-Language` or IP, or crawlers never reach the other versions.

## Do not

- Do not use `next/head`, `next-seo` or hand-written meta tags in the App Router.
- Do not use `priority` on `next/image`, read `params` synchronously in metadata or image routes, or use the sitemap `id` without awaiting it.
- Do not put `themeColor` or `viewport` in `metadata`, stuff `keywords`, or point every page's canonical at the home page.
- Do not block a page in robots.txt to remove it from search, or list a noindex page in the sitemap.
- Do not skip escaping `<` in JSON-LD, or mark up content the page doesn't show.

## Before you finish

- Run `next build` and read its route table: pages meant to be static are, and no metadata error is reported.
- Check the served HTML, not the live DOM: `curl -s <url> | grep -E "<title|canonical|og:|robots"`, and again with a crawler user agent such as `facebookexternalhit` on streamed pages.
- Open `/sitemap.xml`, `/robots.txt` and each image route. Validate JSON-LD with the Rich Results Test and run Lighthouse on each main template.
- Confirm every title and description is unique and within length, and every canonical returns 200.
