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

# 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

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

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