Back/Module P-7 SEO, Metadata, Open Graph, and Sitemaps
Module P-7·24 min read

generateMetadata vs static metadata, generateViewport(), dynamic OG images with ImageResponse, generateImageMetadata() for multiple images, generateSitemaps() for large catalogs, and draftMode() for CMS preview.

P-7 — SEO, Metadata, Open Graph, and Sitemaps

Who this is for: Engineers who know metadata exists but treat it as an afterthought — copy-pasted title tags, missing OG images, sitemaps that were set up once and never maintained. This module covers the full metadata system in Next.js 15, including the parts most tutorials skip: generateViewport, dynamic OG images, generateImageMetadata, and production-safe sitemap splitting.


Why Metadata Matters More in Next.js Than in a SPA

If you built with Create React App or Vite, you likely added something like react-helmet and called it a day. The problem is that when a crawler — Googlebot, the LinkedIn link previewer, the Slack unfurl bot — fetches your page, it runs a quick fetch and reads the HTML. SPAs send a near-empty HTML shell; the metadata is injected by JavaScript after the page loads. Crawlers often do not execute JavaScript, or execute it badly, or time out before it runs.

Next.js with the App Router solves this at the architecture level. Server Components render to actual HTML before the response leaves your server. The <title>, <meta>, and <link> tags are in the initial HTML payload, not injected by a hydration script. Googlebot, Twitter's card validator, and iMessage link previews all see fully-formed metadata on the first request. This is not a minor convenience — it's a fundamental shift in what SEO tooling can do.

The metadata system in Next.js is designed to take advantage of this. You export metadata from route files, and the framework renders it into the document head at build time or request time depending on whether the metadata is static or dynamic.


Static Export vs generateMetadata

The simple case is a static metadata object. You export it from any page.tsx or layout.tsx and Next.js merges it into the document:

tsx
// app/about/page.tsx import type { Metadata } from 'next' export const metadata: Metadata = { title: 'About Us', description: 'The team behind the product.', }

That's it. Next.js picks this up at build time, no work at request time. Use this for any page where the metadata doesn't change based on the content being viewed.

The moment your metadata depends on data — a blog post title, a product name, a user's profile — you need generateMetadata. This is an async function that runs on the server at request time (or at build time for statically-generated routes):

tsx
// app/blog/[slug]/page.tsx import type { Metadata } from 'next' import { getPost } from '@/lib/posts' export async function generateMetadata({ params, }: { params: Promise<{ slug: string }> }): Promise<Metadata> { const { slug } = await params const post = await getPost(slug) return { title: post.title, description: post.excerpt, openGraph: { title: post.title, description: post.excerpt, type: 'article', publishedTime: post.publishedAt, authors: [post.author.name], }, } }

One thing that trips people up: generateMetadata shares the same data-fetching context as the page component. If both the page and generateMetadata call getPost(slug), React's request memoization deduplicates the fetch — you are not making two network requests. This is by design. Don't contort your code to avoid calling the same data function twice.


generateViewport — the Forgotten Sibling

In Next.js 15, viewport-related settings were split out of the metadata object into a separate export. If you try to put viewport, colorScheme, or themeColor inside a metadata export now, you get a deprecation warning.

tsx
// app/layout.tsx import type { Viewport } from 'next' export const viewport: Viewport = { width: 'device-width', initialScale: 1, maximumScale: 5, themeColor: [ { media: '(prefers-color-scheme: light)', color: '#ffffff' }, { media: '(prefers-color-scheme: dark)', color: '#0a0a0a' }, ], colorScheme: 'dark light', }

The themeColor array with media queries is the right pattern for apps that support both light and dark modes. Chrome on Android uses this to color the browser chrome. Safari on iOS uses it for the status bar. It does matter, particularly for PWAs.

Like metadata, you can also export a generateViewport function if your viewport settings depend on runtime data (unusual, but possible).


Title Templates and Brand Suffixes

Every real product has a brand name in every page title: "Dashboard — Acme" or "Acme | Dashboard." Manually appending this to every page title is an error waiting to happen. The title.template field handles it automatically.

Define the template in your root layout and use the %s placeholder:

tsx
// app/layout.tsx export const metadata: Metadata = { title: { template: '%s — Acme', default: 'Acme — Build faster', }, }

Then any child page just sets the bare title:

tsx
// app/dashboard/page.tsx export const metadata: Metadata = { title: 'Dashboard', // renders as "Dashboard — Acme" }

