Good metadata is the cheapest ranking and CTR win you can ship this week. In Next.js App Router, you can set meta at layout, route, and dynamic levels without extra packages.
This tutorial shows technical SEO for product teams how to add, validate, and automate meta tags in Next.js App Router. You will learn route-level metadata, dynamic params, Open Graph, Twitter, robots, sitemaps, and QA gates. Key takeaway: treat metadata as a system with typed inputs, automation, and tests so it ships fast and stays correct at scale.
What You Will Build and Why It Matters for Technical SEO for Product Teams
We add canonical, title, description, Open Graph, Twitter, and robots to App Router routes. We wire a metadata helper for programmatic SEO. We add checks to prevent empty or duplicate tags.
Outcome and Success Criteria
- Titles and descriptions render server side with correct fallbacks.
- Canonicals resolve to absolute URLs per environment.
- Open Graph and Twitter cards validate in scrapers.
- Robots tags and sitemap exclude non index pages.
Assumptions and Dependencies
- Next.js 13.4 or later with App Router.
- TypeScript enabled.
- Deployed on Vercel or similar with environment variables for base URL.
App Router Metadata Primitives You Need to Know
Next.js exposes first class metadata via exports and static files. Use them to avoid hand writing head tags.
The metadata Object and generateMetadata
- metadata in layout.tsx or page.tsx sets static values.
- generateMetadata uses params, searchParams, or fetched data to return Metadata.
- Next merges nearest metadata from root to leaf.
Example static:
// app/blog/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: {
default: 'Blog | Acme',
template: '%s | Acme'
},
robots: { index: true, follow: true }
}
export default function BlogLayout({ children }: { children: React.ReactNode }) {
return <section>{children}</section>
}
Example dynamic:
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
import { getPostBySlug } from '@/lib/posts'
export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
const post = await getPostBySlug(params.slug)
if (!post) return { robots: { index: false, follow: false } }
const url = new URL(`/blog/${post.slug}`, process.env.NEXT_PUBLIC_BASE_URL)
return {
title: post.title,
description: post.excerpt,
alternates: { canonical: url.toString() },
openGraph: {
type: 'article',
url: url.toString(),
title: post.title,
description: post.excerpt,
images: post.ogImage ? [{ url: post.ogImage, width: 1200, height: 630, alt: post.title }] : undefined
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
images: post.ogImage ? [post.ogImage] : undefined
}
}
}
export default async function Page() { /* ... */ }
Static Files: robots.txt and sitemap.xml
Use next-sitemap or route handlers to generate. Prefer a typed route handler for fine control.
// app/robots.ts
import type { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
const base = process.env.NEXT_PUBLIC_BASE_URL!
return {
rules: [{ userAgent: '*', allow: '/' }],
sitemap: `${base}/sitemap.xml`
}
}
A Minimal Metadata System for Programmatic SEO
Ship a reusable helper so developers set metadata with guardrails. Treat it like any other API.
Define a Strongly Typed Builder
// lib/seo.ts
import type { Metadata } from 'next'
type SEOInput = {
title?: string
description?: string
path?: string
image?: { url: string; width?: number; height?: number; alt?: string }
index?: boolean
}
const BASE = process.env.NEXT_PUBLIC_BASE_URL!
export function buildMetadata(input: SEOInput): Metadata {
const title = input.title?.trim() || 'Acme'
const description = input.description?.trim() || 'Acme product site.'
const canonical = new URL(input.path || '/', BASE).toString()
const index = input.index ?? true
return {
title,
description,
alternates: { canonical },
robots: { index, follow: index },
openGraph: {
url: canonical,
title,
description,
images: input.image ? [{
url: input.image.url,
width: input.image.width ?? 1200,
height: input.image.height ?? 630,
alt: input.image.alt ?? title
}] : undefined
},
twitter: {
card: input.image ? 'summary_large_image' : 'summary',
title,
description,
images: input.image ? [input.image.url] : undefined
}
}
}
Use the Builder in Routes
// app/docs/[slug]/page.tsx
import { buildMetadata } from '@/lib/seo'
import type { Metadata } from 'next'
import { getDoc } from '@/lib/docs'
export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
const doc = await getDoc(params.slug)
if (!doc) return buildMetadata({ title: 'Not found', path: '/404', index: false })
return buildMetadata({
title: doc.title,
description: doc.summary,
path: `/docs/${doc.slug}`,
image: doc.ogImage ? { url: doc.ogImage } : undefined
})
}
Canonicals, Indexability, and Pagination
Avoid duplicate content by setting consistent canonicals and robots at the leaf.
Absolute Canonicals Per Environment
- Always compute from NEXT_PUBLIC_BASE_URL.
- Avoid mixing trailing slashes. Pick one.
- For query variants, set canonical to the clean path.
alternates: { canonical: new URL('/pricing', BASE).toString() }
Robots Controls for Non Index Pages
- Set index false for search, cart, auth, and filtered variants.
- Keep follow true unless pages are gated.
robots: { index: false, follow: true }
Open Graph and Twitter Cards That Render Correctly
Social previews drive CTR. Ensure images resolve and titles fit.
Image Requirements and Fallbacks
- Size 1200x630 PNG or JPG for large card.
- Host on your domain or a reliable CDN.
- Provide alt text.
- Fallback to a brand default if post lacks an image.
Validate in Scrapers
- Use the Meta Tags Inspector, Facebook Sharing Debugger, and X Card Validator.
- Expect caches. Trigger re-scrape after deploys with image changes.
Route Handlers for robots and sitemaps at Scale
Route handlers give programmatic control for large catalogs.
Programmatic robots
// app/robots.ts
export default function robots() {
const disallow = process.env.VERCEL_ENV === 'preview'
return {
rules: [{ userAgent: '*', allow: disallow ? '' : '/', disallow: disallow ? '/' : '' }],
sitemap: disallow ? undefined : `${process.env.NEXT_PUBLIC_BASE_URL}/sitemap.xml`
}
}
Programmatic sitemap with chunks
// app/sitemap.ts
import type { MetadataRoute } from 'next'
import { listPosts } from '@/lib/posts'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const base = process.env.NEXT_PUBLIC_BASE_URL!
const posts = await listPosts()
return [
{ url: `${base}/`, changeFrequency: 'weekly', priority: 1 },
...posts.map(p => ({ url: `${base}/blog/${p.slug}`, changeFrequency: 'weekly', priority: 0.8 }))
]
}
QA Gates and Automation Workflows
Prevent regressions with CI checks and runtime guards. Treat metadata like schema you can test.
Lint and Type Checks
- Enable strict null checks. Make title and description required in content schemas.
- Write a unit test for buildMetadata with empty inputs and long strings.
// seo.spec.ts
import { buildMetadata } from './seo'
test('fills defaults and forms absolute canonical', () => {
process.env.NEXT_PUBLIC_BASE_URL = 'https://acme.com'
const m = buildMetadata({ path: '/foo' })
expect(m.alternates?.canonical).toBe('https://acme.com/foo')
expect(m.title).toBe('Acme')
})
CI Scanner for Empty or Duplicate Tags
- During build, scan rendered HTML of key routes.
- Fail build if title is empty or duplicates across different URLs.
## simple check
pnpm build && node scripts/check-meta.js
// scripts/check-meta.js
import fs from 'node:fs'
const routes = ['/', '/blog']
for (const r of routes) {
const html = fs.readFileSync(`.next/server/app${r === '/' ? '/index' : r}/page.html`, 'utf8')
if (!html.match(/<title>.+<\/title>/)) throw new Error(`Missing title on ${r}`)
}
Common Failure Modes and Rollbacks
Know how metadata breaks and how to recover quickly.
Failure Modes
- Wrong base URL creates relative or mixed domain canonicals.
- Missing og:image returns generic previews on social.
- Preview deployments get indexed due to permissive robots.
- Duplicate titles across paginated pages.
Rollbacks and Safeguards
- Gate robots to disallow on preview by environment.
- Add default brand OG image at layout.
- Enforce unique title templates per section with title.template.
- Add a canary test that hits a deployed preview and asserts meta.
Example: Blog Post Route End to End
This example wires content data to generateMetadata with safety checks.
// contentlayer or custom source assumed
// app/blog/[slug]/page.tsx
import { buildMetadata } from '@/lib/seo'
import { getPostBySlug } from '@/lib/posts'
export async function generateMetadata({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug)
if (!post) return buildMetadata({ title: 'Not found', path: '/404', index: false })
return buildMetadata({
title: post.title,
description: post.excerpt || post.title,
path: `/blog/${post.slug}`,
image: post.ogImage ? { url: post.ogImage } : undefined
})
}
export default async function Page({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug)
if (!post) return null
return <article>{/* render markdown */}</article>
}
Tooling Comparison for Metadata and Sitemaps
Below is a quick comparison to choose the right approach for your stack.
| Option | Best for | Pros | Cons | Notes |
|---|---|---|---|---|
| Native metadata exports | Most apps | Typed, SSR, merged by layout | Limited to Next features | Use for 90 percent of pages |
| Route handlers | Large catalogs | Full control, dynamic | More code | Use for robots and sitemaps |
| next-sitemap | Simple sites | Zero code sitemap | Less flexible | Fine for small blogs |
| Custom head tags | Legacy pages | Precise overrides | Easy to drift | Avoid if possible |
Distribution and Experiment Loops After Shipping
Treat this tutorial as an execution playbook. Ship, measure, and iterate.
Distribution Loop
- Publish a changelog post outlining the metadata improvements.
- Share before and after previews on social with UTM tags.
- Add internal docs and code pointers for new routes.
Experiment Loop
- Track CTR uplift in Search Console on pages with new OG images.
- A/B test title templates for core categories.
- Measure crawler errors after sitemap rollout and fix within 48 hours.
Key Takeaways
- Use App Router metadata and generateMetadata for typed, SSR meta.
- Centralize logic in a buildMetadata helper with safe defaults.
- Set absolute canonicals and strict robots per environment.
- Validate OG and Twitter cards with scrapers and CI checks.
- Close the loop with distribution and CTR experiments.
Ship the helper, add tests, and review metrics weekly. Small metadata fixes compound fast.
