# Next.js SEO: the App Router guide to metadata, sitemaps and rendering

URL: https://serpel.app/blog/nextjs-seo

Updated: 2026-10-10

Next.js SEO comes down to using the App Router’s built-in features correctly: the Metadata API for titles, descriptions and canonicals, `sitemap.ts` and `robots.ts` for crawling, and prerendered or server-rendered HTML so every crawler sees your content. This guide shows working code for Next.js 15 and 16, the mistakes that cause most indexing problems and how to check the result on the live site.

## Key takeaways

- Next.js sends complete HTML for prerendered and server-rendered routes. The Metadata API, `sitemap.ts` and `robots.ts` cover most technical SEO.
- Set `metadataBase` once, define a title template in the root layout and canonicals per page. Page-level objects such as `openGraph` replace the layout’s object instead of merging with it.
- Prerender indexable pages with `generateStaticParams` so titles and canonicals sit in the HTML head for every crawler, and return a real 404 with `notFound()` for unknown slugs.
- Use `next/image` and `next/font` for Core Web Vitals, then check the live site with a crawl.

## Is Next.js good for SEO?

Yes, when you use it for what it is good at. In the App Router, pages are Server Components by default, and routes that do not need request-time data are prerendered at build time. The crawler then receives full HTML, with the content, links and metadata already in place. Google recommends [server-side rendering, static rendering or hydration](https://developers.google.com/search/docs/crawling-indexing/javascript/dynamic-rendering) over workarounds, and many other crawlers do not run scripts at all. Our guide to [JavaScript SEO](https://serpel.app/blog/javascript-seo) explains why.

Next.js does not do SEO for you. It gives you the tools, and the mistakes are usually configuration mistakes. The code below follows the Next.js 16 documentation. In Next.js 15 and 16, `params` is [a Promise](https://nextjs.org/docs/app/guides/upgrading/version-15), so you await it.

## How do you set titles, descriptions and canonicals with the Metadata API?

Export a `metadata` object for static values or a `generateMetadata` function for data-driven ones. Both are only supported in [Server Components](https://nextjs.org/docs/app/getting-started/metadata-and-og-images). Put the shared parts in the root layout.

```typescript
import type { Metadata } from 'next'

export const metadata: Metadata = {
  metadataBase: new URL('https://example.com'),
  title: {
    default: 'Example',
    template: '%s | Example',
  },
  description: 'Example builds invoicing software for freelancers.',
}

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  )
}
```

```typescript
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getPost } from '@/lib/posts'

type PageProps = { params: Promise<{ slug: string }> }

export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
  const { slug } = await params
  const post = await getPost(slug)

  if (!post) {
    return {}
  }

  return {
    title: post.title,
    description: post.summary,
    alternates: { canonical: `/blog/${slug}` },
    openGraph: { type: 'article', title: post.title, description: post.summary },
  }
}

export default async function Page({ params }: PageProps) {
  const { slug } = await params
  const post = await getPost(slug)

  if (!post) {
    notFound()
  }

  return <h1>{post.title}</h1>
}
```

- **Set metadataBase once.** It lets you use relative URLs in fields such as `alternates` and `openGraph`. Without it, a relative URL causes a build error, as the [generateMetadata reference](https://nextjs.org/docs/app/api-reference/functions/generate-metadata) explains.
- **A title template applies to child segments only.** `title.template` does not affect a title in a `page.tsx` next to the layout that defines it, and it needs a `title.default`.
- **Metadata is merged shallowly.** A nested object such as `openGraph` or `robots` defined in a page replaces the one from the layout. Repeat every field you still want.
- **A canonical in the root layout is inherited.** Every page without its own `alternates` would canonicalise to that URL. Set canonicals per page.
- **Share data fetching.** Wrap `getPost` in React’s `cache` so `generateMetadata` and the page do not fetch twice.

## How do you generate a sitemap and robots.txt in Next.js?

Add `app/sitemap.ts` and `app/robots.ts`. Both are special route handlers that are cached by default unless they use request-time APIs, according to the [sitemap](https://nextjs.org/docs/app/api-reference/file-conventions/metadata/sitemap) and [robots](https://nextjs.org/docs/app/api-reference/file-conventions/metadata/robots) references.

```typescript
import type { MetadataRoute } from 'next'
import { getAllPosts } from '@/lib/posts'

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await getAllPosts()

  return [
    { url: 'https://example.com', lastModified: '2026-10-01' },
    ...posts.map((post) => ({
      url: `https://example.com/blog/${post.slug}`,
      lastModified: post.updatedOn,
    })),
  ]
}
```

```typescript
import type { MetadataRoute } from 'next'

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

