Next.js SEO

How to make a Next.js site crawlable, indexable, and rankable — the two routers, rendering modes (SSG/SSR/ISR/Server Components), the App Router Metadata API, sitemap.ts, robots.ts, next/image, next/link, and the mistakes that quietly break it.

First published: Jun 26, 2026 · Last updated: Jul 18, 2026 · Advanced
demand #3 in JavaScript Frameworks#19 in Platform SEO#176 in Technical SEO#230 on the site

Next.js solves the hardest JavaScript SEO problems by default — if you use it right. The App Router's Server Components and SSG/ISR put content in the HTML so there's no render-queue delay; the native Metadata API resolves titles, canonicals, and Open Graph on the server; sitemap.ts and robots.ts are file conventions. The framework gives you the infrastructure but writes none of your tags for you. The failures are predictable: missing metadataBase, no priority on the LCP image, metadata exported from a Client Component (silently does nothing), and 404 views returning 200.

TL;DR — Next.js solves the hardest JavaScript SEO problems by default when you use it right. The App Router defaults to Server Components and supports SSG/SSR/ISR — all of which ship rendered HTML, so there’s no render-queue delay. Set metadata with the native Metadata API (metadata / generateMetadata), and remember metadataBase or your canonicals and OG images go relative. Use app/sitemap.ts and app/robots.ts, pre-build dynamic routes with generateStaticParams, set priority on the LCP image, and keep links as real next/link anchors. The Pages Router is also SEO-capable via next/head. The failures are predictable — and most of them aren’t Next.js’s fault, they’re yours.

Where Next.js fits

Next.js is a React framework, so everything in JavaScript SEO applies. What makes it worth its own guide is that Next.js ships first-class answers to most JS-SEO problems: server rendering, static generation, a metadata system, and sitemap/robots conventions. The hard part isn’t whether Google can read it — Google has rendered JavaScript for years — it’s choosing the right rendering mode and not leaving the SEO basics unwired. This is a specialized case of headless CMS SEO: the CMS barely matters, the frontend’s rendering decisions decide everything.

One framing to keep straight: “this is a Next.js site” doesn’t tell you how any single URL is delivered. Rendering mode, caching, and Server/Client Component boundaries are set per route (sometimes per segment) — a project can mix a static marketing page, an SSR product page, and a Client Component dashboard. Don’t extrapolate one route’s behavior to “the whole app”; test the specific URL.

Two routers, two sets of mechanics

Next.js has two routers, and they handle SEO differently:

  • Pages Router (the older model) — data fetching via getStaticProps / getServerSideProps; metadata via <Head> from next/head (or the next-seo package); no Server Components.
  • App Router (v13+, the current and recommended approach) — React Server Components by default; the native Metadata API (metadata export / generateMetadata); file conventions for app/sitemap.ts and app/robots.ts; generateStaticParams for dynamic routes. Evidence for this claim The App Router uses Server Components and supports generateStaticParams plus metadata file conventions. Scope: Current Next.js App Router behavior; route rendering can become dynamic based on APIs used. Confidence: high · Verified: Next.js: Server and Client Components Next.js: generateStaticParams

Both can rank well. The App Router gives you a cleaner, integrated metadata system (no next/head juggling) and Server Components out of the box, which is why I’d reach for it on a new build. But “App Router or you can’t do SEO” is a myth — plenty of Pages Router sites rank fine.

Rendering modes and what each means for SEO

