Next.js App Router SEO Configuration

The App Router moved rendering and metadata out of next/head and page-level getStaticProps into server components, route segment config, and the Metadata API. That shift is powerful, but it also means SEO now depends on a handful of exports being declared correctly on each route — miss one and a route that should ship static HTML with a canonical instead reaches Googlebot as a per-request render with no head tags.

Prerequisites

Confirm these are in place before wiring the App Router for SEO:

  • Next.js 14+ with the App Router (app/ directory) — the Metadata API and sitemap.ts / robots.ts conventions differ in older versions
  • Node.js 18+ for the build and runtime
  • A CMS SDK or fetch client that returns published slugs and per-entry metadata (title, excerpt, updatedAt)
  • NEXT_PUBLIC_SITE_URL set to a protocol-prefixed absolute domain (e.g. https://example.com) in every build environment
  • curl available in CI for raw-HTML validation

Rendering-mode decisions here build directly on the ISR, SSG, and CSR routing tradeoffs; if you have not chosen a per-route model yet, settle that first. This guide sits under the framework SEO configuration reference alongside the SvelteKit and Nuxt equivalents.

App Router SEO request flow A left-to-right flow: an App Router request passes through route segment config and generateMetadata, which write tags into the server-rendered HTML head that the crawler indexes. Request app route Segment config dynamic / revalidate generateMetadata canonical + title HTML head server-rendered Crawler indexes tags

Step-by-Step Implementation Workflow

Step 1 — Set the route segment config

Declare the rendering intent explicitly on every content route. Leaving these to inference is how routes drift into accidental dynamic rendering.

// app/blog/[slug]/page.tsx — route segment options
export const dynamic = 'force-static'; // build to static HTML, not per-request
export const revalidate = 3600;         // background rebuild window in seconds
export const dynamicParams = false;     // 404 any slug not in generateStaticParams

Validation: run next build and confirm the route is listed as Static () or ISR, not Dynamic (ƒ), in the route table.

Step 2 — Enumerate known slugs with generateStaticParams

Prebuild every published slug so crawlers hit warm static pages, and let dynamicParams = false turn unknown slugs into clean 404s instead of soft-404 shells.

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

Validation: confirm the build output reports the expected page count for the segment, and that a known slug returns 200 while a fabricated one returns 404.

Step 3 — Return metadata with an absolute canonical

Resolve title, description, and canonical inside generateMetadata from the CMS entry, using the base URL env variable so the canonical is absolute.

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

const SITE = process.env.NEXT_PUBLIC_SITE_URL ?? '';

export async function generateMetadata(
  { params }: { params: Promise<{ slug: string }> }
): Promise<Metadata> {
  const { slug } = await params;
  const post = await getCmsEntry(slug);
  return {
    title: post.title,
    description: post.excerpt,
    alternates: { canonical: `${SITE}/blog/${slug}/` },
    openGraph: { title: post.title, type: 'article' },
  };
}

Validation: curl -s https://example.com/blog/example | grep -i canonical must return the absolute URL matching the served path.

Step 4 — Add app/sitemap.ts and app/robots.ts

Generate the URL inventory and crawl directives from live CMS data so they stay in step with published content.

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

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await fetchAllPublishedSlugs();
  return posts.map((p) => ({
    url: `${process.env.NEXT_PUBLIC_SITE_URL}/blog/${p.slug}/`,
    lastModified: p.updatedAt,
  }));
}
// app/robots.ts
import type { MetadataRoute } from 'next';

export default function robots(): MetadataRoute.Robots {
  return {
    rules: { userAgent: '*', disallow: ['/api/', '/preview/'] },
    sitemap: `${process.env.NEXT_PUBLIC_SITE_URL}/sitemap.xml`,
  };
}

Validation: fetch /sitemap.xml and /robots.txt and confirm both render at build time with absolute URLs and no localhost leakage.

Step 5 — Validate the raw HTML

Confirm every SEO-critical tag is in the server response, not injected after hydration.

curl -s https://example.com/blog/example \
  | grep -iE '<title>|name="description"|rel="canonical"'

Validation: all three tags appear in the raw body. A missing tag means the logic is running client-side and must move into the server functions above. This keeps the App Router aligned with canonical URL enforcement across the stack.

Framework-Specific Code Examples

Next.js App Router

