Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Apply Next.js best practices for RSC boundaries, async APIs, routing, metadata, and optimization.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
metadata.md
1# Metadata23Add SEO metadata to Next.js pages using the Metadata API.45## Important: Server Components Only67The `metadata` object and `generateMetadata` function are **only supported in Server Components**. They cannot be used in Client Components.89If the target page has `'use client'`:101. Remove `'use client'` if possible, move client logic to child components112. Or extract metadata to a parent Server Component layout123. Or split the file: Server Component with metadata imports Client Components1314## Static Metadata1516```tsx17import type { Metadata } from 'next'1819export const metadata: Metadata = {20title: 'Page Title',21description: 'Page description for search engines',22}23```2425## Dynamic Metadata2627```tsx28import type { Metadata } from 'next'2930type Props = { params: Promise<{ slug: string }> }3132export async function generateMetadata({ params }: Props): Promise<Metadata> {33const { slug } = await params34const post = await getPost(slug)35return { title: post.title, description: post.description }36}37```3839## Avoid Duplicate Fetches4041Use React `cache()` when the same data is needed for both metadata and page:4243```tsx44import { cache } from 'react'4546export const getPost = cache(async (slug: string) => {47return await db.posts.findFirst({ where: { slug } })48})49```5051## Viewport5253Separate from metadata for streaming support:5455```tsx56import type { Viewport } from 'next'5758export const viewport: Viewport = {59width: 'device-width',60initialScale: 1,61themeColor: '#000000',62}6364// Or dynamic65export function generateViewport({ params }): Viewport {66return { themeColor: getThemeColor(params) }67}68```6970## Title Templates7172In root layout for consistent naming:7374```tsx75export const metadata: Metadata = {76title: { default: 'Site Name', template: '%s | Site Name' },77}78```7980## Metadata File Conventions8182Reference: https://nextjs.org/docs/app/getting-started/project-structure#metadata-file-conventions8384Place these files in `app/` directory (or route segments):8586| File | Purpose |87|------|---------|88| `favicon.ico` | Favicon |89| `icon.png` / `icon.svg` | App icon |90| `apple-icon.png` | Apple app icon |91| `opengraph-image.png` | OG image |92| `twitter-image.png` | Twitter card image |93| `sitemap.ts` / `sitemap.xml` | Sitemap (use `generateSitemaps` for multiple) |94| `robots.ts` / `robots.txt` | Robots directives |95| `manifest.ts` / `manifest.json` | Web app manifest |9697## SEO Best Practice: Static Files Are Often Enough9899For most sites, **static metadata files provide excellent SEO coverage**:100101```102app/103├── favicon.ico104├── opengraph-image.png # Works for both OG and Twitter105├── sitemap.ts106├── robots.ts107└── layout.tsx # With title/description metadata108```109110**Tips:**111- A single `opengraph-image.png` covers both Open Graph and Twitter (Twitter falls back to OG)112- Static `title` and `description` in layout metadata is sufficient for most pages113- Only use dynamic `generateMetadata` when content varies per page114115---116117# OG Image Generation118119Generate dynamic Open Graph images using `next/og`.120121## Important Rules1221231. **Use `next/og`** - not `@vercel/og` (it's built into Next.js)1242. **No searchParams** - OG images can't access search params, use route params instead1253. **Avoid Edge runtime** - Use default Node.js runtime126127```tsx128// Good129import { ImageResponse } from 'next/og'130131// Bad132// import { ImageResponse } from '@vercel/og'133// export const runtime = 'edge'134```135136## Basic OG Image137138```tsx139// app/opengraph-image.tsx140import { ImageResponse } from 'next/og'141142export const alt = 'Site Name'143export const size = { width: 1200, height: 630 }144export const contentType = 'image/png'145146export default function Image() {147return new ImageResponse(148(149<div150style={{151fontSize: 128,152background: 'white',153width: '100%',154height: '100%',155display: 'flex',156alignItems: 'center',157justifyContent: 'center',158}}159>160Hello World161</div>162),163{ ...size }164)165}166```167168## Dynamic OG Image169170```tsx171// app/blog/[slug]/opengraph-image.tsx172import { ImageResponse } from 'next/og'173174export const alt = 'Blog Post'175export const size = { width: 1200, height: 630 }176export const contentType = 'image/png'177178type Props = { params: Promise<{ slug: string }> }179180export default async function Image({ params }: Props) {181const { slug } = await params182const post = await getPost(slug)183184return new ImageResponse(185(186<div187style={{188fontSize: 48,189background: 'linear-gradient(to bottom, #1a1a1a, #333)',190color: 'white',191width: '100%',192height: '100%',193display: 'flex',194flexDirection: 'column',195alignItems: 'center',196justifyContent: 'center',197padding: 48,198}}199>200<div style={{ fontSize: 64, fontWeight: 'bold' }}>{post.title}</div>201<div style={{ marginTop: 24, opacity: 0.8 }}>{post.description}</div>202</div>203),204{ ...size }205)206}207```208209## Custom Fonts210211```tsx212import { ImageResponse } from 'next/og'213import { join } from 'path'214import { readFile } from 'fs/promises'215216export default async function Image() {217const fontPath = join(process.cwd(), 'assets/fonts/Inter-Bold.ttf')218const fontData = await readFile(fontPath)219220return new ImageResponse(221(222<div style={{ fontFamily: 'Inter', fontSize: 64 }}>223Custom Font Text224</div>225),226{227width: 1200,228height: 630,229fonts: [{ name: 'Inter', data: fontData, style: 'normal' }],230}231)232}233```234235## File Naming236237- `opengraph-image.tsx` - Open Graph (Facebook, LinkedIn)238- `twitter-image.tsx` - Twitter/X cards (optional, falls back to OG)239240## Styling Notes241242ImageResponse uses Flexbox layout:243- Use `display: 'flex'`244- No CSS Grid support245- Styles must be inline objects246247## Multiple OG Images248249Use `generateImageMetadata` for multiple images per route:250251```tsx252// app/blog/[slug]/opengraph-image.tsx253import { ImageResponse } from 'next/og'254255export async function generateImageMetadata({ params }) {256const images = await getPostImages(params.slug)257return images.map((img, idx) => ({258id: idx,259alt: img.alt,260size: { width: 1200, height: 630 },261contentType: 'image/png',262}))263}264265export default async function Image({ params, id }) {266const images = await getPostImages(params.slug)267const image = images[id]268return new ImageResponse(/* ... */)269}270```271272## Multiple Sitemaps273274Use `generateSitemaps` for large sites:275276```tsx277// app/sitemap.ts278import type { MetadataRoute } from 'next'279280export async function generateSitemaps() {281// Return array of sitemap IDs282return [{ id: 0 }, { id: 1 }, { id: 2 }]283}284285export default async function sitemap({286id,287}: {288id: number289}): Promise<MetadataRoute.Sitemap> {290const start = id * 50000291const end = start + 50000292const products = await getProducts(start, end)293294return products.map((product) => ({295url: `https://example.com/product/${product.id}`,296lastModified: product.updatedAt,297}))298}299```300301Generates `/sitemap/0.xml`, `/sitemap/1.xml`, etc.302