Dev Logs
/Next.js/ Chapter 19: Metadata, SEO & OG Images
Chapters
  • 01Chapter 1: Introduction & Setup
  • 02Chapter 2: Project Structure & Configuration
  • 03Chapter 3: Layouts & Pages
  • 04Chapter 4: Linking & Navigation
  • 05Chapter 5: Dynamic Routes & Params
  • 06Chapter 6: Route Groups & Organization
  • 07Chapter 7: Parallel & Intercepting Routes
  • 08Chapter 8: Loading, Suspense & Streaming
  • 09Chapter 9: Error Handling
  • 10Chapter 10: Server & Client Components
  • 11Chapter 11: Data Fetching
  • 12Chapter 12: Server Actions & Mutations
  • 13Chapter 13: Route Handlers
  • 14Chapter 14: Caching & use cache
  • 15Chapter 15: Revalidation & ISR
  • 16Chapter 16: Cache Components & Partial Prerendering
  • 17Chapter 17: Proxy (formerly Middleware)
  • 18Chapter 18: Authentication & Authorization
  • 19Chapter 19: Metadata, SEO & OG Images
    • Plain English Explanation
    • Static metadata
    • Title templates
    • A realistic root layout
    • Merging
    • generateMetadata
    • The duplicate fetch isn't duplicated
    • Inheriting the parent
    • File-based metadata
    • Icons
    • Static OG images
    • Generated OG images
    • The CSS constraints
    • Custom fonts
    • Multiple images per route
    • Sitemaps
    • Large sitemaps
    • robots.txt
    • JSON-LD structured data
    • generateViewport
    • An SEO checklist
    • Common Pitfalls
    • . Missing metadataBase
    • . 'use client' on a page with metadata
    • . Exporting both metadata and generateMetadata
    • . Missing display: flex in ImageResponse
    • . Tailwind classes in ImageResponse
    • . Synchronous params or id in image/sitemap functions
    • . Testing OG images by looking at them
    • . A stray noindex in production
    • . Forgetting the OG image is public
    • When & Why to Use
    • Mini Practice Problems
    • Problem 1: Fix the metadata
    • Problem 2: Migrate to Next.js 16
    • Problem 3: Debug the preview
    • Problem 4: Build it
    • Interview Notes
    • Common Questions
    • Asked at Companies
    • Visual Memory Aid
    • Key Takeaways
  • 20Chapter 20: Images & Fonts
  • 21Chapter 21: Styling
  • 22Chapter 22: Performance Optimization
  • 23Chapter 23: Testing
  • 24Chapter 24: Deployment & Self-Hosting
  • 25Chapter 25: Upgrading to Next.js 16
  • 26Chapter 26: Capstone Project
  • 27Chapter 27: React Performance Profiling
All chapters

🔍 Chapter 19: Metadata, SEO & OG Images

Getting your pages indexed properly and making shared links look good — without ever touching <head>.

📖 Plain English Explanation

Two audiences never see your CSS.

Search engines read your <title>, <meta name="description">, your canonical URL, your structured data, and your sitemap. That's how you get found.

Social platforms — Slack, X, LinkedIn, iMessage, Discord — read your Open Graph tags and render a preview card. That's how a shared link looks like a product instead of a naked URL.

Both live in <head>, which in the App Router you never write directly. Instead you export metadata and Next.js assembles the tags, deduplicates them, and injects them at the right point in the stream.

Two ways to do it:

  • metadata — a static object, for values known at build time
  • generateMetadata — an async function, for values from your data

Plus a set of file conventions that turn a correctly-named file into the right tag automatically.

🏷️ Static metadata

tsx
// app/layout.tsx
import type { Metadata } from 'next'

export const metadata: Metadata = {
  title: 'Acme',
  description: 'The best widgets on the internet.',
}
jsx
// app/layout.js
export const metadata = {
  title: 'Acme',
  description: 'The best widgets on the internet.',
}

Which produces:

html
<title>Acme</title>
<meta name="description" content="The best widgets on the internet." />

Title templates

The pattern you want in your root layout:

tsx
// app/layout.tsx
export const metadata: Metadata = {
  title: {
    default: 'Acme — Widgets for everyone',
    template: '%s | Acme',
  },
}
tsx
// app/blog/page.tsx
export const metadata: Metadata = {
  title: 'Blog',        // renders as "Blog | Acme"
}

To escape the template on one page:

tsx
export const metadata: Metadata = {
  title: { absolute: 'Just this exact title' },
}

A realistic root layout

tsx
// app/layout.tsx
import type { Metadata } from 'next'

export const metadata: Metadata = {
  metadataBase: new URL('https://acme.com'),   // ← makes relative URLs work
  title: {
    default: 'Acme — Widgets for everyone',
    template: '%s | Acme',
  },
  description: 'Buy well-made widgets, shipped worldwide.',
  applicationName: 'Acme',
  authors: [{ name: 'Acme Inc.', url: 'https://acme.com' }],
  keywords: ['widgets', 'gadgets', 'hardware'],

  openGraph: {
    type: 'website',
    locale: 'en_US',
    url: 'https://acme.com',
    siteName: 'Acme',
    title: 'Acme — Widgets for everyone',
    description: 'Buy well-made widgets, shipped worldwide.',
    images: [{ url: '/og.png', width: 1200, height: 630, alt: 'Acme' }],
  },

  twitter: {
    card: 'summary_large_image',
    site: '@acme',
    creator: '@acme',
  },

  robots: {
    index: true,
    follow: true,
    googleBot: { index: true, follow: true, 'max-image-preview': 'large' },
  },

  alternates: {
    canonical: '/',
    languages: { 'en-US': '/en', 'fr-FR': '/fr' },
  },

  icons: {
    icon: '/favicon.ico',
    apple: '/apple-touch-icon.png',
  },
}

metadataBase is the one people forget. Without it, relative URLs in openGraph.images don't resolve and social platforms fetch nothing. Set it once in the root layout.

Merging

Metadata merges down the tree, deeper segments overriding shallower ones:

app/layout.tsx        → title template, description, OG defaults
  app/blog/layout.tsx → overrides description
    app/blog/[slug]/page.tsx → overrides title, description, OG image

Merging is shallow. Redefine openGraph in a page and you replace the whole object, not merge into it — you'll need to restate siteName, type, and so on.

🔄 generateMetadata

For anything derived from data:

tsx
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getPost } from '@/lib/posts'

export async function generateMetadata(
  props: PageProps<'/blog/[slug]'>
): Promise<Metadata> {
  const { slug } = await props.params
  const post = await getPost(slug)

  if (!post) {
    return { title: 'Post not found' }
  }

  return {
    title: post.title,
    description: post.excerpt,
    authors: [{ name: post.author.name }],
    openGraph: {
      type: 'article',
      title: post.title,
      description: post.excerpt,
      publishedTime: post.publishedAt.toISOString(),
      authors: [post.author.name],
      images: [{ url: post.coverImage, width: 1200, height: 630, alt: post.title }],
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
    alternates: { canonical: `/blog/${slug}` },
  }
}

export default async function Page(props: PageProps<'/blog/[slug]'>) {
  const { slug } = await props.params
  const post = await getPost(slug)
  if (!post) notFound()

  return <article>{post.body}</article>
}
jsx
// app/blog/[slug]/page.js
import { notFound } from 'next/navigation'
import { getPost } from '@/lib/posts'

export async function generateMetadata(props) {
  const { slug } = await props.params
  const post = await getPost(slug)

  if (!post) return { title: 'Post not found' }

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      type: 'article',
      title: post.title,
      description: post.excerpt,
      images: [{ url: post.coverImage, width: 1200, height: 630, alt: post.title }],
    },
    alternates: { canonical: `/blog/${slug}` },
  }
}

export default async function Page(props) {
  const { slug } = await props.params
  const post = await getPost(slug)
  if (!post) notFound()

  return <article>{post.body}</article>
}

