Skip to main content

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; generateMetadata runs 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
  • metadata export in layout.tsx sets 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

tsx
// 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 metadata export
  • Blog post or doc slug pulled from a database: generateMetadata in [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.

tsx
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:

tsx
// 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/:

tsx
// 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

tsx
'use client' export const metadata = { title: 'Nope' } // silently ignored, no <head> injection

Metadata is processed only in Server Components. Move the export to the server page.tsx or layout.tsx.

Mistake: relative OG image URL

tsx
openGraph: { images: '/og.png' } // breaks social crawlers

I'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

tsx
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: generateMetadata pulls project names from the database per request
  • Linear.app: issue pages with alternates.canonical to 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

tsx
// 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

tsx
// 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

tsx
// 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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?