Next.js

The Metadata API and SEO in Next.js

Using generateMetadata and the static metadata export to control title, description, Open Graph tags, and JSON-LD structured data from the App Router.

The App Router replaced the old <Head> component approach with a proper Metadata API. Instead of importing next/head and sprinkling tags anywhere, you export a metadata object or a generateMetadata function from your route files, and Next.js assembles the <head> for you. It's more predictable and more composable.

Static Metadata

For pages where the metadata doesn't depend on runtime data, a plain export is all you need.

// app/about/page.tsx
import type { Metadata } from 'next'

export const metadata: Metadata = {
    title: 'About',
    description: 'Software engineer building with React, Next.js, and AI agents.',
    alternates: {
        canonical: 'https://paulund.co.uk/about',
    },
}

export default function AboutPage() {
    return <main>...</main>
}

Next.js reads this at build time and injects the correct tags. The alternates.canonical field becomes <link rel="canonical" href="..." />. No manual <link> tags, no forgetting to set it on some pages.

Title Templates

One thing that saves a lot of repetition is defining a title template in the root layout. You set the template once and every child page just provides its own segment.

// app/layout.tsx
import type { Metadata } from 'next'

export const metadata: Metadata = {
    title: {
        template: '%s | Paulund',
        default: 'Paulund',
    },
    description: 'Software engineer writing about React, Next.js, and modern web development.',
}

Now any page that exports title: 'About' gets rendered as About | Paulund in the browser tab. The root layout's default covers pages that don't set a title at all.

Dynamic Metadata with generateMetadata

For dynamic routes (like a blog post or notebook article), you need to fetch data before you can know the title. That's where generateMetadata comes in.

// app/notebook/[section]/[slug]/page.tsx
import type { Metadata } from 'next'
import { getNote } from '@/lib/notebook'

type Params = { section: string; slug: string }

export async function generateMetadata({
    params,
}: {
    params: Promise<Params>
}): Promise<Metadata> {
    const { section, slug } = await params
    const note = await getNote(section, slug)
    const url = `https://paulund.co.uk/notebook/${section}/${slug}`

    return {
        title: note.title,
        description: note.description,
        alternates: { canonical: url },
        openGraph: {
            title: note.title,
            description: note.description,
            url,
            type: 'article',
            publishedTime: note.date,
            tags: note.tags,
        },
    }
}

Next.js can deduplicate cached fetch() calls between generateMetadata and your page component, but a custom filesystem helper like getNote() is not automatically reused. In this setup, getNote() will run again unless you explicitly memoize it with something like React cache() or a shared memoized loader.

Open Graph Images

You can point openGraph.images at a static file in public/, or use a dynamic OG image route. For a static site where most pages share the same image, a single default works fine.

openGraph: {
    images: [
        {
            url: 'https://paulund.co.uk/images/og-default.jpg',
            width: 1200,
            height: 630,
            alt: 'Paulund',
        },
    ],
}

For per-article images, you can add an image field to your markdown frontmatter and pull it through. Just make sure the URL is absolute. Search engines and social platforms won't resolve relative paths in OG tags.

JSON-LD Structured Data

JSON-LD is the recommended way to add structured data for rich search results. The Metadata API doesn't have a built-in field for it, but injecting a <script> tag from a Server Component is straightforward.

// components/json-ld.tsx
export function JsonLd({ data }: { data: Record<string, unknown> }) {
    return (
        <script
            type="application/ld+json"
            dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
        />
    )
}

Then in your page, compose the schema and drop it in:

// app/notebook/[section]/[slug]/page.tsx
import { JsonLd } from '@/components/json-ld'
import { schemaArticle } from '@/lib/seo'

export default async function NotePage({
    params,
}: {
    params: Promise<Params>
}) {
    const { section, slug } = await params
    const note = await getNote(section, slug)
    const url = `https://paulund.co.uk/notebook/${section}/${slug}`

    const schema = schemaArticle(note.title, note.description, url, section, {
        datePublished: note.date,
        keywords: note.tags,
    })

    return (
        <article>
            <JsonLd data={schema} />
            <h1>{note.title}</h1>
            {note.content}
        </article>
    )
}

This outputs a BlogPosting schema with author, publisher, datePublished, and keywords. Google can use this to generate rich results in search.

Canonical URLs

Getting canonicals right matters more than most developers think. Duplicate content across slightly different URLs (trailing slash, uppercase, query strings) can dilute search rankings. The Metadata API makes it easy to be explicit.

alternates: {
    canonical: `https://paulund.co.uk/notebook/${section}/${slug}`,
}

Set this on every page. For static pages, hard-code it. For dynamic pages, build it from params the same way you build the OG URL. Consistency is what matters. Pick a base URL, use it everywhere, and don't mix trailing slash and non-trailing slash variants.

Putting It Together

The pattern that works well for a content site is to centralise schema helpers in a lib/seo.ts file, keep a reusable JsonLd component, and let each page or generateMetadata call compose what it needs. Static pages get a plain metadata export. Dynamic pages get generateMetadata. Both set canonical URLs and OG tags. And any page that benefits from structured data gets a <JsonLd> in the render output.

The Metadata API doesn't do everything automatically, but it gives you the right abstractions. You write the data once, in the right place, and Next.js handles the plumbing.

← Older
The Next.js App Router Mental Model
Newer →
Server Actions in Next.js 15

Newsletter

A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.