- **Use real dates.** Google [ignores the priority and changefreq values](https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap) and uses `<lastmod>` only if it is consistently and verifiably accurate. `new Date()` for every URL fails that test.
- **Stay within the limits.** A sitemap may hold 50,000 URLs or 50 MB uncompressed. Split larger sites with `generateSitemaps`.
- **List canonical, indexable URLs only.** Leave out redirects, `noindex` pages and error pages.
- **Do not use robots.txt to hide pages.** Google says it is [not a mechanism for keeping a page out of Google](https://developers.google.com/search/docs/crawling-indexing/robots/intro). Use `noindex` or authentication.

Run the generated file through the free [sitemap checker](https://serpel.app/tools/sitemap-checker), then submit the sitemap URL in Search Console. Google treats a submitted sitemap as a suggestion and does not guarantee it will download or use it, so strong internal links still matter.

## How do you add JSON-LD and Open Graph images?

Next.js recommends rendering [JSON-LD](https://nextjs.org/docs/app/guides/json-ld) as a plain `<script>` tag in a layout or page. `JSON.stringify` does not sanitise strings, so replace `<` with its unicode escape to prevent injection. Validate the result in Google’s Rich Results Test or with the free [schema validator](https://serpel.app/tools/schema-validator).

```typescript
import { notFound } from 'next/navigation'
import { getPost } from '@/lib/posts'

export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const post = await getPost(slug)

  if (!post) {
    notFound()
  }

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'BlogPosting',
    headline: post.title,
    datePublished: post.publishedOn,
    dateModified: post.updatedOn,
  }

  return (
    <article>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{
          __html: JSON.stringify(jsonLd).replace(/</g, '\\u003c'),
        }}
      />
      <h1>{post.title}</h1>
    </article>
  )
}
```

For social previews, add an [opengraph-image file](https://nextjs.org/docs/app/api-reference/file-conventions/metadata/opengraph-image) to a route segment. A static `.png` or `.jpg` works, and so does a `.tsx` file that returns an `ImageResponse`. Generated images are statically optimised at build time unless they use request-time APIs. A more specific segment overrides the one above it, so a file in `app/blog/[slug]` wins over the one in `app`.

```typescript
import { ImageResponse } from 'next/og'

export const alt = 'Example, invoicing for freelancers'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'

export default function Image() {
  return new ImageResponse(
    (
      <div
        style={{
          width: '100%',
          height: '100%',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
          fontSize: 96,
          background: 'white',
        }}
      >
        Example
      </div>
    ),
    { ...size },
  )
}
```

## Which rendering mode should each page use?

**Rendering choices in the App Router**
| Page type | Approach | Notes |
| --- | --- | --- |
| Blog, docs and marketing pages | Prerender at build with `generateStaticParams` | Complete HTML. With `dynamicParams = false`, unknown slugs return 404 |
| Large catalogue | Prerender a subset and render the rest on first visit | Return a partial list from `generateStaticParams`. Other pages are rendered when first requested, as [documented](https://nextjs.org/docs/app/api-reference/functions/generate-static-params) |
| Personalised pages such as carts and dashboards | Dynamic rendering | Keep them out of the sitemap and add `noindex` |
| Pages with a few dynamic parts | Cache Components with `<Suspense>` | With `cacheComponents: true`, static and cached content ships in the [initial HTML](https://nextjs.org/docs/app/getting-started/caching) and runtime data streams in behind fallbacks |

```typescript
import { getAllPosts } from '@/lib/posts'

export const dynamicParams = false

export async function generateStaticParams() {
  const posts = await getAllPosts()

  return posts.map((post) => ({ slug: post.slug }))
}
```

Watch metadata on dynamic pages. For dynamically rendered pages, Next.js [streams metadata](https://nextjs.org/docs/app/api-reference/functions/generate-metadata) and appends it to the body once `generateMetadata` resolves. Bots that run JavaScript, such as Googlebot, read it correctly. For crawlers on a built-in list of HTML-limited bots, which includes Bingbot, Twitterbot and Slackbot, the metadata still blocks rendering and lands in `<head>`. Prerendered pages resolve metadata at build time and do not stream. If you need metadata in the head for every crawler, set the [htmlLimitedBots option](https://nextjs.org/docs/app/api-reference/config/next-config-js/htmlLimitedBots) to `/.*/`. The documentation warns that overriding it can lead to longer response times.

## How do canonical URLs and hreflang work in Next.js?

Use `alternates.canonical` and `alternates.languages`. Google requires that each language version [lists itself and all other versions](https://developers.google.com/search/docs/specialty/international/localized-versions), with fully qualified URLs. Annotations without matching return links are ignored. `x-default` is the fallback for visitors who match no language. Set the same alternates on every version, and use `alternates.languages` in `sitemap.ts` if you prefer the sitemap method.

```typescript
import type { Metadata } from 'next'

export const metadata: Metadata = {
  alternates: {
    canonical: '/en/pricing',
    languages: {
      'en-GB': '/en/pricing',
      'de-DE': '/de/pricing',
      'x-default': '/en/pricing',
    },
  },
}
```

## How do you keep images and fonts from hurting Core Web Vitals?

`next/image` uses `width` and `height` to reserve space, which avoids layout shift. Images load lazily by default, so the hero image needs the opposite. Since Next.js 16 the `priority` prop is deprecated in favour of `preload`, but the documentation says that in most cases `loading="eager"` or `fetchPriority="high"` is the better choice. `next/font` downloads Google fonts at build time and self-hosts them, and `adjustFontFallback` is on by default to reduce layout shift. See our guide to the [Core Web Vitals test](https://serpel.app/blog/core-web-vitals-test) for the thresholds.

```typescript
import Image from 'next/image'
import { Inter } from 'next/font/google'

const inter = Inter({ subsets: ['latin'], display: 'swap' })

export default function Hero() {
  return (
    <section className={inter.className}>
      <h1>Invoices that send themselves</h1>
      <Image
        src="/hero.webp"
        alt="Product dashboard"
        width={1200}
        height={630}
        loading="eager"
        fetchPriority="high"
      />
    </section>
  )
}
```

## What are the most common Next.js SEO mistakes?

**Mistakes, effects and fixes**
| Mistake | Effect | Fix |
| --- | --- | --- |
| Fetching page content in `useEffect` inside a Client Component | The HTML is empty for crawlers that do not render | Fetch in a Server Component |
| Exporting `metadata` from a Client Component | Not supported | Move the export to a server `layout` or `page` |
| Overriding `openGraph` or `robots` in a page | Fields from the layout disappear | Repeat the full object or build it with a helper |
| A canonical in the root layout | Every page canonicalises to one URL | Set `alternates.canonical` per page |
| A staging `noindex` shipped to production | The site drops out of the index | Drive `robots` from an environment variable and check after each deploy |
| Unknown slugs render a “not found” message with 200 | [Soft 404](https://serpel.app/blog/soft-404) pages | Call `notFound()` before streaming starts |
| Navigation with `router.push` on a button | Crawlers find no link | Use `next/link` or `<a href>`, which Google can [crawl](https://developers.google.com/search/docs/crawling-indexing/links-crawlable) |
| `new Date()` as `lastModified` for every URL | Google stops trusting `lastmod` | Use the real update date |

## How does Serpel’s codebase scan map Next.js routes to live pages?

The Serpel CLI can link your source to what is live. `serpel scan` runs locally and needs `next` in your `package.json`. It reads the routes in `app/` and `pages/`, also under `src/`, and drops route groups such as `(marketing)`. Dynamic segments like `[slug]` and catch-all segments stay in the route. Parallel routes, intercepted routes and private folders are not routes. It also reads static metadata, meaning `title`, `description`, `alternates.canonical` and `robots` written as strings or template literals without expressions, and it applies `title.template` from parent layouts. Anything computed, including `generateMetadata`, counts as unknown unless build output exists. If a `.next` or `out` folder holds HTML files, the scan also uses their title, description, headings and structured data types.

The scan never reads `.env` files, keys, `node_modules`, binary files or anything in `.gitignore`, and it sends extracted metadata, never source code. Use `--dry-run` to see exactly what would be sent. After an upload, Serpel matches each route with the pages of your latest completed crawl and your Search Console page data. A static route matches its exact path, and a dynamic route matches by segment with the most specific route winning.

```bash
serpel scan --dry-run
serpel scan --project <project-id> --wait
```

The result lists routes without a live page, live pages without a route, routes without metadata and titles or descriptions that differ between code and live site. An agent can read the same summary through the `get_codebase_overview` tool of the [MCP server](https://serpel.app/developers/mcp), which links a crawl finding to the file that renders it.

## Next.js SEO best practices at a glance

1. Set `metadataBase`, a title template and a description in the root layout.
2. Give every page a unique title, description and canonical.
3. Prerender indexable pages and return a real 404 for unknown slugs.
4. Generate `sitemap.ts` with real `lastModified` dates and `robots.ts` without hiding pages.
5. Add JSON-LD as a plain script tag and an `opengraph-image` per route.
6. Use `next/image` and `next/font`, and keep the LCP image out of lazy loading.
7. Submit the sitemap in Search Console and keep a staging noindex out of production.
8. Crawl the live site after each release.

## Frequently asked questions

### Is Next.js good for SEO?

Yes, when pages are prerendered or server-rendered, because crawlers then receive complete HTML with content, links and metadata. Problems come from client-side data fetching, wrong canonicals, missing 404 responses and metadata that is overridden by mistake.

### How do I add meta tags in the Next.js App Router?

Export a `metadata` object or a `generateMetadata` function from a server `layout.tsx` or `page.tsx`. Next.js builds the head tags from them, and `metadataBase` lets you use relative URLs for canonicals and images.

### Do I need next-sitemap or another package for a sitemap?

No. The App Router supports `app/sitemap.ts` and `app/robots.ts` natively, and `generateSitemaps` splits large sitemaps. A package is only worth adding if you need features the built-in files do not offer.

### Why does my Next.js page show a 200 status for a missing slug?

The page probably renders a not-found message without calling `notFound()`, or calls it after streaming has started. Next.js then keeps the 200 status and relies on a `noindex` tag. Check for the slug before the page streams.

### How can I check my Next.js SEO on the live site?

Crawl the site and compare it with your code. A site audit finds missing titles, wrong canonicals and soft 404s, and `serpel scan` shows routes without a live page or without metadata.

## Sources

- [Next.js documentation: Metadata and OG images](https://nextjs.org/docs/app/getting-started/metadata-and-og-images), accessed 2026-10-10
- [Next.js documentation: generateMetadata](https://nextjs.org/docs/app/api-reference/functions/generate-metadata), accessed 2026-10-10
- [Next.js documentation: sitemap.xml](https://nextjs.org/docs/app/api-reference/file-conventions/metadata/sitemap), accessed 2026-10-10
- [Next.js documentation: robots.txt](https://nextjs.org/docs/app/api-reference/file-conventions/metadata/robots), accessed 2026-10-10
- [Next.js documentation: opengraph-image and twitter-image](https://nextjs.org/docs/app/api-reference/file-conventions/metadata/opengraph-image), accessed 2026-10-10
- [Next.js documentation: How to implement JSON-LD](https://nextjs.org/docs/app/guides/json-ld), accessed 2026-10-10
- [Next.js documentation: generateStaticParams](https://nextjs.org/docs/app/api-reference/functions/generate-static-params), accessed 2026-10-10
- [Next.js documentation: Caching and Cache Components](https://nextjs.org/docs/app/getting-started/caching), accessed 2026-10-10
- [Next.js documentation: htmlLimitedBots](https://nextjs.org/docs/app/api-reference/config/next-config-js/htmlLimitedBots), accessed 2026-10-10
- [Next.js documentation: Upgrading to version 15](https://nextjs.org/docs/app/guides/upgrading/version-15), accessed 2026-10-10
- [Next.js documentation: Image component](https://nextjs.org/docs/app/api-reference/components/image), accessed 2026-10-10
- [Next.js documentation: Font module](https://nextjs.org/docs/app/api-reference/components/font), accessed 2026-10-10
- [Google Search Central: Build and submit a sitemap](https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap), accessed 2026-10-10
- [Google Search Central: Introduction to robots.txt](https://developers.google.com/search/docs/crawling-indexing/robots/intro), accessed 2026-10-10
- [Google Search Central: Localized versions of your pages](https://developers.google.com/search/docs/specialty/international/localized-versions), accessed 2026-10-10
- [Google Search Central: Dynamic rendering as a workaround](https://developers.google.com/search/docs/crawling-indexing/javascript/dynamic-rendering), accessed 2026-10-10
- [Google Search Central: Make your links crawlable](https://developers.google.com/search/docs/crawling-indexing/links-crawlable), accessed 2026-10-10