New skills: - next-best-practices: Next.js 15+ RSC, async patterns, self-hosting (vercel-labs) - better-auth-best-practices: Official Better-Auth with Drizzle adapter (better-auth) - verification-before-completion: Evidence-based completion claims (obra/superpowers) - shadcn-ui: Component patterns with Tailwind v4 adaptation note (developer-kit) - writing-skills: TDD methodology for skill authoring (obra/superpowers) README reorganized by category with Mosaic Stack alignment section. Total: 9 skills (4 existing + 5 new). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
7.0 KiB
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':
- Remove
'use client'if possible, move client logic to child components - Or extract metadata to a parent Server Component layout
- Or split the file: Server Component with metadata imports Client Components
Static Metadata
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Page Title',
description: 'Page description for search engines',
}
Dynamic Metadata
import type { Metadata } from 'next'
type Props = { params: Promise<{ slug: string }> }
export async function generateMetadata({ params }: Props): Promise<Metadata> {
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:
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:
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:
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.pngcovers both Open Graph and Twitter (Twitter falls back to OG) - Static
titleanddescriptionin layout metadata is sufficient for most pages - Only use dynamic
generateMetadatawhen content varies per page
OG Image Generation
Generate dynamic Open Graph images using next/og.
Important Rules
- Use
next/og- not@vercel/og(it's built into Next.js) - No searchParams - OG images can't access search params, use route params instead
- Avoid Edge runtime - Use default Node.js runtime
// Good
import { ImageResponse } from 'next/og'
// Bad
// import { ImageResponse } from '@vercel/og'
// export const runtime = 'edge'
Basic OG Image
// 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(
(
<div
style={{
fontSize: 128,
background: 'white',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
Hello World
</div>
),
{ ...size }
)
}
Dynamic OG Image
// 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(
(
<div
style={{
fontSize: 48,
background: 'linear-gradient(to bottom, #1a1a1a, #333)',
color: 'white',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: 48,
}}
>
<div style={{ fontSize: 64, fontWeight: 'bold' }}>{post.title}</div>
<div style={{ marginTop: 24, opacity: 0.8 }}>{post.description}</div>
</div>
),
{ ...size }
)
}
Custom Fonts
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(
(
<div style={{ fontFamily: 'Inter', fontSize: 64 }}>
Custom Font Text
</div>
),
{
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:
// 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:
// 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<MetadataRoute.Sitemap> {
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.