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 andsitemap.ts/robots.tsconventions 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_URLset to a protocol-prefixed absolute domain (e.g.https://example.com) in every build environmentcurlavailable 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.
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
- Configuring generateMetadata for SEO in Next.js App Router β async metadata from the CMS,
alternates.canonical, Open Graph, and guaranteeing server-rendered head tags - Controlling Static and Dynamic Rendering in Next.js App Router β segment config,
generateStaticParams,dynamicParams, and avoiding accidental dynamic rendering
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
- SvelteKit SEO Configuration β the same four surfaces wired through prerender flags and
svelte:head - Nuxt SEO Configuration β hybrid rendering and metadata via
routeRulesanduseSeoMeta - Canonical URL Enforcement β the cross-layer canonical strategy that
generateMetadataplugs into - ISR vs SSG vs CSR Routing β choosing the rendering mode you declare in the segment config
- Crawl Budget Impact in Headless β how static and ISR rendering conserve crawler quota