The goal
Every page on the site has a unique title under sixty characters, a description that fits the snippet, a self-referencing canonical, correct Open Graph tags, and the structured data its type warrants. The sitemap lists exactly the indexable pages. None of this drifts when someone adds a page. That last requirement is the hard one, and the App Router has the pieces to meet it if you centralise.
One registry, not scattered exports
The temptation is a metadata export in each page.tsx. It works and it drifts: titles get too long, descriptions get copy-pasted, a new page forgets the canonical. Instead, keep one registry of route metadata and have pages read from it.
// lib/seo/index.ts
export type SeoEntry = {
path: string
title: string // ≤ 48 chars, suffix appended
description: string // 110–160 chars
h1: string
}
export const SEO_ROUTES: Record<string, SeoEntry> = {
'/': { path: '/', title: 'Web design for booking-led businesses', description: '...', h1: '...' },
'/services/nextjs-development': { path: '/services/nextjs-development', title: '...', description: '...', h1: '...' },
// ...
}Then a helper builds the Metadata object with the site-wide defaults applied:
// lib/seo/metadata.ts
import type { Metadata } from 'next'
import { siteConfig } from '@/lib/metadata'
import { SEO_ROUTES } from './index'
export function metadataFor(path: string): Metadata {
const e = SEO_ROUTES[path]
if (!e) throw new Error(`No SEO entry for ${path}`)
const url = new URL(path, siteConfig.url).toString()
return {
title: `${e.title} | ${siteConfig.name}`,
description: e.description,
alternates: { canonical: url },
openGraph: {
title: e.title,
description: e.description,
url,
siteName: siteConfig.name,
type: 'website',
images: [{ url: `${siteConfig.url}/og?title=${encodeURIComponent(e.title)}` }],
},
twitter: { card: 'summary_large_image' },
}
}A page becomes export const metadata = metadataFor('/services/nextjs-development'). For dynamic routes, generateMetadata looks up the entry by the resolved path. A missing entry throws at build, which is exactly what you want.
Enforce the limits
A registry lets you lint it. A small script run before build checks every entry: title length, description length, uniqueness across the site, an H1 of at least three words. It fails the build when someone adds a 90-character title. This is the mechanism that stops drift, and it is more valuable than any individual tag.
Root layout defaults
The root layout.tsx metadata sets metadataBase to the production URL, so relative image paths resolve, and provides the fallback title.template. Do not set a description at the root: a fallback description is a duplicate description on any page that forgets its own, and the registry check should catch the forgetting instead.
sitemap.ts and robots.ts
Generate the sitemap from the same registry plus your dynamic content, so it cannot list a page that does not exist or miss one that does:
// app/sitemap.ts
import type { MetadataRoute } from 'next'
import { SEO_ROUTES } from '@/lib/seo'
import { getAllPosts } from '@/lib/mdx'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const base = 'https://example.com'
const staticRoutes = Object.keys(SEO_ROUTES).map((p) => ({
url: `${base}${p}`,
changeFrequency: 'monthly' as const,
priority: p === '/' ? 1 : 0.7,
}))
const posts = (await getAllPosts()).map((post) => ({
url: `${base}/blog/${post.slug}`,
lastModified: post.date,
changeFrequency: 'yearly' as const,
priority: 0.5,
}))
return [...staticRoutes, ...posts]
}robots.ts allows everything, disallows any private paths, and points at the sitemap. Keep staging out of the index with a noindex header set from an environment variable, not by editing robots.
Structured data without a library
JSON-LD is a script tag with a JSON object. Build it from the same data the page renders, so it cannot disagree with the page:
// components/JsonLd.tsx
export function JsonLd({ data }: { data: Record<string, unknown> }) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>
)
}Site-wide, on the root layout: Organization (or ProfessionalService for a local business) with name, URL, logo, contact and social profiles, plus WebSite. Per page type: Service on service pages, BlogPosting on posts with datePublished, author and an absolute image URL, FAQPage where a page has real question-and-answer content, BreadcrumbList on nested pages. Emit only what is true and visible on the page.
Validate in the Rich Results Test after each new type. The common mistakes are relative image URLs, a BlogPosting with no image, and FAQPage on pages with no visible FAQ.
Open Graph images
A dynamic opengraph-image.tsx or an /og route rendering the title into a branded image gives every page a share card without a designer touching each one. Cache it for as long as the page.
The checks that catch drift
- The registry lint at build: lengths, uniqueness, missing entries.
- A crawl of the built site comparing each page's rendered title and canonical to the registry.
- Search Console's Pages and Enhancements reports monthly for duplicate titles, excluded pages and structured data errors.
With those three, the SEO plumbing stays correct as the site grows, and adding a page is one registry entry plus the page, with the build refusing anything that does not fit. This is how every Next.js site we build handles metadata, and it is the difference between SEO that is set up once and SEO that quietly decays.