How Google handles JavaScript is a three-phase pipeline — crawl, then a deferred render wave, then index. “All pages with a 200 HTTP status code are sent to the rendering queue.” The whole game in Next.js is choosing a mode that puts your content in the HTML before that render wave, so there’s nothing to wait for. Evidence for this claim Google crawls, renders, and indexes JavaScript pages, and successful pages can enter the rendering queue. Scope: Google Search processing, not a promise that a URL will be indexed. Confidence: high · Verified: Google: JavaScript SEO basics

  • Static Site Generation (SSG) — pages pre-rendered at build time. HTML is immediately available with no render-queue risk. Best for content that doesn’t change every minute. generateStaticParams() (App Router) / getStaticPaths() (Pages Router) decides which dynamic routes get pre-built.
  • Incremental Static Regeneration (ISR) — static pages that revalidate after a set interval (export const revalidate = 3600). Crawlers get static HTML with low TTFB and the content stays fresh. A strong default — with one trap: after the window expires the next request (possibly Googlebot) still gets the stale page; the fresh version serves on the request after that. For genuinely volatile data (prices, stock), SSR is safer.
  • Server-Side Rendering (SSR) — HTML rendered per request. Crawlers get fully rendered HTML immediately; the tradeoff is server latency, so watch TTFB and LCP. export const dynamic = 'force-dynamic' or using request-time APIs (cookies, headers) opts a route into SSR.
  • React Server Components (App Router default) — render on the server and send HTML; no JavaScript ships for the component itself. Content is in the initial response with no hydration gap. This is the best default for SEO. Interactivity lives in Client Components marked 'use client'.
  • Client-Side Rendering (CSR) — rendered entirely in the browser. Googlebot can index it after the render wave (median ~10 seconds, but the 90th percentile stretches to hours), and other crawlers — Bingbot, AI bots, social preview bots — may get an empty page. In the App Router, CSR is opt-in ('use client'); in the Pages Router, avoid fetching primary content in useEffect. Don’t use it for content you want ranked.

A reminder I keep coming back to: “Googlebot can render it” is not the same as “you should make Googlebot render it.” Rendering is expensive, deferred, and not universal across crawlers.

The Metadata API (App Router)

The Metadata API is Server Component only — metadata resolves on the server before the page renders, so it lands in the initial HTML. Export metadata from layout.js or page.js:

export const metadata: Metadata = {
  title: 'My Page',
  description: 'Page description',
}

Or, when the tags depend on fetched data, use generateMetadata():

export async function generateMetadata({ params }) {
  const post = await getPost(params.slug)
  return { title: post.title, description: post.description }
}

The fields that matter for SEO:

  • title — supports a string, a template ('%s | Brand'), a default, and an absolute override. Set the template once in the root layout and per-page titles inherit it.
  • description, alternates.canonical (the correct way to set a canonical in the App Router), openGraph (images must resolve to absolute URLs), twitter (also used by LinkedIn and Slack previews), and robots (index/follow plus googleBot-specific directives like max-snippet, max-image-preview).
  • metadataBaserequired for canonical and OG image URLs to resolve correctly. Forgetting it is the single most common Next.js metadata bug: relative URLs leak into your canonical and Open Graph tags, breaking social previews and muddying canonical signals.

A title-template example:

// app/layout.tsx
export const metadata: Metadata = {
  metadataBase: new URL('https://example.com'),
  title: { template: '%s | Brand Name', default: 'Brand Name' },
}
// app/blog/page.tsx
export const metadata: Metadata = { title: 'My Blog Post' }
// Output: <title>My Blog Post | Brand Name</title>

Two gotchas. First, metadata is shallowly merged from layout to page — a nested object like openGraph defined in a child segment replaces the parent’s entirely, so a page-level openGraph: { title: 'Home' } quietly drops any openGraph.images set in the layout. Second — and this one bites people — metadata only works in Server Components. Export it from a 'use client' file and it silently does nothing.

Streaming metadata. For dynamically rendered pages, generateMetadata can stream the metadata after the initial HTML. Googlebot executes JavaScript and inspects the full DOM, so streamed metadata works for Google. But Next.js detects “HTML-limited bots” — Bingbot, Twitterbot, Slackbot, facebookexternalhit — and ships them blocking metadata in the <head> instead. Per the Next.js docs, “streaming metadata is disabled for bots and crawlers that expect metadata to be in the <head> tag.” This is automatic; no configuration needed. It’s a detail almost no competing guide covers, and it’s why streaming metadata isn’t a risk for the bots that can’t wait for it. Prerendered pages are a different case entirely — metadata there resolves at build time, so there’s no stream to worry about. This behavior is version-specific (current as of Next.js 16.2.10); re-check the generateMetadata docs when you upgrade. And because delivery paths differ by entry point, verify metadata two ways, not one: a direct/production request (curl -I or View Source) and a client-side navigation to the same route — the head can update differently between the two.

