Developers

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

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.

Serpel Team10 min read

Serpel illustration: a Next.js page file with generateMetadata, a canonical URL and alternate languages, next to chips for metadata, sitemap and rendering checks

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 over workarounds, and many other crawlers do not run scripts at all. Our guide to 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, 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. Put the shared parts in the root layout.

app/layout.tsx
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>
  )
}
app/blog/[slug]/page.tsx
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 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 and robots references.

app/sitemap.ts
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,
    })),
  ]
}
app/robots.ts
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 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. Use noindex or authentication.

Run the generated file through the free 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 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.

app/blog/[slug]/page.tsx
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 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.

app/opengraph-image.tsx
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 typeApproachNotes
Blog, docs and marketing pagesPrerender at build with generateStaticParamsComplete HTML. With dynamicParams = false, unknown slugs return 404
Large cataloguePrerender a subset and render the rest on first visitReturn a partial list from generateStaticParams. Other pages are rendered when first requested, as documented
Personalised pages such as carts and dashboardsDynamic renderingKeep them out of the sitemap and add noindex
Pages with a few dynamic partsCache Components with <Suspense>With cacheComponents: true, static and cached content ships in the initial HTML and runtime data streams in behind fallbacks
app/blog/[slug]/page.tsx
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 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 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, 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.

app/en/pricing/page.tsx
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 for the thresholds.

app/components/hero.tsx
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
MistakeEffectFix
Fetching page content in useEffect inside a Client ComponentThe HTML is empty for crawlers that do not renderFetch in a Server Component
Exporting metadata from a Client ComponentNot supportedMove the export to a server layout or page
Overriding openGraph or robots in a pageFields from the layout disappearRepeat the full object or build it with a helper
A canonical in the root layoutEvery page canonicalises to one URLSet alternates.canonical per page
A staging noindex shipped to productionThe site drops out of the indexDrive robots from an environment variable and check after each deploy
Unknown slugs render a “not found” message with 200Soft 404 pagesCall notFound() before streaming starts
Navigation with router.push on a buttonCrawlers find no linkUse next/link or <a href>, which Google can crawl
new Date() as lastModified for every URLGoogle stops trusting lastmodUse 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.

Terminal
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, 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

  1. Next.js documentation: Metadata and OG images, accessed 10 Oct 2026
  2. Next.js documentation: generateMetadata, accessed 10 Oct 2026
  3. Next.js documentation: sitemap.xml, accessed 10 Oct 2026
  4. Next.js documentation: robots.txt, accessed 10 Oct 2026
  5. Next.js documentation: opengraph-image and twitter-image, accessed 10 Oct 2026
  6. Next.js documentation: How to implement JSON-LD, accessed 10 Oct 2026
  7. Next.js documentation: generateStaticParams, accessed 10 Oct 2026
  8. Next.js documentation: Caching and Cache Components, accessed 10 Oct 2026
  9. Next.js documentation: htmlLimitedBots, accessed 10 Oct 2026
  10. Next.js documentation: Upgrading to version 15, accessed 10 Oct 2026
  11. Next.js documentation: Image component, accessed 10 Oct 2026
  12. Next.js documentation: Font module, accessed 10 Oct 2026
  13. Google Search Central: Build and submit a sitemap, accessed 10 Oct 2026
  14. Google Search Central: Introduction to robots.txt, accessed 10 Oct 2026
  15. Google Search Central: Localized versions of your pages, accessed 10 Oct 2026
  16. Google Search Central: Dynamic rendering as a workaround, accessed 10 Oct 2026
  17. Google Search Central: Make your links crawlable, accessed 10 Oct 2026

Related reading