Technical SEO

Next.js SEO: App Router, generateMetadata & Server-Rendered Schema

· · 15 min read

What is Next.js SEO?

Next.js SEO is the practice of configuring a Next.js application so that search engines and AI crawlers receive complete, correct HTML — primary content, per-route metadata, canonical tags, and JSON-LD — in the server’s initial response, before any client JavaScript runs. In the App Router, it comes down to a few decisions: keep content in server components, define metadata with generateMetadata, emit schema server-side, and pick a rendering mode (static, dynamic, or ISR) per route.

This is the Next.js instalment of the SEO for engineers series, and it sits at a specific point on that hub’s arc: crawlable → fast → understandable → citable. The sibling React SEO guide is about the problem — a plain React SPA ships an empty <div id="root"> and assembles everything client-side, which is invisible to the class of bots that never run JavaScript. Next.js is one of the answers. But adopting it doesn’t automatically fix the problem; it gives you a set of levers, and pulling the wrong one puts you right back to shipping a shell. This guide is the App Router-first version of which lever to pull, with the code.

I’m writing this from the seat of someone who ships Next.js and does the SEO on it. Everything below is a decision you make in the codebase — a component boundary, a generateMetadata export, a revalidate value — not a ticket you hand to a marketing team.

TL;DR — Key takeaways

  • Server components are the SEO default, and that’s the whole advantage. In the App Router, components render on the server unless you opt out. Content, headings, and JSON-LD rendered in a server component land in the initial HTML — exactly where Googlebot’s first pass and every AI crawler can read them. The moment you add "use client" to a component that holds primary content, you’ve moved that content behind hydration.

  • generateMetadata is where per-route SEO lives. Title, description, alternates.canonical, and OpenGraph are all set from one exported async function per route — and it’s Server Component only by design, so Next.js can resolve it before the page renders and put the tags in the response.

  • Rendering mode is a per-route SEO choice. Static generation is the strongest posture; ISR (export const revalidate) keeps static pages fresh without a full rebuild; dynamic rendering (export const dynamic = 'force-dynamic') trades cacheability for per-request data. The failure mode is letting one route accidentally opt the whole tree into dynamic.

  • Schema must come from a server component, not a useEffect. A JSON-LD <script> injected after hydration is invisible to GPTBot, ClaudeBot, and PerplexityBot — none of which execute JavaScript. ConvertMate’s analysis of 80M+ AI citations measured a 67% lift in citation eligibility for content carrying valid schema, and you forfeit all of it if the schema arrives via client JS.

  • app/sitemap.ts and app/robots.ts make sitemap and robots hygiene a build-time property. Generate them from your canonical URL set in code, so they can’t drift out of sync with the content.

  • The App Router lowers INP by default. Because server components ship no client JavaScript, the hydration bill — the single biggest Interaction to Next Paint cost — is only what your "use client" components add. Less client JS is fewer long tasks blocking the main thread.

The server/client boundary is the SEO decision

Before any API, the one thing to internalise: in the App Router, everything is a server component until you write "use client". A server component runs only on the server, ships zero JavaScript for its own logic, and its output — text, headings, links, script tags — is in the HTML the server sends. That is the ideal SEO posture, and it’s the default. You don’t configure it; you avoid breaking it.

"use client" marks a boundary. Everything from that directive down is a client component: it renders on the server for the initial HTML and hydrates on the client so it can use state, effects, and browser APIs. The trap is scope. If you slap "use client" at the top of a page because one button needs an onClick, you’ve pulled the entire page — article body included — into the client bundle and made its content dependent on hydration.

The fix is to keep the boundary small. Leave the page a server component, render the content server-side, and push only the interactive leaf into its own client component:

// app/insights/[slug]/page.tsx — stays a Server Component
import { ShareButton } from './share-button' // the only client piece