Evidence for this claim Prerendered Next.js pages do not use streaming metadata because metadata is resolved at build time in the documented path. Scope: route output, metadata and deployment Confidence: high · Verified: Metadata and OG images

Metadata with next/head (Pages Router)

On the Pages Router, metadata lives in <Head> from next/head:

import Head from 'next/head'

export default function Page() {
  return (
    <>
      <Head>
        <title>My Page | Brand</title>
        <meta name="description" content="Description" />
        <link rel="canonical" href="https://example.com/my-page" />
      </Head>
      {/* page content */}
    </>
  )
}

Set title and description per page (not just in _app.js), and put a canonical on every page including paginated variants. The next-seo package standardizes this with a <NextSeo> component and structured-data helpers. Migrating to the App Router mostly means trading next/head and next-seo for the native metadata export.

Do you need the next-seo package? It’s a third-party plugin (not part of Next.js itself) — actively maintained, at v7.2.0 as of this writing, not archived. Its own docs are explicit about where it fits: for standard meta tags on the App Router, the package’s README recommends using Next.js’s built-in generateMetadata/metadata export instead of <NextSeo>; on the Pages Router, <NextSeo> is still a reasonable convenience layer over next/head. The one App Router use case the package still covers is its JSON-LD helper components (ArticleJsonLd, FAQPageJsonLd, etc. with useAppDir), which some teams prefer over hand-rolling <script type="application/ld+json">. Bottom line: on a new App Router build, reach for the native Metadata API first — the package is optional, not a requirement, and its own maintainers say so.

Sitemaps

In the App Router, app/sitemap.ts is a file convention that outputs /sitemap.xml:

import type { MetadataRoute } from 'next'

export default function sitemap(): MetadataRoute.Sitemap {
  return [
    { url: 'https://acme.com', lastModified: new Date(), priority: 1 },
    { url: 'https://acme.com/blog', lastModified: new Date(), priority: 0.8 },
  ]
}

For large sites, generateSitemaps() shards into multiple files (Google’s limit is 50,000 URLs per sitemap), each served at /.../sitemap/[id].xml. The sitemap output also supports image sitemaps, video sitemaps, and localized alternates.languages. On the Pages Router, use next-sitemap or generate pages/sitemap.xml.js with getServerSideProps.

robots.txt

app/robots.ts generates your robots file programmatically:

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

Per-user-agent rules and multiple sitemaps are supported. The Pages Router uses a static public/robots.txt. The rule you cannot get wrong on either router: never disallow your JavaScript or CSS — Google won’t render from blocked files, and on a JS framework that can blank out the page entirely.

next/image and Core Web Vitals

next/image is one of the strongest reasons to use the framework for SEO. It lazy-loads below-the-fold images, requires width/height (or fill) so it reserves space and prevents layout shift / CLS, serves WebP/AVIF automatically, and emits a proper srcset from the sizes prop. The single most important CWV optimization is the priority prop on your hero / above-the-fold image, which preloads it for a faster LCP:

<Image src="/hero.jpg" width={1200} height={630} priority alt="Hero" />

Forgetting priority on the LCP image is the most common Next.js CWV mistake — and CWV problems are widespread on real Next.js sites (see the Stats tab for the Salt Agency data). alt is required: empty for decorative images, descriptive for content images.

next/link renders standard <a href> anchors in the HTML, so Google follows them normally, and it adds client-side navigation plus background prefetching of in-viewport links in production. The SEO rule is simple: use next/link for internal links, and never substitute an onClick handler or JavaScript navigation that doesn’t produce a real anchor — those links aren’t crawlable. Use prefetch={false} on low-value links to save bandwidth if you need to.