The duplicate fetch isn't duplicated

getPost(slug) runs in both generateMetadata and the page. That's fine — fetch deduplicates automatically, and for a database client you wrap it in cache():

ts
// lib/posts.ts
import { cache } from 'react'
import { db } from '@/lib/db'

export const getPost = cache(async (slug: string) =>
  db.post.findUnique({ where: { slug } })
)

One query per request, regardless of how many callers.

Inheriting the parent

tsx
import type { Metadata, ResolvingMetadata } from 'next'

export async function generateMetadata(
  props: PageProps<'/blog/[slug]'>,
  parent: ResolvingMetadata
): Promise<Metadata> {
  const { slug } = await props.params
  const post = await getPost(slug)
  const previousImages = (await parent).openGraph?.images ?? []

  return {
    title: post.title,
    openGraph: {
      images: [post.coverImage, ...previousImages],   // add, don't replace
    },
  }
}

You cannot export both metadata and generateMetadata from the same file. Pick one.

🖼️ File-based metadata

Certain filenames become tags automatically. No code required.

Icons

app/
├── favicon.ico          →  <link rel="icon">
├── icon.png             →  <link rel="icon">
├── icon.svg
└── apple-icon.png       →  <link rel="apple-touch-icon">

Drop the files in; Next.js reads their dimensions and emits the right tags.

Static OG images

app/
├── opengraph-image.png    →  <meta property="og:image">
├── opengraph-image.alt.txt →  <meta property="og:image:alt">
└── twitter-image.png      →  <meta name="twitter:image">

Place them in a route folder to scope them to that route:

app/blog/opengraph-image.png    →  applies to /blog and everything under it

🎨 Generated OG images

The good part. Generate a unique image per page, at request time, from JSX.

tsx
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { getPost } from '@/lib/posts'

export const alt = 'Blog post cover'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'

export default async function Image(props: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await props.params        // ← Promise in Next.js 16
  const post = await getPost(slug)

  return new ImageResponse(
    (
      <div
        style={{
          height: '100%',
          width: '100%',
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'space-between',
          background: 'linear-gradient(135deg, #0f172a 0%, #1e3a8a 100%)',
          padding: 64,
        }}
      >
        <div style={{ display: 'flex', color: '#93c5fd', fontSize: 28 }}>
          acme.com/blog
        </div>

        <div
          style={{
            display: 'flex',
            color: 'white',
            fontSize: 68,
            fontWeight: 700,
            lineHeight: 1.1,
          }}
        >
          {post?.title ?? 'Acme Blog'}
        </div>

        <div style={{ display: 'flex', color: '#cbd5e1', fontSize: 30 }}>
          {post?.author.name} · {post?.readingMinutes} min read
        </div>
      </div>
    ),
    { ...size }
  )
}
jsx
// app/blog/[slug]/opengraph-image.js
import { ImageResponse } from 'next/og'
import { getPost } from '@/lib/posts'

export const alt = 'Blog post cover'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'

export default async function Image(props) {
  const { slug } = await props.params
  const post = await getPost(slug)

  return new ImageResponse(
    (
      <div
        style={{
          height: '100%',
          width: '100%',
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'space-between',
          background: 'linear-gradient(135deg, #0f172a 0%, #1e3a8a 100%)',
          padding: 64,
        }}
      >
        <div style={{ display: 'flex', color: '#93c5fd', fontSize: 28 }}>
          acme.com/blog
        </div>
        <div style={{ display: 'flex', color: 'white', fontSize: 68, fontWeight: 700 }}>
          {post?.title ?? 'Acme Blog'}
        </div>
      </div>
    ),
    { ...size }
  )
}

⚠️ Changed in Next.js 16

The image-generating function now receives params and id as Promises — matching the async Request APIs change.

jsx
// ❌ Next.js 15
export default function Image({ params, id }) {
  const slug = params.slug
  const imageId = id                // string
}

