# Next.js SEO: metadata, rendering, sitemaps and canonicals

> Next.js SEO is mostly configuration: the framework renders on the server by default, so the work is setting metadata, canonicals, sitemaps and status codes correctly per route. What to set in the App Router and the pitfalls that undo it.

Updated 2026-09-26 · Technical SEO · HTML version: https://getreport.app/guides/nextjs-seo

Next.js SEO is mostly a matter of configuration. The framework renders pages on the server or at build time by default, so crawlers get complete HTML; your job is to set the title, description, canonical and robots tags per route with the Metadata API, generate a sitemap and robots.txt, answer missing pages with a real `404`, and avoid the few patterns that move content back into the browser. This guide covers the App Router in Next.js 15 and 16, with notes for the older Pages Router. It sits under our guide to [JavaScript SEO for sites built with frameworks](https://getreport.app/guides/javascript-seo), which explains why server-rendered HTML matters for Google and AI crawlers alike.

## Quick answer

- **Keep indexable data on the server.** Fetch it in Server Components, not in a `useEffect` in a Client Component.
- **Set metadata with `export const metadata` or `generateMetadata`,** plus `metadataBase` in the root layout so relative URLs become absolute.
- **Give every route a self-referencing canonical** with `alternates.canonical`, and hreflang with `alternates.languages` if you have language versions.
- **Generate `sitemap.xml` and `robots.txt`** from `app/sitemap.ts` and `app/robots.ts`.
- **Call `notFound()` for missing records** and use `permanentRedirect()` or `redirects` in `next.config` for moved pages.
- **Know about streaming metadata** (Next.js 15.2 and later): readers that do not run JavaScript and are not on Next.js's bot list get the tags at the end of the body.
- Check a route with the free [JavaScript SEO check](https://getreport.app/tools/js-rendering-check) to confirm the raw HTML carries the tags and content.

## How Next.js renders pages

In the App Router, every component is a Server Component unless its file starts with `'use client'`. Server Components render to HTML on the server, either at build time (static) or per request (dynamic), and Next.js decides which from what the route uses. A page that reads `cookies()`, `headers()` or `searchParams`, or fetches uncached data, is rendered per request; one that does not can be prerendered.

Two points often cause confusion:

- **Client Components are still rendered on the server.** `'use client'` marks a component for hydration in the browser, but its first render happens on the server too. A product description passed as props to a Client Component is in the HTML.
- **Data fetched in the browser is not.** If a Client Component loads its content with `fetch` inside `useEffect`, or through a client data library on mount, the server HTML contains only the loading state. The same happens with `dynamic(() => import('./Reviews'), { ssr: false })`, which leaves the component out of the server render entirely.

So the rule is simple: whatever should rank is fetched on the server and rendered into the HTML. Interactive parts can hydrate on top. Our comparison of [server-side and client-side rendering](https://getreport.app/guides/ssr-vs-csr-seo) explains the trade-offs behind each mode, and the [React SEO guide](https://getreport.app/guides/react-seo) covers React apps outside Next.js.

## Metadata API: titles, descriptions and robots

Next.js writes the head from the `metadata` object or the `generateMetadata` function a layout or page exports. Values from nested layouts merge, with the page winning.

```tsx
// app/layout.tsx
import type { Metadata } from 'next';

export const metadata: Metadata = {
  metadataBase: new URL('https://example-shop.hr'),
  title: { default: 'Example Shop', template: '%s | Example Shop' },
  description: 'Handmade shoes from Zagreb, shipped across the EU.',
};
```

```tsx
// app/products/[slug]/page.tsx
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getProduct } from '@/lib/products';

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}): Promise<Metadata> {
  const { slug } = await params;
  const product = await getProduct(slug);
  if (!product) notFound();
  return {
    title: product.name,
    description: product.summary,
    alternates: { canonical: `/products/${slug}/` },
    openGraph: { images: product.image },
  };
}
```

With `metadataBase` set, `/products/blue-shoe/` becomes `https://example-shop.hr/products/blue-shoe/` in the canonical and Open Graph tags. Without it, a relative path in a URL field causes a build error, so set it once in the root layout rather than repeating the host in every page.

For pages that should stay out of search, set `robots: { index: false }` in their metadata. That writes `<meta name="robots" content="noindex">` into the server HTML, where Google reads it before rendering. Never add `noindex` from a Client Component: our [noindex and nofollow reference](https://getreport.app/guides/noindex-nofollow-complete-reference) explains why a robots tag changed by script is unreliable.

> **Tip:**
> Use `generateMetadata` and the page component with the same data function. Next.js deduplicates identical `fetch` requests during one render, and React's `cache()` does the same for database calls, so the product is loaded once.

## Streaming metadata and bots

Since Next.js 15.2, `generateMetadata` can be streamed. When it has to wait for data at request time, Next.js sends the page first and, according to the [generateMetadata documentation](https://nextjs.org/docs/app/api-reference/functions/generate-metadata), appends the resulting tags to the `<body>` once they resolve. Next.js says it has verified that Googlebot, which renders JavaScript and reads the full DOM, interprets them correctly.

For "HTML-limited bots" that cannot run JavaScript, Next.js blocks instead and puts the tags in the `<head>`. It detects them by User-Agent. At the time of writing, the default list includes Bingbot, Applebot, DuckDuckBot, Yandex, Baidu, Google's special-purpose crawlers, and the preview bots of Facebook, X, LinkedIn, Slack, Discord and WhatsApp. It does not include the AI crawlers such as GPTBot, ClaudeBot or PerplexityBot, which, as the [JavaScript SEO guide](https://getreport.app/guides/javascript-seo) explains, read only the raw HTML.

If AI assistants matter to you, override the list in `next.config.ts` with the `htmlLimitedBots` option. A regular expression that matches everything turns streaming metadata off:

```ts
// next.config.ts
import type { NextConfig } from 'next';

const config: NextConfig = {
  htmlLimitedBots: /.*/,
};

export default config;
```

That costs some time to first byte on dynamic pages, because the head waits for the data. Statically rendered pages are not affected, since their metadata is resolved at build time.

## Canonicals and hreflang

Every indexable route should carry a canonical that points at itself: the clean URL, without tracking parameters, with the same trailing-slash choice as the rest of the site. Set it with `alternates.canonical`. Keep `trailingSlash` in `next.config` consistent with your canonicals, because Next.js redirects the other form automatically.

For language versions, `alternates.languages` writes the hreflang tags:

```ts
alternates: {
  canonical: '/de/produkte/blauer-schuh/',
  languages: {
    en: '/en/products/blue-shoe/',
    de: '/de/produkte/blauer-schuh/',
    'x-default': '/en/products/blue-shoe/',
  },
},
```

Each language version should list the complete set, itself included, and its canonical should point at itself, not at the English page. The guide to [hreflang and canonical tags together](https://getreport.app/guides/hreflang-and-canonical-tags) explains why.

## Sitemap and robots.txt

Next.js turns special files in the `app` folder into routes:

```ts
// app/sitemap.ts
import type { MetadataRoute } from 'next';
import { getAllProducts } from '@/lib/products';

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const products = await getAllProducts();
  return products.map((p) => ({
    url: `https://example-shop.hr/products/${p.slug}/`,
    lastModified: p.updatedAt,
  }));
}
```

```ts
// app/robots.ts
import type { MetadataRoute } from 'next';

export default function robots(): MetadataRoute.Robots {
  return {
    rules: { userAgent: '*', allow: '/', disallow: '/account/' },
    sitemap: 'https://example-shop.hr/sitemap.xml',
  };
}
```

A sitemap file may list at most 50,000 URLs. For larger sites, `generateSitemaps` splits the output into several files. Use real modification dates for `lastModified`, not the build time, as our note on [sitemap lastmod](https://getreport.app/guides/sitemap-lastmod-when-to-set-it) explains. Do not disallow `/_next/` in robots.txt: Google needs the scripts and styles there to render the page.

## Status codes and redirects

**Missing pages.** Call `notFound()` from `next/navigation` when a record does not exist. It renders your `not-found.tsx` and adds a `noindex` meta tag. The status is `404` as long as streaming has not started; if a `loading.tsx` boundary has already sent part of the page, the status stays `200` and only the `noindex` protects you. Resolve the record before any `Suspense` boundary, for example in `generateMetadata` as above, so missing pages answer a true `404`.

**Moved pages.** For permanent moves, use `redirects()` in `next.config` with `permanent: true`, or `permanentRedirect()` in a Server Component. Both answer `308`, which Google treats like `301`. `redirect()` answers `307`, a temporary redirect. As with `notFound()`, a redirect thrown after streaming has started becomes a client-side redirect through a meta tag, so redirect before the page starts rendering. Our [301 redirects guide](https://getreport.app/guides/301-redirects) covers the choice between codes.

## Links, images and performance

- **Links.** `next/link` renders a normal `<a href>`, so Google can follow it. Do not replace it with `router.push()` in an `onClick` on a `<div>`.
- **Images.** `next/image` requires `alt`; write real descriptions for content images and `alt=""` for decorative ones. Load the LCP image eagerly: `priority` in Next.js 15, and `loading="eager"` or `fetchPriority="high"` in Next.js 16, where `priority` is deprecated.
- **Scripts.** Load third-party tags with `next/script` and `strategy="lazyOnload"` or `afterInteractive` so they do not block rendering.
- **Headers and caching.** Static routes are served from the cache; our guide to [headers and caching on static sites and Next.js](https://getreport.app/guides/netlify-and-vercel-headers-and-caching) covers the settings.

## Pages Router notes

Older apps in the `pages` folder render on the server with `getServerSideProps` or at build time with `getStaticProps`. Set tags with `next/head` inside the page. Return `{ notFound: true }` from either data function for a `404`, and `{ redirect: { destination, permanent: true } }` for a `308`. The same rule applies: data fetched in `useEffect` is not in the HTML.

## How to check a Next.js site

> **Free tool:** [JavaScript rendering checker: raw vs rendered HTML](https://getreport.app/tools/js-rendering-check): Free JavaScript rendering checker: compare raw HTML with the rendered page and see which text, links, tags and structured data appear only after scripts run.

The check fetches the route as raw HTML, then renders it in Chromium, and compares title, description, canonical, robots, headings, links, word count and JSON-LD. On a healthy Next.js page the two columns match. A title missing from the raw column usually means streamed metadata; text missing from it usually means client-side fetching or `ssr: false`. The full report runs the same comparison:

> **Check: JavaScript leaves the title, canonical and robots tags alone.** Search engines read the HTML first and the rendered page later, if at all. A canonical or noindex that JavaScript changes sends two different instructions, and Google may act on either.
>
> 1. Put the final title, description, canonical and robots tags in the HTML the server sends.
> 2. In a single-page app, set them on the server for each route (Next.js metadata, Nuxt useHead with SSR, Angular Universal).
> 3. Never add noindex with JavaScript to a page you want indexed; Google may drop it before the script runs.

> **Check: Canonical tag points to a valid URL.** The canonical tag asks Google to index that URL instead of this one. If it points to another site, appears twice or leads to an error page, your ranking signals are given away or ignored.
>
> 1. Point the canonical at this page's own clean URL (same scheme and host, no tracking parameters), and make sure it returns 200.
> 2. Keep exactly one canonical tag; if a plugin and the theme both add one, disable one of them.

Also request an unknown URL such as `/products/does-not-exist/` with `curl -I` and confirm it answers `404`, and open `/sitemap.xml` and `/robots.txt` on production, not only in development.

## Common mistakes

- **Hard-coding the staging host.** Canonicals and `og:image` URLs built from an environment variable point at the preview domain on production. Set `metadataBase` from the production URL.
- **Every page canonicalised to the home page** because the canonical was set once in the root layout.
- **Content loaded in `useEffect`** on product or article pages.
- **`ssr: false` on the main content** to silence a hydration warning.
- **`noindex` left on from staging,** set in the root layout by an environment check that production also matched.
- **Loading boundaries above missing-record checks,** so unknown URLs answer `200`.

## Questions people ask

### Is Next.js good for SEO?

Yes. Next.js renders pages on the server or at build time by default, so crawlers receive complete HTML with the title, text, links and structured data already in place. It also has a Metadata API for head tags and file-based `sitemap.ts` and `robots.ts`. SEO problems in Next.js apps usually come from content fetched in the browser, missing canonicals or status codes, not from the framework.

### Does Next.js generate a sitemap automatically?

No, but it makes one easy. Add `app/sitemap.ts` that returns a list of URLs with modification dates, and Next.js serves it at `/sitemap.xml`. For sites with more than 50,000 URLs, `generateSitemaps` splits the list into several files. A static `sitemap.xml` in the `app` folder works too. Pair it with `app/robots.ts` so robots.txt points at the sitemap.

### Does 'use client' hurt SEO in Next.js?

No. Client Components are rendered on the server first and hydrated in the browser, so their output is in the HTML. What hurts is data a Client Component fetches after it mounts, for example in `useEffect`, and components loaded with `ssr: false`: neither is in the server HTML. Fetch indexable content in a Server Component and pass it down as props.
