Metadata and SEO in Next.js
Next.js metadata is a declarative API that generates HTML <head> tags (title, description, OpenGraph) either statically from a plain export or dynamically via an async function that runs per request.
Theory
TL;DR
- Static metadata exports a plain object at build time;
generateMetadataruns per request and can fetch live data - Analogy: static is the default sign in the shop window; dynamic swaps it per product based on live inventory
metadataexport inlayout.tsxsets site-wide defaults; child pages override specific fields- Use static for fixed pages, dynamic for slug-based routes (blog, docs, products)
- OG image URLs must be absolute or social crawlers ignore them
Quick example
// app/layout.tsx - Static: applies to every page
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: { template: '%s | IT Lead', default: 'IT Lead' },
description: 'Interview prep platform for developers'
}
// Renders: <title>IT Lead</title> on root, "Page | IT Lead" on children
// app/blog/[slug]/page.tsx - Dynamic: runs per request
export async function generateMetadata(
{ params }: { params: { slug: string } }
): Promise<Metadata> {
const post = await fetch(`/api/posts/${params.slug}`).then(r => r.json())
return {
title: post.title, // overrides the layout template
description: post.excerpt
}
}Root layout sets the baseline. Each route segment can override or extend it. Next.js merges them automatically.
Static vs dynamic metadata
Static metadata is a plain object exported from layout.tsx or page.tsx. Next.js reads it at build time (or once per SSR render) and injects the tags into <head>. No database calls, no waiting.
Dynamic metadata lives in generateMetadata, an async function that receives params and searchParams. It runs on every request, so you can pull a blog post title from a CMS or a product name from a database. Next.js deduplicates fetch calls: if generateMetadata and the page component call the same fetch, the request runs once.
Choose static for anything that does not change between users or requests. Choose dynamic when the <head> content depends on route params or live data.
When to use
- Fixed site title and description in root layout: static
metadataexport - Blog post or doc slug pulled from a database:
generateMetadatain[slug]/page.tsx - E-commerce product pages with OG images from a CDN: dynamic with
openGraph.images - Admin or internal pages with no public SEO value: set
robots: { index: false } - Multilingual sites: dynamic with
alternates.languages
How Next.js handles metadata internally
The App Router scans each route segment for a metadata export or generateMetadata function during the server render. It collects them from the root layout down to the current page, merges the objects (child fields override parent fields with the same key), and injects the result into <head> via React Server Components.
For dynamic metadata, Next.js awaits the promise before streaming the response. Fetch calls inside generateMetadata use the same data cache as the page component, so duplicated fetches collapse into one. You control caching with fetch({ next: { revalidate: 3600 } }) or tag-based invalidation via revalidateTag.
OpenGraph and Twitter Cards
Social crawlers (Facebook, LinkedIn, X) read OG and Twitter meta tags to build link previews. One consistent gotcha: OG image URLs must be absolute. A relative path like /og.png breaks social crawlers because they fetch the URL without your domain context.
export const metadata: Metadata = {
openGraph: {
title: 'JavaScript Interview Problems',
description: 'Solve problems from real interviews',
url: 'https://itlead.org/problems',
siteName: 'IT Lead',
images: [
{
url: `${process.env.NEXT_PUBLIC_SITE_URL}/images/ITLeadBanner.png`, // absolute URL
width: 1200,
height: 630
}
],
type: 'website'
},
twitter: {
card: 'summary_large_image',
images: [`${process.env.NEXT_PUBLIC_SITE_URL}/images/ITLeadBanner.png`]
}
}If twitter is partially omitted, Next.js inherits the missing values from openGraph automatically.
Title templates
A title template in the root layout adds a site name suffix to every child page without repeating it anywhere:
// app/layout.tsx
export const metadata: Metadata = {
title: {
template: '%s | IT Lead',
default: 'IT Lead' // shown when a child page sets no title
}
}
// app/docs/page.tsx
export const metadata: Metadata = {
title: 'Knowledge Base'
// Result: "Knowledge Base | IT Lead"
}%s is replaced with the child page's title string. default shows on pages that set no title of their own.
Sitemap and robots.txt
Next.js generates both as routes, not static files. Place them in app/:
// app/sitemap.ts
import { MetadataRoute } from 'next'
export default function sitemap(): MetadataRoute.Sitemap {
const docs = getAllDocs()
return [
{ url: 'https://itlead.org', lastModified: new Date() },
...docs.map(doc => ({
url: `https://itlead.org/docs/${doc.slug}`,
lastModified: doc.updatedAt
}))
]
}
// app/robots.ts
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: '*', allow: '/', disallow: ['/api/', '/auth/'] },
sitemap: 'https://itlead.org/sitemap.xml'
}
}Both are auto-served at /sitemap.xml and /robots.txt.
Common mistakes
Mistake: exporting metadata from a Client Component
'use client'
export const metadata = { title: 'Nope' } // silently ignored, no <head> injectionMetadata is processed only in Server Components. Move the export to the server page.tsx or layout.tsx.
Mistake: relative OG image URL
openGraph: { images: '/og.png' } // breaks social crawlersI've seen this exact issue in three separate production Next.js apps. Everything looks fine on localhost, then LinkedIn shows a broken preview. Fix: ${process.env.NEXT_PUBLIC_SITE_URL}/og.png.
Mistake: missing Promise<Metadata> return type
export async function generateMetadata({ params }) { // wrong inferred type
return { title: 'Bad' }
}Add the explicit return type: Promise<Metadata>. Without it TypeScript cannot catch missing or incorrect fields at compile time.
Mistake: slow API call inside generateMetadata without caching
A blocking fetch in generateMetadata delays the entire server response. Use fetch({ next: { revalidate: 3600 } }) for ISR or cache: 'force-cache' when the data rarely changes.
Mistake: no canonical URL in dynamic routes
Without alternates.canonical, search engines may treat /blog/post?ref=twitter and /blog/post as separate pages and split SEO signals between them.
Real-world usage
- nextjs.org docs: static base in root layout + dynamic per doc slug via Contentlayer
- Supabase dashboard:
generateMetadatapulls project names from the database per request - Linear.app: issue pages with
alternates.canonicalto consolidate SEO signals - E-commerce product pages: dynamic OG images from CDN with ISR revalidation every 5 minutes
Follow-up questions
Q: What is the difference between static metadata and generateMetadata in terms of render timing?
A: Static metadata is read at build time (or once per SSR render) and costs nothing at request time. generateMetadata runs per request and can fetch fresh data, but adds latency without caching.
Q: How does metadata inheritance work between layouts and pages?
A: Next.js merges metadata from the root layout down to the current segment. Child fields override parent fields with the same key. A child page that sets title replaces the layout's title but inherits everything else unchanged.
Q: Why do OG images need absolute URLs?
A: Social crawlers (Facebook, Googlebot, X) do not know your domain. They fetch the URL as-is, so a relative path resolves to nothing outside your server context.
Q: How do you add structured data (JSON-LD) for rich snippets in search results?
A: Inject a <script type="application/ld+json"> tag directly in the page component with dangerouslySetInnerHTML. The metadata API does not handle JSON-LD directly.
Q: In a multi-tenant app, how would you generate tenant-specific metadata without leaking data across requests?
A: Read the tenant identifier via headers().get('x-tenant') or cookies() inside generateMetadata. Call unstable_noStore() to opt out of caching, then use fetch({ next: { tags: ['tenant-data'] } }) for on-demand revalidation when tenant data changes. Never store tenant state in module scope since serverless functions share no state between requests.
Examples
Static metadata in root layout
// app/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: {
template: '%s | IT Lead',
default: 'IT Lead'
},
description: 'Interview prep platform for developers',
keywords: ['frontend', 'interview', 'javascript', 'react'],
openGraph: {
siteName: 'IT Lead',
type: 'website',
images: [
{
url: 'https://itlead.org/images/ITLeadBanner.png', // must be absolute
width: 1200,
height: 630
}
]
}
}This runs once. Every page in the app inherits these defaults unless it sets its own values.
Dynamic metadata for a blog post with OpenGraph
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
import { getPost } from '@/lib/posts'
export async function generateMetadata(
{ params }: { params: { slug: string } }
): Promise<Metadata> {
const post = await getPost(params.slug) // cached by Next.js, shared with page component
return {
title: post.title, // becomes "How closures work | IT Lead" via template
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
type: 'article',
publishedTime: post.date,
images: post.image ? [{ url: post.image }] : []
},
twitter: { card: 'summary_large_image' }
}
}
export default async function BlogPost({ params }) {
const post = await getPost(params.slug) // same fetch - runs once total
return <article>{post.content}</article>
}getPost appears twice in the file but runs once per request. Next.js deduplicates fetch calls within the same render cycle, so there is no double network hit.
Structured data with JSON-LD for rich snippets
// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }) {
const post = await getPost(params.slug)
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
description: post.excerpt,
datePublished: post.date,
author: { '@type': 'Organization', name: 'IT Lead' }
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article>{post.content}</article>
</>
)
}JSON-LD lives in the page component, not in the metadata API. Google reads it for article cards, breadcrumbs, and FAQ boxes in search results.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.