// ✅ Next.js 16
export default async function Image({ params, id }) {
  const { slug } = await params
  const imageId = await id          // Promise<string>
}

Note the asymmetry: generateImageMetadata still receives synchronous params. Only the Image function's props became Promises.

The CSS constraints

ImageResponse uses Satori, not a browser. It supports a subset of CSS, and the limits are strict:

✅ display: flex   |   display: none
✅ flexbox layout, absolute positioning
✅ colors, gradients, borders, border-radius, shadows
✅ font-size, font-weight, letter-spacing, line-height
✅ <img> with an absolute URL

❌ display: grid  |  display: block  |  display: inline
❌ float, position: sticky
❌ CSS variables, media queries
❌ pseudo-elements (::before, ::after)
❌ external stylesheets or Tailwind classes (unless you use tw="…")

Every element with more than one child needs an explicit display: flex. This is the error you'll hit first:

Error: Expected <div> to have explicit "display: flex"

Custom fonts

tsx
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { readFile } from 'node:fs/promises'
import path from 'node:path'

export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'

export default async function Image(props: { params: Promise<{ slug: string }> }) {
  const { slug } = await props.params
  const post = await getPost(slug)

  const fontData = await readFile(
    path.join(process.cwd(), 'assets/Inter-Bold.ttf')
  )

  return new ImageResponse(
    (
      <div style={{ display: 'flex', fontFamily: 'Inter', fontSize: 64 }}>
        {post?.title}
      </div>
    ),
    {
      ...size,
      fonts: [{ name: 'Inter', data: fontData, style: 'normal', weight: 700 }],
    }
  )
}

Multiple images per route

tsx
// app/product/[id]/opengraph-image.tsx
export async function generateImageMetadata({
  params,
}: {
  params: { id: string }          // synchronous — unchanged in Next.js 16
}) {
  return [
    { id: 'square', size: { width: 600, height: 600 }, alt: 'Square' },
    { id: 'wide', size: { width: 1200, height: 630 }, alt: 'Wide' },
  ]
}

export default async function Image({
  params,
  id,
}: {
  params: Promise<{ id: string }>
  id: Promise<string>             // ← Promise in Next.js 16
}) {
  const { id: productId } = await params
  const variant = await id

  const product = await getProduct(productId)
  const size = variant === 'square'
    ? { width: 600, height: 600 }
    : { width: 1200, height: 630 }

  return new ImageResponse(
    <div style={{ display: 'flex' }}>{product.name}</div>,
    size
  )
}

🗺️ Sitemaps

ts
// app/sitemap.ts
import type { MetadataRoute } from 'next'
import { db } from '@/lib/db'

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const base = 'https://acme.com'

  const posts = await db.post.findMany({
    where: { published: true },
    select: { slug: true, updatedAt: true },
  })

  const staticRoutes = [
    { url: base, lastModified: new Date(), changeFrequency: 'daily' as const, priority: 1 },
    { url: `${base}/blog`, lastModified: new Date(), changeFrequency: 'daily' as const, priority: 0.8 },
    { url: `${base}/pricing`, lastModified: new Date(), changeFrequency: 'monthly' as const, priority: 0.8 },
  ]

  const postRoutes = posts.map((post) => ({
    url: `${base}/blog/${post.slug}`,
    lastModified: post.updatedAt,
    changeFrequency: 'weekly' as const,
    priority: 0.6,
  }))

  return [...staticRoutes, ...postRoutes]
}
js
// app/sitemap.js
import { db } from '@/lib/db'

export default async function sitemap() {
  const base = 'https://acme.com'

  const posts = await db.post.findMany({
    where: { published: true },
    select: { slug: true, updatedAt: true },
  })

  return [
    { url: base, lastModified: new Date(), changeFrequency: 'daily', priority: 1 },
    { url: `${base}/blog`, lastModified: new Date(), changeFrequency: 'daily', priority: 0.8 },
    ...posts.map((post) => ({
      url: `${base}/blog/${post.slug}`,
      lastModified: post.updatedAt,
      changeFrequency: 'weekly',
      priority: 0.6,
    })),
  ]
}

