# Metadata Add SEO metadata to Next.js pages using the Metadata API. ## Important: Server Components Only The `metadata` object and `generateMetadata` function are **only supported in Server Components**. They cannot be used in Client Components. If the target page has `'use client'`: 1. Remove `'use client'` if possible, move client logic to child components 2. Or extract metadata to a parent Server Component layout 3. Or split the file: Server Component with metadata imports Client Components ## Static Metadata ```tsx import type { Metadata } from 'next' export const metadata: Metadata = { title: 'Page Title', description: 'Page description for search engines', } ``` ## Dynamic Metadata ```tsx import type { Metadata } from 'next' type Props = { params: Promise<{ slug: string }> } export async function generateMetadata({ params }: Props): Promise { const { slug } = await params const post = await getPost(slug) return { title: post.title, description: post.description } } ``` ## Avoid Duplicate Fetches Use React `cache()` when the same data is needed for both metadata and page: ```tsx import { cache } from 'react' export const getPost = cache(async (slug: string) => { return await db.posts.findFirst({ where: { slug } }) }) ``` ## Viewport Separate from metadata for streaming support: ```tsx import type { Viewport } from 'next' export const viewport: Viewport = { width: 'device-width', initialScale: 1, themeColor: '#000000', } // Or dynamic export function generateViewport({ params }): Viewport { return { themeColor: getThemeColor(params) } } ``` ## Title Templates In root layout for consistent naming: ```tsx export const metadata: Metadata = { title: { default: 'Site Name', template: '%s | Site Name' }, } ``` ## Metadata File Conventions Reference: https://nextjs.org/docs/app/getting-started/project-structure#metadata-file-conventions Place these files in `app/` directory (or route segments): | File | Purpose | |------|---------| | `favicon.ico` | Favicon | | `icon.png` / `icon.svg` | App icon | | `apple-icon.png` | Apple app icon | | `opengraph-image.png` | OG image | | `twitter-image.png` | Twitter card image | | `sitemap.ts` / `sitemap.xml` | Sitemap (use `generateSitemaps` for multiple) | | `robots.ts` / `robots.txt` | Robots directives | | `manifest.ts` / `manifest.json` | Web app manifest | ## SEO Best Practice: Static Files Are Often Enough For most sites, **static metadata files provide excellent SEO coverage**: ``` app/ ├── favicon.ico ├── opengraph-image.png # Works for both OG and Twitter ├── sitemap.ts ├── robots.ts └── layout.tsx # With title/description metadata ``` **Tips:** - A single `opengraph-image.png` covers both Open Graph and Twitter (Twitter falls back to OG) - Static `title` and `description` in layout metadata is sufficient for most pages - Only use dynamic `generateMetadata` when content varies per page --- # OG Image Generation Generate dynamic Open Graph images using `next/og`. ## Important Rules 1. **Use `next/og`** - not `@vercel/og` (it's built into Next.js) 2. **No searchParams** - OG images can't access search params, use route params instead 3. **Avoid Edge runtime** - Use default Node.js runtime ```tsx // Good import { ImageResponse } from 'next/og' // Bad // import { ImageResponse } from '@vercel/og' // export const runtime = 'edge' ``` ## Basic OG Image ```tsx // app/opengraph-image.tsx import { ImageResponse } from 'next/og' export const alt = 'Site Name' export const size = { width: 1200, height: 630 } export const contentType = 'image/png' export default function Image() { return new ImageResponse( (
Hello World
), { ...size } ) } ``` ## Dynamic OG Image ```tsx // app/blog/[slug]/opengraph-image.tsx import { ImageResponse } from 'next/og' export const alt = 'Blog Post' export const size = { width: 1200, height: 630 } export const contentType = 'image/png' type Props = { params: Promise<{ slug: string }> } export default async function Image({ params }: Props) { const { slug } = await params const post = await getPost(slug) return new ImageResponse( (
{post.title}
{post.description}
), { ...size } ) } ``` ## Custom Fonts ```tsx import { ImageResponse } from 'next/og' import { join } from 'path' import { readFile } from 'fs/promises' export default async function Image() { const fontPath = join(process.cwd(), 'assets/fonts/Inter-Bold.ttf') const fontData = await readFile(fontPath) return new ImageResponse( (
Custom Font Text
), { width: 1200, height: 630, fonts: [{ name: 'Inter', data: fontData, style: 'normal' }], } ) } ``` ## File Naming - `opengraph-image.tsx` - Open Graph (Facebook, LinkedIn) - `twitter-image.tsx` - Twitter/X cards (optional, falls back to OG) ## Styling Notes ImageResponse uses Flexbox layout: - Use `display: 'flex'` - No CSS Grid support - Styles must be inline objects ## Multiple OG Images Use `generateImageMetadata` for multiple images per route: ```tsx // app/blog/[slug]/opengraph-image.tsx import { ImageResponse } from 'next/og' export async function generateImageMetadata({ params }) { const images = await getPostImages(params.slug) return images.map((img, idx) => ({ id: idx, alt: img.alt, size: { width: 1200, height: 630 }, contentType: 'image/png', })) } export default async function Image({ params, id }) { const images = await getPostImages(params.slug) const image = images[id] return new ImageResponse(/* ... */) } ``` ## Multiple Sitemaps Use `generateSitemaps` for large sites: ```tsx // app/sitemap.ts import type { MetadataRoute } from 'next' export async function generateSitemaps() { // Return array of sitemap IDs return [{ id: 0 }, { id: 1 }, { id: 2 }] } export default async function sitemap({ id, }: { id: number }): Promise { const start = id * 50000 const end = start + 50000 const products = await getProducts(start, end) return products.map((product) => ({ url: `https://example.com/product/${product.id}`, lastModified: product.updatedAt, })) } ``` Generates `/sitemap/0.xml`, `/sitemap/1.xml`, etc.