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
Metadata in the App Router merges downward, so a value can come from three places and the resolved result is what matters.
# 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.
| 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
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
- 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