Served at /sitemap.xml.

Large sitemaps

The limit is 50,000 URLs per file. Split with generateSitemaps:

ts
// app/product/sitemap.ts
export async function generateSitemaps() {
  const count = await db.product.count()
  const pages = Math.ceil(count / 50000)
  return Array.from({ length: pages }, (_, i) => ({ id: i }))
}

export default async function sitemap({ id }: { id: Promise<string> }) {
  const resolvedId = await id                  // ← Promise in Next.js 16
  const start = Number(resolvedId) * 50000

  const products = await db.product.findMany({
    skip: start,
    take: 50000,
    select: { slug: true, updatedAt: true },
  })

  return products.map((p) => ({
    url: `https://acme.com/product/${p.slug}`,
    lastModified: p.updatedAt,
  }))
}

⚠️ Changed in Next.js 16

The id passed to the sitemap function is now a Promise.

js
// ❌ Next.js 15
export default async function sitemap({ id }) {
  const start = id * 50000        // id was a number
}

// ✅ Next.js 16
export default async function sitemap({ id }) {
  const start = Number(await id) * 50000
}

generateSitemaps itself still receives synchronous params.

🤖 robots.txt

ts
// app/robots.ts
import type { MetadataRoute } from 'next'

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      {
        userAgent: '*',
        allow: '/',
        disallow: ['/admin/', '/api/', '/dashboard/'],
      },
      {
        userAgent: 'GPTBot',
        disallow: '/',
      },
    ],
    sitemap: 'https://acme.com/sitemap.xml',
  }
}
js
// app/robots.js
export default function robots() {
  return {
    rules: [
      { userAgent: '*', allow: '/', disallow: ['/admin/', '/api/', '/dashboard/'] },
      { userAgent: 'GPTBot', disallow: '/' },
    ],
    sitemap: 'https://acme.com/sitemap.xml',
  }
}

robots.txt is a request to well-behaved crawlers, not access control. Nothing in it protects /admin — that's Chapter 18's job.

📐 JSON-LD structured data

What produces rich results — star ratings, prices, recipe cards, FAQ accordions in Google.

tsx
// app/product/[id]/page.tsx
import { getProduct } from '@/lib/products'

export default async function Page(props: PageProps<'/product/[id]'>) {
  const { id } = await props.params
  const product = await getProduct(id)

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Product',
    name: product.name,
    description: product.description,
    image: product.images,
    sku: product.sku,
    brand: { '@type': 'Brand', name: product.brand },
    offers: {
      '@type': 'Offer',
      url: `https://acme.com/product/${id}`,
      priceCurrency: 'USD',
      price: (product.priceInCents / 100).toFixed(2),
      availability:
        product.stock > 0
          ? 'https://schema.org/InStock'
          : 'https://schema.org/OutOfStock',
    },
    aggregateRating: product.reviewCount
      ? {
          '@type': 'AggregateRating',
          ratingValue: product.averageRating,
          reviewCount: product.reviewCount,
        }
      : undefined,
  }

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <ProductDetails product={product} />
    </>
  )
}

JSON.stringify on data you control is safe here. If any field can contain user input, escape < to avoid breaking out of the script tag:

ts
const safe = JSON.stringify(jsonLd).replace(/</g, '\\u003c')

Validate with Google's Rich Results Test.

📱 generateViewport

Viewport settings moved out of metadata:

tsx
// app/layout.tsx
import type { Viewport } from 'next'

export const viewport: Viewport = {
  width: 'device-width',
  initialScale: 1,
  maximumScale: 5,           // don't set 1 — it blocks pinch-zoom (a11y failure)
  themeColor: [
    { media: '(prefers-color-scheme: light)', color: '#ffffff' },
    { media: '(prefers-color-scheme: dark)', color: '#0f172a' },
  ],
}

Or dynamically:

tsx
export async function generateViewport(props: PageProps<'/[theme]'>): Promise<Viewport> {
  const { theme } = await props.params
  return { themeColor: theme === 'dark' ? '#0f172a' : '#ffffff' }
}

📋 An SEO checklist

Every page
  □ unique <title> under ~60 characters
  □ unique description, 120–160 characters
  □ canonical URL set
  □ one <h1>

Root layout
  □ metadataBase set  ← the most commonly missed
  □ title template
  □ default OG image (1200×630)
  □ twitter card type

Site
  □ sitemap.xml, submitted to Search Console
  □ robots.txt pointing at the sitemap
  □ HTTPS with valid certificate
  □ mobile responsive
  □ Core Web Vitals in the green

Content
  □ descriptive alt text on every image
  □ JSON-LD where a rich result exists for your type
  □ internal links between related pages
  □ no accidental noindex in production

⚠️ Common Pitfalls

1. Missing metadataBase

Warning: metadataBase property in metadata export is not set

Relative OG image URLs don't resolve, so social previews come back blank. One line in the root layout fixes it.

2. 'use client' on a page with metadata

Metadata exports are silently ignored in Client Components. Keep the page a Server Component and push interactivity into a child.

3. Exporting both metadata and generateMetadata

Build error. Pick one per file.

4. Missing display: flex in ImageResponse

Any element with multiple children needs it explicitly.

5. Tailwind classes in ImageResponse

className="flex text-white" does nothing. Use inline style, or the tw prop:

tsx
<div tw="flex text-white text-6xl">{title}</div>

6. Synchronous params or id in image/sitemap functions

The Next.js 16 change. await them.

7. Testing OG images by looking at them

Platforms cache aggressively. Use the official debuggers:

  • Facebook Sharing Debugger
  • X Card Validator
  • LinkedIn Post Inspector

Each has a "scrape again" button. You'll need it.

8. A stray noindex in production

tsx
robots: { index: false }   // ❌ shipped from a staging config

Check /robots.txt and the meta tag on your live site after every deploy. This one silently deletes your traffic.

9. Forgetting the OG image is public

opengraph-image.tsx is fetched by anonymous crawlers with no session. Don't render private data into it.

🎯 When & Why to Use

metadata (static)          →  values known at build time
generateMetadata           →  values from your data
opengraph-image.tsx        →  per-page share cards worth generating
opengraph-image.png        →  one image for the whole site
sitemap.ts                 →  always, for any indexable site
robots.ts                  →  always
JSON-LD                    →  products, articles, recipes, events, FAQs
generateViewport           →  theme color, viewport settings

🏋️ Mini Practice Problems

Problem 1: Fix the metadata

tsx
'use client'

export const metadata = {
  title: 'Dashboard',
}

export default function Page() {
  const [tab, setTab] = useState('overview')
  return <Tabs value={tab} onChange={setTab} />
}

Two problems. Explain and rewrite.

Problem 2: Migrate to Next.js 16

jsx
// app/product/[id]/opengraph-image.js
export function generateImageMetadata({ params }) {
  return [{ id: 'a' }, { id: 'b' }]
}

export default function Image({ params, id }) {
  const product = getProduct(params.id)
  return new ImageResponse(<div>{product.name} — {id}</div>)
}

Problem 3: Debug the preview

A blog post's OG image is blank in Slack but the file loads fine when you visit /blog/hello/opengraph-image directly. List four possible causes.

Problem 4: Build it

An e-commerce product page with:

  • Title "<product> | Acme" via the template
  • Description from the product's short copy
  • A generated OG image showing name, price, and rating
  • JSON-LD Product with offers and aggregate rating
  • Canonical URL
  • noindex when the product is discontinued

💼 Interview Notes

Common Questions

Q: How does metadata work in the App Router? You export a metadata object or a generateMetadata function from a layout or page. Next.js resolves the tree, merges shallowly with deeper segments winning, and injects the tags into <head> during server rendering. You never write <head> yourself.