Dynamic routes and generateStaticParams

generateStaticParams() tells Next.js which dynamic routes to pre-render at build time:

// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await getPosts()
  return posts.map((post) => ({ slug: post.slug }))
}

Pages built this way are fully static HTML — best for SEO. Without it, dynamic routes are rendered on-demand (SSR) by default, which is fine but reintroduces server latency. Combine it with revalidate (ISR) for content that updates regularly. Make sure all important dynamic URLs are in generateStaticParams so nothing waits on the render queue.

Structured data (JSON-LD)

The Metadata API has no structured-data field — you inject JSON-LD as a <script> in a Server Component, which keeps it in the server-rendered HTML at zero client-bundle cost:

const jsonLd = { '@context': 'https://schema.org', '@type': 'Article', /* … */ }
return <script type="application/ld+json"
  dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />

Article/BlogPosting, BreadcrumbList, Product, and FAQPage are the usual types. Validate with the Rich Results Test after any rendering change.

Common Next.js SEO mistakes

Drawn from real audits and the patterns above:

  1. Missing or relative canonicals — often from a forgotten metadataBase, which also breaks OG image URLs.
  2. No priority on the LCP image — the biggest CWV miss.
  3. 404 views returning 200 — use the built-in notFound() to return a real status; soft 404s are rampant on Next.js sites.
  4. metadata exported from a Client Component — silently does nothing; it’s Server Component only.
  5. CSR for primary content — fetching critical content in useEffect means non-Google crawlers get empty pages.
  6. Hash (#) routing instead of the History API — those views aren’t separately crawlable.
  7. Not exporting generateStaticParams — dynamic routes render on-demand instead of being pre-built.
  8. openGraph overwritten by layout inheritance — child segments replace, not merge.
  9. Blocking JS/CSS in robots.txt or via a Content Security Policy that stops Googlebot’s headless Chrome from loading scripts — test with URL Inspection.

Deployment notes

Next.js is built by Vercel; hosting there gives tight integration (edge CDN for static and ISR pages, good TTFB) but isn’t required. Define permanent redirects in redirects() in next.config.js (returns 308, or 301 with permanent: true) for reliable signaling to crawlers, set security and caching headers via headers(), and use X-Robots-Tag response headers for path-based noindex rules when per-page robots metadata is awkward.

What to check where. Cache state, status codes, redirects, and streamed metadata don’t all show up in the same test — a route can look fine in one check and still be broken in another:

CheckWhere to lookWhy it can differ from what you see rendered
Cache/revalidation ageResponse headers on a direct request (curl -I)ISR can serve a stale page on the request right after the window expires
Direct HTTP statuscurl -I on the production URL, not the rendered UIA “not found” view without notFound() still returns 200
Redirect behaviorThe actual context that fires it — redirect() in a Server Action, a Route Handler, vs. a client onClickStatus code and response path differ by invocation context, not just the destination
Streamed metadataDirect request and client-side navigation to the same routeOrdinary clients can get streamed metadata; HTML-limited bots get blocking metadata; the two paths aren’t identical
Client-side transitionsNavigate in-app, then re-check the <head>A route that’s correct on first load can drift after a client transition

None of this is guaranteed by the framework — Next.js gives you the mechanisms (redirects(), notFound(), revalidation, streaming), but cache keys, invalidation, preview state, and deployment configuration are still your responsibility to get right and to test in production, not just locally.

One last note on dynamic rendering — serving prerendered HTML to bots and JavaScript to users. Google has deprecated it as a recommendation: “dynamic rendering was a workaround and not a long-term solution.” You don’t need it on Next.js anyway — SSR, SSG, ISR, and Server Components all put content in the HTML natively. Mention it so you recognize it in an audit; don’t build on it.

The happy path: App Router + Server Components + ISR + the Metadata API (with metadataBase) + next/image with priority. Get those right and most of Next.js SEO is handled.

Add an expert note

Pin an expert quote

New person? Create their unclaimed profile at /admin/experts/ → Pin an expert quote first.