The App Router’s defining SEO trait is that server components render on the server by default, so generateMetadata output and page content both land in the first response — as long as no 'use client' boundary sits above the metadata-generating segment. The pattern below combines an ISR window with an absolute canonical and a per-entry Open Graph block, which is the shape most content routes want.

// app/[category]/[slug]/page.tsx
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';

export const revalidate = 1800;
export const dynamicParams = true; // allow on-demand ISR for new slugs

const SITE = process.env.NEXT_PUBLIC_SITE_URL ?? '';

export async function generateMetadata(
  { params }: { params: Promise<{ category: string; slug: string }> }
): Promise<Metadata> {
  const { category, slug } = await params;
  const entry = await getCmsEntry(category, slug);
  if (!entry) return {};
  const canonical = `${SITE}/${category}/${slug}/`;
  return {
    title: entry.title,
    description: entry.excerpt,
    alternates: { canonical },
    openGraph: { title: entry.title, url: canonical, type: 'article' },
    robots: { index: entry.published, follow: true },
  };
}

export default async function Page(
  { params }: { params: Promise<{ category: string; slug: string }> }
) {
  const { category, slug } = await params;
  const entry = await getCmsEntry(category, slug);
  if (!entry) notFound();
  return <article dangerouslySetInnerHTML={{ __html: entry.html }} />;
}

SEO impact: Metadata and body are both server-rendered, so crawlers index the correct canonical, title, description, and content on the first pass. The robots.index flag driven by CMS publish state keeps drafts out of the index without a separate mechanism.

Validation: curl -s https://example.com/guides/example | grep -iE 'canonical|og:url' returns matching absolute URLs, and next build lists the route as ISR with the expected revalidate window.

The two guides in this section drill into the highest-leverage exports: configuring generateMetadata for SEO and controlling static and dynamic rendering.

SvelteKit and Nuxt equivalents

The same four surfaces exist in the other frameworks, wired differently. SvelteKit declares rendering per page module (export const prerender, ssr) and injects metadata via a load function feeding svelte:head — see SvelteKit SEO configuration. Nuxt centralizes rendering in routeRules and injects metadata with useSeoMeta — see Nuxt SEO configuration. The canonical and sitemap principles are identical; only the API names change.

HTTP Headers & CDN Directives Reference

Header Required value Rationale
Cache-Control (static/ISR) public, s-maxage=1800, stale-while-revalidate=86400 CDN serves cached HTML to bots while ISR regenerates in the background
Cache-Control (dynamic) public, s-maxage=60, stale-while-revalidate=120 Short TTL keeps origin load bounded during crawl spikes
Link <https://example.com/path/>; rel="canonical" Reinforces the head canonical at the HTTP layer for crawlers that read headers first
X-Robots-Tag index, follow (or noindex on preview) Environment-conditional indexation control without touching page code
Content-Type (sitemap) application/xml Ensures Search Console parses sitemap.xml correctly

Validation Protocol

Metadata in the App Router merges downward, so a value can come from three places and the resolved result is what matters.

Metadata merging from layout to page Root layout defaults merged with a nested layout and then the page's own metadata to produce the resolved head output. root layout site defaults section layout template overrides page entry values resolved head Assert the resolved output, not the value any single file declares
# 1. Confirm the route rendered static/ISR in the build
next build | grep -E 'blog/\[slug\]'

# 2. Verify SEO tags exist in the raw server HTML
curl -s https://example.com/blog/example \
  | grep -iE '<title>|name="description"|rel="canonical"'

# 3. Confirm sitemap and robots resolve with absolute URLs
curl -s https://example.com/sitemap.xml | head -n 5
curl -s https://example.com/robots.txt

For continuous enforcement, add a Lighthouse CI assertion so a missing or mismatched canonical fails the deployment:

// lighthouserc.js
module.exports = {
  assert: {
    assertions: {
      'canonical': ['error', { minScore: 1 }],
      'document-title': ['error', { minScore: 1 }],
      'meta-description': ['error', { minScore: 1 }],
    },
  },
};

Troubleshooting

The most consequential App Router behaviour is that reading a request-scoped value anywhere in a segment converts the whole segment to dynamic rendering.