Q: metadata or generateMetadata? metadata for static values known at build time. generateMetadata when the values come from data — it's async and receives params and searchParams. You can't export both from one file.

Q: What is metadataBase and why does it matter? The absolute base URL used to resolve relative URLs in metadata — especially OG images. Without it, relative image paths don't resolve and social previews are blank. It's the single most commonly missed setting.

Q: How do you generate a dynamic OG image? An opengraph-image.tsx file exporting a function that returns an ImageResponse built from JSX. It renders through Satori — flexbox only, no grid, no external CSS, explicit display: flex on multi-child elements.

Q: Does dynamic metadata slow down rendering? generateMetadata runs before the page streams, so a slow fetch there delays the first byte. Keep it fast and rely on request deduplication so it shares queries with the page.

Q: How do you handle a site with a million URLs in a sitemap? generateSitemaps splits into chunks of 50,000. In Next.js 16 the id passed to the sitemap function is a Promise and must be awaited.

Q: What's JSON-LD for? Structured data that lets search engines produce rich results — star ratings, prices, FAQ accordions. Embed it as a <script type="application/ld+json"> in the page.

🏢 Asked at Companies

  • Vercel: "Design per-post OG images for a blog with 10,000 posts. What are the performance implications?"
  • Shopify: "How do you avoid duplicate content penalties on a store with filtered product listings?"
  • HubSpot: "A marketing site's traffic drops 90% overnight after a deploy. What do you check first?"
  • Medium: "Explain how a shared link becomes a preview card, end to end."

📊 Visual Memory Aid

              METADATA SOURCES

  app/layout.tsx
    export const metadata = { title: { template: '%s | Acme' } }
         │  merges down (shallow — deeper wins)
         ▼
  app/blog/[slug]/page.tsx
    export async function generateMetadata()   →  "Post Title | Acme"


              FILE CONVENTIONS

  favicon.ico           →  <link rel="icon">
  icon.png              →  <link rel="icon">
  apple-icon.png        →  <link rel="apple-touch-icon">
  opengraph-image.tsx   →  <meta property="og:image">
  twitter-image.tsx     →  <meta name="twitter:image">
  sitemap.ts            →  /sitemap.xml
  robots.ts             →  /robots.txt
  manifest.ts           →  /manifest.webmanifest


              NEXT.JS 16 ASYNC CHANGES

  Image({ params, id })          both are Promises  ← await them
  generateImageMetadata({params}) synchronous       ← unchanged
  sitemap({ id })                 Promise           ← await it
  generateSitemaps()              synchronous       ← unchanged


              ImageResponse CSS

  ✅ display: flex   flexbox   colors   gradients
     borders   shadows   font-size   <img src="absolute">

  ❌ grid   block   inline   float   CSS vars
     media queries   ::before   Tailwind classNames

  ⚠️ multiple children? explicit display: flex REQUIRED

🎯 Key Takeaways

  1. Export metadata, never write <head>. metadata for static values, generateMetadata for data-driven ones — and never both in the same file.
  2. Set metadataBase in the root layout. Without it, relative OG image URLs don't resolve and every social preview is blank.
  3. ImageResponse renders through Satori, not a browser. Flexbox only, inline styles only, and explicit display: flex on anything with multiple children.
  4. Next.js 16 made params and id Promises in opengraph-image, icon, and sitemap functions — while generateImageMetadata and generateSitemaps stayed synchronous.
  5. generateMetadata and the page share queries via request deduplication, so fetching the same data twice costs one query. Wrap non-fetch sources in cache().

Next Chapter: Images & Fonts →

Practice: Add complete metadata to a blog — root template, metadataBase, per-post generateMetadata, a generated OG image with a custom font, a sitemap, and robots.ts. Then paste a post URL into Slack and the Facebook debugger and confirm the card renders.


PreviousChapter 18: Authentication & AuthorizationNextChapter 20: Images & Fonts

Open source, free forever. Built by iammhador.

Contribute on GitHub