The default field is what renders when a child page doesn't export any title at all — useful for pages that haven't been updated yet or dynamic route segments that don't match. If you want a nested segment (say, the settings area) to have its own template like "%s — Settings — Acme", you can export a new template from app/settings/layout.tsx and it overrides the parent for everything below it.


metadataBase — the Mistake You Will Make in Production

Relative URLs in metadata objects don't work for Open Graph images. The OG protocol and Twitter Cards require absolute URLs — https://example.com/og.png, not /og.png. Next.js knows this, and it will automatically make relative URLs absolute if you tell it the base:

tsx
// app/layout.tsx export const metadata: Metadata = { metadataBase: new URL('https://acme.com'), }

Without this, any relative image paths in your openGraph.images array will either throw a warning or render as relative paths in the HTML, which means every link preview will show a broken image.

The classic mistake: you set this up in development with http://localhost:3000 hard-coded, then forget to change it for production. The fix is to drive it from an environment variable:

tsx
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? 'https://acme.com'),

The Open Graph Object in Full

OG metadata is what makes links unfurl with a rich card in Slack, Twitter, iMessage, and LinkedIn. The openGraph object in your metadata handles all of it:

tsx
openGraph: { title: post.title, description: post.excerpt, url: `https://acme.com/blog/${post.slug}`, siteName: 'Acme Blog', images: [ { url: `/api/og?slug=${post.slug}`, width: 1200, height: 630, alt: post.title, }, ], type: 'article', publishedTime: post.publishedAt, modifiedTime: post.updatedAt, authors: ['https://acme.com/authors/alice'], tags: post.tags, }

The images array accepts multiple entries — this is useful if you have images at multiple aspect ratios. Always include width, height, and alt. The OG spec requires them, and scrapers use the dimensions to decide how to lay out the card.

For Twitter Cards specifically, the type matters. summary gives a small square thumbnail in the timeline — fine for most pages. summary_large_image gives a full-width banner. If your OG image is designed at 1200×630, use summary_large_image. If it's a small product icon or avatar, use summary. Twitter's card validator will show you how each renders before you ship.

tsx
twitter: { card: 'summary_large_image', title: post.title, description: post.excerpt, images: [`/api/og?slug=${post.slug}`], creator: '@acmehq', }

Dynamic OG Images with ImageResponse

Automated OG images that pull in post titles, author names, and brand imagery are a step change in click-through rate. next/og makes this possible without a headless browser or an external service.

The mechanism: you create a Route Handler that returns an ImageResponse. ImageResponse takes JSX and renders it via resvg (a Rust-based SVG renderer) to a PNG. The whole thing runs on the Edge runtime, so cold starts are negligible and it scales automatically.

tsx
// app/api/og/route.tsx import { ImageResponse } from 'next/og' export const runtime = 'edge' export async function GET(request: Request) { const { searchParams } = new URL(request.url) const title = searchParams.get('title') ?? 'Acme' const author = searchParams.get('author') ?? '' const fontData = await fetch( new URL('/fonts/Inter-Bold.ttf', request.url) ).then((res) => res.arrayBuffer()) return new ImageResponse( ( <div style={{ background: 'linear-gradient(135deg, #0f172a 0%, #1e3a5f 100%)', width: '100%', height: '100%', display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: '60px', fontFamily: 'Inter', }} > <div style={{ display: 'flex', alignItems: 'center' }}> {/* eslint-disable-next-line @next/next/no-img-element */} <img src="https://acme.com/logo.png" width={48} height={48} alt="Acme" /> <span style={{ color: '#94a3b8', marginLeft: 16, fontSize: 24 }}> Acme Blog </span> </div> <div> <p style={{ color: '#f8fafc', fontSize: 56, fontWeight: 700, lineHeight: 1.2, margin: 0, }} > {title} </p> {author && ( <p style={{ color: '#94a3b8', fontSize: 28, marginTop: 16 }}> by {author} </p> )} </div> </div> ), { width: 1200, height: 630, fonts: [{ name: 'Inter', data: fontData, weight: 700 }], } ) }

A critical detail: fonts must be fetched as ArrayBuffer, not as text. The resvg renderer needs the raw binary. The fetch-then-arrayBuffer() chain is the correct pattern. If you try to pass a URL string directly, the renderer won't load it.

The JSX is not full React JSX — it's a subset. Flexbox is supported. CSS Grid is not. position: absolute works. Complex CSS selectors do not. Think of it as a simplified layout engine with a subset of CSS. If you hit a layout edge case, the satori documentation (the underlying library) lists what's supported.