export default async function Article({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const post = await getPost(slug)      // runs on the server
  return (
    <article>
      <h1>{post.title}</h1>
      {/* body is server-rendered — in the initial HTML, no hydration needed */}
      <div dangerouslySetInnerHTML={{ __html: post.html }} />
      <ShareButton url={post.url} />     {/* island of interactivity */}
    </article>
  )
}
// app/insights/[slug]/share-button.tsx
'use client'
export function ShareButton({ url }: { url: string }) {
  return <button onClick={() => navigator.share({ url })}>Share</button>
}

This is the same “keep the interactive part small” instinct that Astro gets for free with islands — the Astro SEO guide covers that end of the spectrum. In Next.js you have to draw the boundary deliberately, but the App Router at least makes the SEO-safe choice the default one. If you want the deeper render-strategy trade-offs behind this, I wrote them up once in technical SEO for headless architecture rather than repeat them here.

generateMetadata: per-route title, description, canonical, OpenGraph

The App Router replaces the Pages Router’s next/head with a data-driven metadata API. You export either a static metadata object (when the values are known at build time) or an async generateMetadata function (when they depend on route params or fetched data). Next.js resolves it and emits the <head> tags for you.

Here’s the pattern I ship for a blog post route, setting the four things that matter most for SEO — title, description, canonical, and OpenGraph:

// app/insights/[slug]/page.tsx
import type { Metadata } from 'next'

export async function generateMetadata(
  { params }: { params: Promise<{ slug: string }> }
): Promise<Metadata> {
  const { slug } = await params
  const post = await getPost(slug)
  return {
    title: post.seoTitle,
    description: post.excerpt,
    alternates: {
      canonical: `/insights/${slug}`,   // resolved against metadataBase
    },
    openGraph: {
      title: post.seoTitle,
      description: post.excerpt,
      url: `/insights/${slug}`,
      type: 'article',
      publishedTime: post.date,
      authors: ['Nadia Mohamed'],
    },
  }
}

Three details that decide whether this actually helps:

Set metadataBase once, in the root layout. URL-based fields like alternates.canonical and openGraph.url can then be relative paths, and Next.js composes them into absolute URLs. Without it, a relative canonical throws a build error:

// app/layout.tsx
export const metadata = {
  metadataBase: new URL('https://example.com'),
}

generateMetadata is Server Component only — that’s the feature, not a limitation. The docs are explicit: the metadata export and generateMetadata are only supported in server components, because the metadata has to resolve on the server before the page renders so it can go in the initial HTML response. If a route needs client interactivity, keep the page a server component and move the interactive bits into a child client component — never convert the page itself.

Mind streaming metadata. Since v15.2, Next.js can stream metadata: when generateMetadata introduces dynamic behaviour, the resolved tags are appended to the <body> after the initial UI streams, rather than blocking the response. For a prerenderable page the metadata still lands in <head> in the initial HTML. And critically for AI/social crawlers, Next.js detects HTML-limited bots (ones that don’t execute JavaScript) by user agent and keeps their metadata blocking in <head>. The practical rule: keep metadata statically analysable and your route prerenderable, and the tags stay in <head> where every crawler reads them.

Emitting JSON-LD from a server component

Structured data is the highest-leverage, most controllable input to AI citation eligibility — and in Next.js the only thing you have to get right is where it renders. Build the object in a server component and render it as a script tag in the returned JSX. Because the component is server-side, the script is in the HTML response, not injected after hydration:

// app/insights/[slug]/page.tsx (Server Component)
export default async function Article({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const post = await getPost(slug)

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: post.title,
    datePublished: post.date,
    dateModified: post.updated ?? post.date,
    author: { '@id': 'https://example.com/about/#person' },
    mainEntityOfPage: {
      '@type': 'WebPage',
      '@id': `https://example.com/insights/${slug}/`,
    },
  }

  return (
    <article>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      {/* …article body… */}
    </article>
  )
}

Two things carry over from the pillar and stay true here. The author is referenced by @id rather than duplicated inline, so the Person entity is defined once and pointed at everywhere — the priority stack and the sameAs mechanics live in structured data for AI search. And you validate before trusting it: Google’s Rich Results Test renders the route the way Googlebot does and confirms the schema parses.

The reason this placement is non-negotiable is the same one that runs through the whole hub — AI crawlers read raw HTML and stop. Schema that a client component appends works for Googlebot eventually and fails for exactly the engines you added it to reach.

67%
higher AI citation eligibility for content with valid schema markup
Source: ConvertMate — analysis of 80M+ AI citations
38%
of Google AI Overview citations came from pages already ranking in the top 10
Source: Ahrefs — down from ~76% in July 2025
Server-rendered schema is what makes a Next.js route eligible for both. Inject it client-side and the AI crawlers — which never run your JavaScript — see none of it.

Static, dynamic, and ISR: rendering mode is a per-route lever

Next.js decides per route whether to prerender (static) or render on demand (dynamic). For SEO, static is the strongest posture — the crawler gets fully assembled HTML with no rendering ambiguity and the fastest time to first byte. Most content routes should be static, and the App Router prerenders them by default when nothing forces otherwise.

Two route segment config exports control the behaviour when you need to override the default:

// Force a route's mode explicitly
export const dynamic = 'force-static'
// 'auto' | 'force-dynamic' | 'error' | 'force-static'
  • 'auto' (default) caches as much as possible without blocking any component from opting into dynamic behaviour.
  • 'force-static' forces prerendering.
  • 'force-dynamic' renders per request — correct for genuinely per-user or real-time routes, wrong for content you want cached and crawled cheaply.

The subtle SEO bug is accidental dynamic rendering: one uncached data call or a request-time API (cookies(), headers()) can tip a route into dynamic mode, quietly giving up the static advantage across everything below it. Audit which of your content routes are actually prerendered.

ISR is the freshness lever. Incremental Static Regeneration keeps a route static but lets it regenerate on an interval, so pages stay fresh without a full redeploy. Set it with the revalidate segment export — the value has to be statically analysable (a literal, not an expression):

// app/insights/[slug]/page.tsx
export const revalidate = 3600 // re-generate at most once an hour
// false (cache indefinitely) | 0 (always dynamic) | number (seconds)

For content that updates on discrete events rather than a clock, use on-demand revalidation instead — call revalidatePath('/insights/my-post') or revalidateTag('posts') from next/cache in a Server Action or Route Handler after the content changes. That combination — static by default, ISR or on-demand revalidation for freshness — is the pattern that keeps dateModified honest without paying dynamic-rendering costs on every crawl. Freshness is a real GEO signal, and this is the mechanism that delivers it cheaply.

Streaming, Suspense, and what crawlers actually get

The App Router streams: a page can send its shell and static content immediately while a <Suspense> boundary holds slower data and fills in later. This is great for perceived performance, and it’s worth being precise about what a crawler receives.

The safe rule is that primary content must not sit behind a Suspense boundary that resolves after the initial HTML. Streaming is ideal for genuinely secondary, below-the-fold, or interactive regions — a comments feed, a “related products” rail, a personalised widget. It is the wrong place for the article body, the H1, or anything you need indexed and cited. Keep those in the synchronously-rendered part of the server component so they’re in the first byte of HTML, and reserve Suspense for the parts where a late arrival costs nothing. The same discipline that keeps content out of a useEffect keeps it out of a slow Suspense boundary.

app/sitemap.ts and app/robots.ts

Two file conventions turn sitemap and robots hygiene from a manual chore into generated output. app/sitemap.ts exports a default function returning a typed MetadataRoute.Sitemap — build it from your canonical URL set so it can never list a stale or non-canonical URL:

// app/sitemap.ts
import type { MetadataRoute } from 'next'

export default async function sitemap(): MetadataRoute.Sitemap {
  const posts = await getAllPosts()
  return posts.map((post) => ({
    url: `https://example.com/insights/${post.slug}`,
    lastModified: post.updated ?? post.date,
    changeFrequency: 'weekly',
    priority: 0.7,
  }))
}

app/robots.ts does the same for robots.txt, returning a MetadataRoute.Robots object — and it’s the right place to make AI-crawler access a deliberate policy rather than an accident:

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

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

Because both are code, they stay in sync with the content by construction — the recurring “sitemap lists 301s and deleted URLs” finding from a standing technical SEO audit mostly disappears when the sitemap is derived from the canonical set at build time.

For legacy readers: the Pages Router

If you’re on the Pages Router (pages/ directory), the primitives differ but the SEO principles are identical — get content and metadata into the server response.

  • Metadata comes from the next/head component: render a <Head> with your <title>, <meta name="description">, <link rel="canonical">, and OpenGraph tags inside the page.
  • Rendering is chosen by which data function you export. getStaticProps prerenders at build time (SSG — the strongest SEO posture); getServerSideProps renders per request (SSR); returning a revalidate value from getStaticProps gives you ISR.

The through-line holds across both routers: server-render primary content and schema, set an explicit canonical, and pick static generation unless a route genuinely needs per-request rendering. The App Router just makes the safe defaults easier to keep.

The ten-second check, and where this fits

Whichever router you’re on, settle the question the same way the pillar does — ask for the page as an AI crawler would and read what comes back:

curl -sA "GPTBot" https://example.com/insights/your-post | grep -i "<h1\|application/ld+json"

If your H1 and your JSON-LD are in that output, your server components are doing their job and the content is crawlable by Googlebot’s first pass and every AI bot alike. If it’s a shell, something got pulled behind a client boundary — walk back up your "use client" directives and your Suspense boundaries.

That crawlable-and-understandable base is what makes a page eligible to be cited; turning eligibility into actual citations is the GEO layer — extractable answers, a clear author entity, freshness — and it’s covered in the pillar’s citable stage. Next.js gives you an unusually clean path to the base: server components put content and schema in the HTML by default, generateMetadata handles canonicals and OpenGraph per route, and ISR keeps it all fresh. Pull those levers correctly and most of the technical work is done.

If you’d rather have the render-strategy, metadata, and schema layers designed and shipped in the codebase than build them internally, that’s the full-stack development scope — the same crawlable-to-citable arc, deployed rather than described.

FAQ

Is Next.js good for SEO?

Yes — provided you keep primary content in server components. The App Router server-renders by default, so content, headings, and JSON-LD land in the initial HTML where search engines and AI crawlers can read them, and the metadata API handles titles, canonicals, and OpenGraph per route. The caveat is the "use client" boundary: a component marked as a client component moves its content behind hydration, which is the same client-rendering problem a plain React SPA has. Next.js makes the SEO-safe choice the default, but it doesn’t enforce it.

What does generateMetadata do in Next.js?

generateMetadata is an async function you export from an App Router page.tsx or layout.tsx to set per-route metadata — title, description, alternates.canonical, OpenGraph, and Twitter tags — based on route params or fetched data. Next.js resolves it on the server before the page renders and emits the corresponding <head> tags. It’s supported only in server components by design, so the metadata can be included in the initial HTML response rather than added on the client.

How do I add JSON-LD structured data in the Next.js App Router?

Build the schema object inside a server component and render it as a <script type="application/ld+json"> tag using dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} in the returned JSX. Because the component runs on the server, the script is in the HTML response — visible to AI crawlers like GPTBot and PerplexityBot, which don’t execute JavaScript. Never inject JSON-LD from a client component or a useEffect; that renders it invisible to those engines. Validate with Google’s Rich Results Test before shipping.