How one request-scoped read opts a segment into dynamic rendering A statically generated segment containing a shared helper that reads cookies, causing the whole segment to render per request. route segment page component analytics helper — reads cookies whole segment renders per request no cache, slower crawl responses Nothing in the page file changed — the opt-out came from an import
Symptom Root cause Fix
Route shows as Dynamic (ƒ) in build output cookies(), headers(), or searchParams read in the segment Remove the request-bound call or move it to a child client component; set dynamic = 'force-static'
Canonical present in dev, missing in production NEXT_PUBLIC_SITE_URL unset in the CI build env Add the variable to the build step; rebuild and recheck with curl
Metadata correct in browser, blank in view-source Title/description set in a 'use client' component Move the logic into generateMetadata on the server route
New CMS slug returns 404 dynamicParams = false with the slug absent from generateStaticParams Set dynamicParams = true for on-demand ISR, or rebuild after the slug is published
Sitemap lists 404 URLs Sitemap built from routes rather than published entries Generate <loc> values only from published, canonical slugs
Duplicate canonicals across parameter variants Tracking params included in the canonical Strip utm_* / ref before building the canonical string

Pages in This Section

Reading the build output as a rendering audit

The App Router prints a route table at the end of every build, and that table is the cheapest rendering audit available. Each route is marked as static, dynamic, or partially prerendered, and the marks are derived from the same analysis the runtime uses. A route you believed was static appearing as dynamic is a defect you can see in seconds, without deploying anything.

Reading it regularly changes how rendering regressions are caught. The usual failure is gradual: a shared helper starts reading a cookie, an analytics import moves, a data fetch loses its cache option, and one route at a time slips from static to dynamic. Nobody notices because the pages still render correctly and the only symptom is a slowly rising origin load. Diffing the route table between builds turns that invisible drift into a line in a pull request.

The audit is worth automating once the route count passes a few dozen. Capture the table as JSON, compare it against a committed expectation, and fail the build when a route changes rendering mode without the expectation being updated. That single check catches the most common and most expensive App Router SEO regression, and it costs nothing at runtime because the analysis has already been done.

Metadata as data rather than markup

Because the metadata API takes an object rather than emitting tags directly, it is worth treating that object as data your application produces and tests, not as a template detail. Build it in a plain function that takes a content entry and returns the metadata shape; call that function from the route. The route becomes trivial and the function becomes unit-testable without a browser, a build, or a server.

This matters most for the fields that are easy to get subtly wrong: the canonical, the robots directive, and the alternate-language map. Each is derived from content rather than invented by the template, and each has a small set of rules that are much easier to assert against a returned object than against rendered HTML. The rendered-HTML assertions remain valuable as an end-to-end check, but they are slow and they tell you only that something is wrong, not which rule was violated.

Server components changed what belongs where

The App Router’s default of server components moved a large amount of code off the client without anyone deciding to move it, and that shift has an SEO consequence worth naming. Content that previously arrived through a client fetch now arrives in the server response by default, which means the baseline for a carelessly written route is considerably better than it used to be.

The corresponding risk is that the boundary is easy to cross accidentally. Marking a component as client-side pulls everything it imports into the client bundle, and a single interactive control near the top of a tree can convert most of a page. The audit worth running is not “which components are client components” but “how much of the rendered output ends up client-side”, which the build output answers directly.

Frequently Asked Questions

Does generateMetadata run on the server? Yes. generateMetadata executes on the server during static generation or the SSR pass, and Next.js serializes its return value into the HTML head before the response is sent. The tags are present in view-source, so crawlers read them without executing JavaScript. The one caveat is that a 'use client' boundary above the segment moves rendering to the client and can strip metadata from the initial HTML.

How do I force a route to be statically generated? Set export const dynamic = 'force-static' on the route segment and provide generateStaticParams for its dynamic params. Avoid request-bound APIs like cookies() and headers() in that segment, since any of them silently opts the route back into dynamic rendering. Confirm the result in the next build route table, where the segment should read as Static or ISR rather than Dynamic.

Why is my canonical tag missing in production build? Almost always because the base URL environment variable is unset in the CI build environment, so the absolute canonical resolves to empty. Set NEXT_PUBLIC_SITE_URL in the build step and rebuild, then confirm the tag with curl against the production URL. This is the most common cause behind canonical mismatches reported in Search Console after a headless launch.


Part of: Framework-Specific SEO Configuration for Headless Stacks

Related