Cache this route aggressively. OG images are expensive to generate. Add a Cache-Control: public, max-age=86400, stale-while-revalidate header, or use unstable_cache if you're computing expensive data inside the handler.


generateImageMetadata for Multi-Image Pages

Product detail pages, portfolio entries, and gallery pages often have multiple meaningful images. generateImageMetadata lets you tell Next.js about all of them:

tsx
// app/products/[id]/opengraph-image.tsx import { ImageResponse } from 'next/og' import { getProduct } from '@/lib/products' export async function generateImageMetadata({ params, }: { params: Promise<{ id: string }> }) { const { id } = await params const product = await getProduct(id) return product.images.map((img, index) => ({ id: index, alt: img.alt, size: { width: 1200, height: 630 }, contentType: 'image/png', })) } export default async function Image({ params, id, }: { params: Promise<{ id: string }> id: number }) { const { id: productId } = await params const product = await getProduct(productId) const image = product.images[id] return new ImageResponse(<div>{/* render image[id] */}</div>, { width: 1200, height: 630, }) }

This is an advanced pattern most apps don't need. But if you're building a product catalog where each product has multiple shareable images, this is how you expose all of them to scrapers properly.


Robots and Canonical URLs

Staging environments should not be indexed. Neither should search result pages, filtered views with ?sort=price, or admin routes. The robots field gives you per-route control:

tsx
// app/search/page.tsx — don't index search result pages export const metadata: Metadata = { robots: { index: false, follow: true, }, }

For staging, you want a blanket block. The simplest approach is a conditional in your root layout:

tsx
robots: process.env.NEXT_PUBLIC_ENVIRONMENT === 'production' ? { index: true, follow: true } : { index: false, follow: false }

Canonical URLs prevent duplicate content penalties when the same content is accessible via multiple URLs — /products/shirt and /products/shirt?color=blue are the same product. Declare the canonical:

tsx
alternates: { canonical: `https://acme.com/products/${product.slug}`, }

app/sitemap.ts and generateSitemaps

The sitemap.ts convention at the root of your app directory generates a sitemap automatically. For small sites, one function is all you need:

ts
// app/sitemap.ts import type { MetadataRoute } from 'next' import { getAllPosts } from '@/lib/posts' export default async function sitemap(): Promise<MetadataRoute.Sitemap> { const posts = await getAllPosts() return [ { url: 'https://acme.com', lastModified: new Date(), changeFrequency: 'weekly', priority: 1 }, ...posts.map((post) => ({ url: `https://acme.com/blog/${post.slug}`, lastModified: new Date(post.updatedAt), changeFrequency: 'monthly' as const, priority: 0.8, })), ] }

At scale — tens of thousands of URLs — one sitemap becomes a problem. The Sitemap protocol has a 50,000 URL limit and a 50MB file size limit. generateSitemaps splits your catalog into multiple sitemaps, each referenced by a sitemap index:

ts
// app/sitemap.ts export async function generateSitemaps() { const count = await getProductCount() const pageSize = 10_000 return Array.from({ length: Math.ceil(count / pageSize) }, (_, i) => ({ id: i })) } export default async function sitemap({ id }: { id: number }): Promise<MetadataRoute.Sitemap> { const products = await getProducts({ page: id, pageSize: 10_000 }) return products.map((p) => ({ url: `https://acme.com/products/${p.slug}`, lastModified: new Date(p.updatedAt), })) }

Next.js automatically generates a sitemap index at /sitemap.xml that references /sitemap/0.xml, /sitemap/1.xml, and so on.


app/robots.ts

The robots.txt convention is simpler than sitemap but follows the same pattern — a file at app/robots.ts that exports a default function:

ts
// app/robots.ts import type { MetadataRoute } from 'next' export default function robots(): MetadataRoute.Robots { return { rules: [ { userAgent: '*', allow: '/', disallow: ['/admin/', '/api/', '/_next/'], }, { userAgent: 'GPTBot', disallow: '/', }, ], sitemap: 'https://acme.com/sitemap.xml', } }

The GPTBot rule is optional but increasingly common — it tells OpenAI's crawler whether it can index your content. Whether you want that is a product decision, not a technical one.

A note on priority: search engines do not weight priority and changeFrequency heavily. Google has said publicly that it largely ignores them and uses its own crawl signals instead. Set them to reasonable values for human readability, but don't spend time optimizing them.

The combination of these pieces — solid metadata, dynamic OG images, proper canonical URLs, and a maintained sitemap — is what separates applications that perform well in organic search from ones that technically have good content but can't be found.

Discussion