Configuring generateMetadata for SEO in Next.js App Router
Drive per-page title, description, canonical, and Open Graph tags from your CMS through the App Router’s async generateMetadata so they land in the server-rendered head where crawlers can read them.
When to Use This Approach
Reach for generateMetadata when any of these apply:
- App Router routes need per-page metadata sourced from a CMS entry, not static strings hard-coded in the layout.
- You need
alternates.canonicaland Open Graph tags resolved per slug and present inview-source. - Your metadata renders correctly in the browser but is missing from the raw HTML because it is currently set client-side.
Implementation Steps
Step 1: Export an async generateMetadata
Add the export to the route segment with typed params. In Next.js 15+ params is a promise, so the function must be async.
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
export async function generateMetadata(
{ params }: { params: Promise<{ slug: string }> }
): Promise<Metadata> {
const { slug } = await params;
return { title: slug }; // placeholder, expanded below
}
Validation: next build completes without a type error and the route appears in the build table.
Step 2: Fetch the CMS entry and build the canonical
Await the entry inside the function and assemble an absolute canonical from the base URL env variable.
const SITE = process.env.NEXT_PUBLIC_SITE_URL ?? '';
const entry = await getCmsEntry(slug);
const canonical = `${SITE}/blog/${slug}/`;
Validation: node -e "console.log(process.env.NEXT_PUBLIC_SITE_URL)" in the build env prints the absolute domain, not undefined.
Step 3: Return title, description, canonical, and Open Graph
Return a complete Metadata object so all crawler-relevant tags are emitted together.
return {
title: entry.title,
description: entry.excerpt,
alternates: { canonical },
openGraph: {
title: entry.title,
description: entry.excerpt,
url: canonical,
type: 'article',
},
};
Validation: curl -s https://example.com/blog/example | grep -iE 'og:url|rel="canonical"' returns the same absolute URL for both.
Step 4: Deduplicate repeated fields with a title template
Move shared suffixes and site-wide defaults into the layout so each page function stays focused on its unique fields.
// app/layout.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: { default: 'Example', template: '%s | Example' },
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? 'https://example.com'),
};
Validation: curl -s https://example.com/blog/example | grep -i '<title>' shows the page title composed with the | Example suffix exactly once.
Step 5: Validate the server-rendered head
Confirm every tag is in the raw response, not injected after hydration.
curl -s https://example.com/blog/example \
| grep -iE '<title>|name="description"|rel="canonical"|og:title'
Validation: all four tags appear in the raw body. A missing tag indicates client-side injection that must move into generateMetadata.
SEO Impact Summary
| Signal | What improves | What breaks if misconfigured |
|---|---|---|
| Title & description | Unique, CMS-driven tags in the first response boost relevance and CTR | Placeholder or duplicate titles across routes dilute rankings |
| Canonical | alternates.canonical consolidates signals on one absolute URL |
Relative or missing canonical triggers duplicate-content selection in Search Console |
| Open Graph | Correct social previews resolved per slug | Empty og:url breaks share cards and can mismatch the canonical |
| Server rendering | Tags in view-source are read without JavaScript execution |
Client-injected tags may be missed on the first crawl wave |
Measurable signals to watch:
- Search Console “Duplicate, Google chose different canonical” count should trend to zero after deployment.
- The share of indexed pages with a correct, unique title should approach 100% in the coverage report.
curlspot-checks across a URL sample should show the canonical present on every page.
Edge Cases and Gotchas
A client boundary above the segment strips metadata
If a 'use client' component wraps the route above the metadata-generating segment, Next.js renders the branch on the client and the head tags can disappear from the initial HTML. Keep the metadata-owning segment as a server component and push interactivity into leaf client components.
Preview and draft entries need explicit robots control
When generateMetadata runs for an unpublished entry (preview mode), set robots: { index: false } so drafts never reach the index. Drive the flag from the CMS publish state rather than hard-coding it.
metadataBase must be set for relative Open Graph assets
If openGraph.images uses relative paths, an unset metadataBase yields broken absolute URLs. Define metadataBase in the layout so image and canonical resolution share one origin.
Trailing-slash policy must match the route
A canonical of /blog/slug/ while the route serves /blog/slug (or vice versa) creates a mismatch. Align the canonical string with your trailingSlash config; the full fix is covered in fixing canonical mismatches in Next.js App Router.
Frequently Asked Questions
Does generateMetadata run on the server or the client?
It runs on the server, during static generation or the SSR pass. Next.js serializes the returned Metadata object into the HTML head before the response is flushed, so the tags exist in view-source without any JavaScript execution. The exception is when a 'use client' boundary sits above the segment, which moves rendering to the client.
How do I set the canonical URL in generateMetadata?
Return an alternates.canonical value built from an absolute base URL env variable plus the route path. Never derive it from window.location, which is unavailable on the server and produces a relative or empty canonical in the HTML. This keeps the tag aligned with the broader canonical URL enforcement strategy.
Why is my metadata missing from view-source?
The metadata is being set in a client component or added after hydration. Move all title, description, and canonical logic into the server-side generateMetadata export so it is serialized into the initial HTML rather than injected by the browser. Confirm the fix with a curl grep against the production URL.
Part of: Next.js App Router SEO Configuration
Related
- Fixing Canonical Mismatches in Next.js App Router — resolve Google-chosen-canonical disputes rooted in metadata and trailing-slash config
- Framework-Specific SEO Configuration for Headless Stacks — how metadata injection compares across Next.js, SvelteKit, and Nuxt
- Next.js App Router SEO Configuration — the full segment-config, sitemap, and robots workflow this page fits into