Subscribe
Technical SEOGrowth Engineering

How to Add Meta Tags in Next.js App Router

⚡ Powered by AutoBlogWriter
GGrowthHackerDev7 min read
How to Add Meta Tags in Next.js App Router

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.

OptionBest forProsConsNotes
Native metadata exportsMost appsTyped, SSR, merged by layoutLimited to Next featuresUse for 90 percent of pages
Route handlersLarge catalogsFull control, dynamicMore codeUse for robots and sitemaps
next-sitemapSimple sitesZero code sitemapLess flexibleFine for small blogs
Custom head tagsLegacy pagesPrecise overridesEasy to driftAvoid 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.

Behind this blog

AutoBlogWriter

This blog runs on AutoBlogWriter. It automates the entire content pipeline including research, SEO structure, article generation, images, and publishing.

See how the system works

System parallels

Implementation FAQ

What Next.js version supports the metadata API?

Next.js 13.4 and later support App Router metadata and route handlers for robots and sitemaps.

Should canonicals be absolute URLs?

Yes. Use your public base URL to form absolute canonicals. This avoids mixed domains and improves consistency.

How do I prevent preview deploys from being indexed?

Set robots to disallow in preview environments using a conditional in app/robots.ts based on VERCEL_ENV or a custom flag.

What size should Open Graph images be?

Use 1200x630 for large cards. Host on your domain or a reliable CDN. Provide alt text for accessibility and relevance.

Do I still need next/head with App Router?

No. Prefer the metadata API. Use next/head only for edge cases that metadata does not cover.

Ship growth systems faster

Reserve your spot for weekly deep dives into technical growth, SEO architecture, and scalable product systems.

Reserve your spot