What’s the difference between static rendering, dynamic rendering, and ISR for SEO?

Static rendering prerenders the route to HTML at build time — the strongest SEO posture, with the fastest TTFB and no rendering ambiguity. Dynamic rendering (export const dynamic = 'force-dynamic') renders per request, which you want only for per-user or real-time routes. ISR (Incremental Static Regeneration, via export const revalidate = <seconds>) keeps a route static but regenerates it on an interval or on demand, so content stays fresh without a full redeploy. Most content routes should be static or ISR; reserve dynamic rendering for routes that genuinely need per-request data.

Do the App Router and server components fix SEO on their own?

Not automatically. Server components give you the right default — content and schema in the server-rendered HTML — but the moment you wrap primary content in a "use client" component or hold it behind a Suspense boundary that resolves after the initial HTML, you’ve moved it out of the crawlable response. The App Router makes the correct posture the easy one; keeping the client boundary small and content server-rendered is still a decision you make per route.

How do I create a sitemap and robots.txt in Next.js?

Use the file conventions. app/sitemap.ts exports a default function returning a MetadataRoute.Sitemap array of { url, lastModified, changeFrequency, priority } entries — generate it from your canonical URL set so it never drifts. app/robots.ts exports a default function returning a MetadataRoute.Robots object with rules (userAgent, allow, disallow), a sitemap URL, and optional host. Because both are code, sitemap and robots hygiene becomes a build-time property instead of a manual maintenance task.