Next.js Interview Prep
Performance and Optimization

Metadata & SEO in Next.js

The Built-In SEO Engine

LinkedIn Hook

"Your React app has 200 pages. Google sees 1."

That's the dirty secret of client-rendered SPAs. The crawler arrives, downloads an empty <div id="root">, finds no title, no description, no Open Graph tags — and moves on. Every page looks identical to search engines because the HTML that ships from the server is identical.

Next.js solved this with a first-class Metadata API. You export a metadata object (or an async generateMetadata function) from any page or layout, and Next.js injects a fully-populated <head> into the server-rendered HTML — before a single byte of JavaScript runs.

No more react-helmet. No more manual <head> manipulation. No more wondering whether Twitter will find your card image. Just type-safe objects, nested inheritance through layouts, and robots.ts + sitemap.ts files that replace static XML with real code.

In Lesson 7.4, I break down the entire Metadata API — static exports, dynamic generation, title templates, Open Graph, JSON-LD structured data, and how SSR/SSG turns Next.js into the best SEO framework in the React ecosystem.

Read the full lesson -> [link]

#NextJS #SEO #WebDevelopment #OpenGraph #StructuredData #InterviewPrep


Metadata & SEO in Next.js thumbnail


What You'll Learn

  • How to export a static metadata object from pages and layouts
  • How generateMetadata works for dynamic, data-driven meta tags
  • Title templates and how inheritance flows through nested layouts
  • Open Graph and Twitter card configuration for rich social previews
  • Icons, favicons, and Apple touch icons via file conventions
  • Building robots.txt and sitemap.xml with app/robots.ts and app/sitemap.ts
  • Canonical URLs and how to avoid duplicate content penalties
  • Embedding JSON-LD structured data for rich Google results
  • The viewport export and why it moved out of metadata
  • How SSR and SSG fundamentally change what crawlers see

The Storefront Sign Analogy — Why Metadata Matters

Imagine you own a bakery on a busy street. Every morning, you write your menu on a chalkboard sign outside the door. Passersby glance at the sign, decide whether to come in, and that decision happens in under two seconds. If your sign is blank, people walk past. If it says "Fresh Sourdough - $5" with a picture of a golden loaf, they stop.

Now imagine your bakery is a client-rendered React SPA. The storefront looks identical to every other store on the street — a blank gray wall. The "sign" is inside the shop, written on a whiteboard that's only visible after customers walk in, sit down, and wait for the staff to finish setting up. Most people never walk in.

That's what search engine crawlers and social media scrapers see when they hit a pure SPA. They fetch the HTML, find no metadata in <head>, take a screenshot of nothing, and move on. Facebook, Twitter, LinkedIn, Google — none of them execute JavaScript deeply enough to find the metadata your client code sets at runtime.

Next.js flips this. Metadata is generated on the server, injected into the HTML document before it's sent to the browser, and visible to every crawler instantly. Your storefront sign is already up when the first customer walks by.

+---------------------------------------------------------------+
|           CLIENT-RENDERED SPA (The Problem)                   |
+---------------------------------------------------------------+
|                                                                |
|  Crawler requests /products/shoes                              |
|  Server returns:                                               |
|    <html>                                                      |
|      <head>                                                    |
|        <title>My App</title>           <-- generic!            |
|      </head>                                                   |
|      <body><div id="root"></div></body>                        |
|    </html>                                                     |
|                                                                |
|  Crawler sees: generic title, no OG, no description           |
|  Result: poor ranking, blank social previews                   |
|                                                                |
+---------------------------------------------------------------+

+---------------------------------------------------------------+
|           NEXT.JS METADATA API (The Solution)                 |
+---------------------------------------------------------------+
|                                                                |
|  Crawler requests /products/shoes                              |
|  Server runs generateMetadata({ params })                      |
|  Server returns:                                               |
|    <html>                                                      |
|      <head>                                                    |
|        <title>Running Shoes - Nike Air Zoom</title>            |
|        <meta name="description" content="Lightweight...">     |
|        <meta property="og:image" content="/shoe.jpg">          |
|        <meta property="og:title" content="Running Shoes">      |
|        <link rel="canonical" href="https://shop.com/...">     |
|        <script type="application/ld+json">{...}</script>      |
|      </head>                                                   |
|      <body>...fully rendered HTML...</body>                    |
|    </html>                                                     |
|                                                                |
|  Crawler sees: everything. Indexed with rich results.          |
|                                                                |
+---------------------------------------------------------------+

Napkin AI Visual Prompt: "Dark gradient (#0a0e1a -> #111827). Split comparison: LEFT side labeled 'Client SPA' shows a Google crawler looking at an empty HTML document with a confused expression (red #ef4444 accents, empty head tag). RIGHT side labeled 'Next.js SSR' shows the same crawler reading a fully-populated head section with title, description, Open Graph, and JSON-LD (green #10b981 checkmarks glowing). Purple (#8b5cf6) divider. White monospace labels."


Static Metadata — The Simplest Export

For pages whose metadata never changes, you export a plain metadata object. Next.js reads it at build time (for static pages) or request time (for dynamic ones) and emits the matching <head> tags.

Basic Page Metadata

// app/about/page.tsx
import type { Metadata } from 'next';

// Export a typed metadata object — Next.js picks it up automatically
export const metadata: Metadata = {
  title: 'About Us',
  description: 'Learn about our mission, team, and company history.',
  keywords: ['company', 'team', 'mission', 'about'],
  authors: [{ name: 'Jane Doe', url: 'https://example.com/jane' }],
  creator: 'Jane Doe',
  publisher: 'Example Inc.',
};

export default function AboutPage() {
  return <h1>About Us</h1>;
}

What Next.js renders into <head>:

<title>About Us</title>
<meta name="description" content="Learn about our mission, team, and company history." />
<meta name="keywords" content="company,team,mission,about" />
<meta name="author" content="Jane Doe" />
<meta name="creator" content="Jane Doe" />
<meta name="publisher" content="Example Inc." />

You get full type safety — misspelling a property throws a TypeScript error, and autocomplete suggests every valid field. No more memorizing meta tag names.

Root Layout Metadata — The Defaults

The root layout.tsx is where you define site-wide defaults. Every page inherits these unless it overrides them.

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

export const metadata: Metadata = {
  // Set a base URL so relative og:image paths resolve correctly
  metadataBase: new URL('https://shop.example.com'),

  // Title template applies to every child page
  title: {
    default: 'Example Shop - The Best Store Online',
    template: '%s | Example Shop',  // %s is replaced by child page title
  },

  description: 'Curated products from around the world.',

  // Applicable to all pages unless overridden
  robots: {
    index: true,
    follow: true,
    googleBot: {
      index: true,
      follow: true,
      'max-image-preview': 'large',
    },
  },
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

Title Templates — Inheritance in Action

When a child page sets title: 'Running Shoes', the template resolves to Running Shoes | Example Shop. The default kicks in only when the child page has no title at all. If a child wants to opt out of the template entirely, it can use title: { absolute: 'Custom Title' }.

+---------------------------------------------------------------+
|           TITLE TEMPLATE RESOLUTION                           |
+---------------------------------------------------------------+
|                                                                |
|  Root layout template: '%s | Example Shop'                     |
|                                                                |
|  Child sets title: 'Running Shoes'                             |
|  -> Renders: 'Running Shoes | Example Shop'                    |
|                                                                |
|  Child sets NO title                                           |
|  -> Renders: 'Example Shop - The Best Store Online' (default)  |
|                                                                |
|  Child sets title: { absolute: 'Home' }                        |
|  -> Renders: 'Home' (template ignored)                         |
|                                                                |
+---------------------------------------------------------------+

generateMetadata — Dynamic, Data-Driven Meta Tags

Static metadata works when the title is known ahead of time. But product pages, blog posts, and user profiles need metadata that depends on data fetched at request time. That's where generateMetadata comes in — an async function that receives route params and returns a Metadata object.

Dynamic Blog Post Metadata

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';

// Fetch the post data once — Next.js dedupes calls across metadata + page
async function getPost(slug: string) {
  const res = await fetch(`https://api.example.com/posts/${slug}`);
  if (!res.ok) return null;
  return res.json();
}

// generateMetadata runs on the server before the page renders
export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}): Promise<Metadata> {
  // Await params (Next.js 15+ returns them as a Promise)
  const { slug } = await params;
  const post = await getPost(slug);

  // Graceful fallback if the post does not exist
  if (!post) {
    return {
      title: 'Post Not Found',
      description: 'The requested blog post could not be found.',
    };
  }

  return {
    title: post.title,
    description: post.excerpt,
    authors: [{ name: post.author.name }],
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: 'article',
      publishedTime: post.publishedAt,
      authors: [post.author.name],
      images: [
        {
          url: post.coverImage,
          width: 1200,
          height: 630,
          alt: post.title,
        },
      ],
    },
  };
}

export default async function BlogPostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) notFound();

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

Key insight: Next.js automatically deduplicates the getPost(slug) call. The fetch happens once — generateMetadata and the page component share the same cached result. You don't pay a double-fetch cost for rich metadata.


Open Graph and Twitter Cards — Rich Social Previews

When someone pastes your URL into Slack, Twitter, Facebook, or LinkedIn, the platform scrapes your page and displays a preview card. Open Graph (Facebook's standard, now universal) and Twitter cards control exactly how that preview looks.

Full Open Graph + Twitter Configuration

// app/products/[id]/page.tsx
import type { Metadata } from 'next';

export async function generateMetadata({
  params,
}: {
  params: Promise<{ id: string }>;
}): Promise<Metadata> {
  const { id } = await params;
  const product = await fetch(`https://api.shop.com/products/${id}`).then(r => r.json());

  return {
    title: product.name,
    description: product.shortDescription,

    // Open Graph: used by Facebook, LinkedIn, Slack, Discord, iMessage
    openGraph: {
      title: product.name,
      description: product.shortDescription,
      url: `https://shop.example.com/products/${id}`,
      siteName: 'Example Shop',
      locale: 'en_US',
      type: 'website',
      images: [
        {
          url: product.imageUrl,       // Absolute URL or relative to metadataBase
          width: 1200,                  // Recommended: 1200x630
          height: 630,
          alt: `${product.name} product photo`,
        },
      ],
    },

    // Twitter card: overrides Open Graph when posted to Twitter/X
    twitter: {
      card: 'summary_large_image',     // Large hero image layout
      title: product.name,
      description: product.shortDescription,
      site: '@exampleshop',             // Account that owns the site
      creator: '@johndoe',              // Content author account
      images: [product.imageUrl],
    },
  };
}
+---------------------------------------------------------------+
|           OG IMAGE DIMENSIONS GUIDE                           |
+---------------------------------------------------------------+
|                                                                |
|  Recommended: 1200 x 630 pixels  (1.91:1 aspect ratio)         |
|                                                                |
|  +--------------------------------------------------+          |
|  |                                                  |          |
|  |             Your Product Image                  |          |
|  |             1200 x 630 hero shot                 |          |
|  |                                                  |          |
|  +--------------------------------------------------+          |
|  | Product Name                                     |          |
|  | Short description under 200 characters           |          |
|  | example.com                                      |          |
|  +--------------------------------------------------+          |
|                                                                |
|  Minimum: 600 x 315 (below this, cards fallback to thumb)      |
|  Max file size: 5 MB (some platforms reject larger)            |
|                                                                |
+---------------------------------------------------------------+

Dynamic OG Images with opengraph-image.tsx

Next.js supports a file convention where placing an opengraph-image.tsx next to a page generates a dynamic image at build or request time using React components and the Edge runtime.

// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og';

// Tell Next.js the output dimensions
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';

// Runs per-route and returns a generated PNG
export default async function Image({ params }: { params: { slug: string } }) {
  const post = await fetch(`https://api.example.com/posts/${params.slug}`).then(r => r.json());

  return new ImageResponse(
    (
      <div
        style={{
          fontSize: 60,
          background: 'linear-gradient(135deg, #0a0e1a, #111827)',
          color: 'white',
          width: '100%',
          height: '100%',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
          padding: 80,
        }}
      >
        {post.title}
      </div>
    ),
    { ...size }
  );
}

Icons and Favicons — File Convention Magic

Next.js uses a file-based convention for icons. Drop these files into your app directory and Next.js automatically emits the right <link> tags.

+---------------------------------------------------------------+
|           ICON FILE CONVENTIONS                                |
+---------------------------------------------------------------+
|                                                                |
|  app/favicon.ico         -> <link rel="icon">                  |
|  app/icon.png            -> <link rel="icon">                  |
|  app/icon.svg            -> <link rel="icon" type="image/svg"> |
|  app/apple-icon.png      -> <link rel="apple-touch-icon">      |
|  app/icon.tsx            -> Dynamic, code-generated icon       |
|                                                                |
|  Size conventions read from filename:                          |
|  app/icon1.png           -> 32x32                              |
|  app/icon-192.png        -> 192x192                            |
|                                                                |
+---------------------------------------------------------------+

You can also declare icons explicitly in the metadata object:

// app/layout.tsx
export const metadata: Metadata = {
  icons: {
    icon: [
      { url: '/icon.svg', type: 'image/svg+xml' },
      { url: '/icon-32.png', sizes: '32x32', type: 'image/png' },
    ],
    apple: [
      { url: '/apple-icon.png', sizes: '180x180', type: 'image/png' },
    ],
    other: [
      { rel: 'mask-icon', url: '/safari-pinned-tab.svg', color: '#10b981' },
    ],
  },
};

robots.txt and sitemap.xml — Code, Not Config

Traditionally, robots.txt and sitemap.xml are static files dropped into the public folder. Next.js lets you generate them programmatically from app/robots.ts and app/sitemap.ts — which means they can include dynamic data from your database or CMS.

app/robots.ts

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

// Exported default is called at build time (or on-demand for dynamic routes)
export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      {
        userAgent: '*',                  // Applies to all crawlers
        allow: '/',                       // Allow the whole site
        disallow: ['/admin/', '/api/'],   // Except these private paths
      },
      {
        userAgent: 'GPTBot',              // Block OpenAI's crawler specifically
        disallow: '/',
      },
    ],
    sitemap: 'https://shop.example.com/sitemap.xml',
    host: 'https://shop.example.com',
  };
}

This emits a real robots.txt at /robots.txt:

User-Agent: *
Allow: /
Disallow: /admin/
Disallow: /api/

User-Agent: GPTBot
Disallow: /

Sitemap: https://shop.example.com/sitemap.xml
Host: https://shop.example.com

app/sitemap.ts

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

// Async sitemap: fetch dynamic URLs from your data source
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  // Fetch all product slugs from the database
  const products = await fetch('https://api.shop.com/products').then(r => r.json());

  // Static routes
  const staticRoutes: MetadataRoute.Sitemap = [
    {
      url: 'https://shop.example.com',
      lastModified: new Date(),
      changeFrequency: 'daily',
      priority: 1.0,
    },
    {
      url: 'https://shop.example.com/about',
      lastModified: new Date(),
      changeFrequency: 'monthly',
      priority: 0.5,
    },
  ];

  // Dynamic product pages — one entry per product
  const productRoutes: MetadataRoute.Sitemap = products.map((p: any) => ({
    url: `https://shop.example.com/products/${p.slug}`,
    lastModified: new Date(p.updatedAt),
    changeFrequency: 'weekly',
    priority: 0.8,
  }));

  return [...staticRoutes, ...productRoutes];
}

Large sites (50,000+ URLs): split into multiple sitemaps using the generateSitemaps convention — Next.js emits a sitemap index file automatically.


Canonical URLs — Stop Duplicate Content Penalties

If the same content is reachable at multiple URLs (/products/shoes, /products/shoes?ref=twitter, /products/shoes/), Google may penalize your site for duplicate content. The fix is a canonical URL — a single <link rel="canonical"> tag that tells Google which version is the "real" one.

// app/products/[id]/page.tsx
export async function generateMetadata({
  params,
}: {
  params: Promise<{ id: string }>;
}): Promise<Metadata> {
  const { id } = await params;

  return {
    alternates: {
      // Declare the canonical URL — all variants point here
      canonical: `https://shop.example.com/products/${id}`,
      // Optional: language alternates for i18n
      languages: {
        'en-US': `https://shop.example.com/products/${id}`,
        'es-ES': `https://shop.example.com/es/productos/${id}`,
        'fr-FR': `https://shop.example.com/fr/produits/${id}`,
      },
    },
  };
}

Structured Data (JSON-LD) — The Key to Rich Results

Meta tags tell Google what your page is. Structured data tells Google exactly what kind of thing it is — a product, a recipe, an event, an article — in a machine-readable schema. Google uses this to show rich results: star ratings, prices, cooking times, author photos.

JSON-LD lives inside a <script type="application/ld+json"> tag. Next.js doesn't wrap it in the metadata API (it would be awkward as an object), so you render it directly as a React component.

// app/products/[id]/page.tsx
export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const product = await getProduct(id);

  // Build the JSON-LD object following schema.org vocabulary
  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Product',
    name: product.name,
    image: product.images,
    description: product.description,
    sku: product.sku,
    brand: {
      '@type': 'Brand',
      name: product.brand,
    },
    offers: {
      '@type': 'Offer',
      url: `https://shop.example.com/products/${id}`,
      priceCurrency: 'USD',
      price: product.price,
      availability: product.inStock
        ? 'https://schema.org/InStock'
        : 'https://schema.org/OutOfStock',
    },
    aggregateRating: {
      '@type': 'AggregateRating',
      ratingValue: product.rating,
      reviewCount: product.reviewCount,
    },
  };

  return (
    <>
      {/* Inject the JSON-LD directly into the server-rendered HTML */}
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <article>
        <h1>{product.name}</h1>
        <p>{product.description}</p>
      </article>
    </>
  );
}

Because this is SSR'd, Google sees the structured data on first fetch — no JavaScript execution required. Test your markup with Google's Rich Results Test before shipping.


The Viewport Export — Separate from Metadata

Starting in Next.js 14, viewport and theme color moved out of metadata into their own viewport export. Keeping them separate lets Next.js optimize when each is emitted.

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

export const viewport: Viewport = {
  width: 'device-width',
  initialScale: 1,
  maximumScale: 5,
  userScalable: true,
  // Theme color can respond to light/dark mode preferences
  themeColor: [
    { media: '(prefers-color-scheme: light)', color: '#ffffff' },
    { media: '(prefers-color-scheme: dark)', color: '#0a0e1a' },
  ],
  colorScheme: 'light dark',
};

How SSR and SSG Fundamentally Change SEO

The metadata API only works because Next.js renders your HTML on the server. Here's why that matters:

+---------------------------------------------------------------+
|           WHAT CRAWLERS ACTUALLY SEE                          |
+---------------------------------------------------------------+
|                                                                |
|  GOOGLEBOT (modern, executes JS but with budget limits):       |
|    - Fetches HTML first pass                                   |
|    - Indexes head tags immediately                             |
|    - Queues JS-rendered content for second pass (delayed)      |
|                                                                |
|  SOCIAL SCRAPERS (Facebook, Twitter, Slack, LinkedIn):         |
|    - Fetch HTML once                                           |
|    - Do NOT execute JavaScript at all                          |
|    - Only see what is in the initial HTML response             |
|                                                                |
|  AI CRAWLERS (GPTBot, ClaudeBot, Perplexity):                  |
|    - Fetch HTML                                                |
|    - Most do not render JavaScript                             |
|                                                                |
|  CONCLUSION: metadata MUST be in the server response           |
|  Client-side metadata changes are invisible to most crawlers   |
|                                                                |
+---------------------------------------------------------------+

Next.js emits fully-populated <head> tags in the initial HTML response every time, whether the page is statically generated at build time, server-rendered on demand, or using ISR. The metadata is always there before the crawler is. That's the whole game.


Metadata Inheritance in Nested Layouts

Layouts form a tree. Each layout can export its own metadata, and Next.js merges them from root down to the leaf page. Children override parents for the same field — but OG and Twitter objects are replaced entirely, not deep-merged.

+---------------------------------------------------------------+
|           METADATA MERGE ORDER                                |
+---------------------------------------------------------------+
|                                                                |
|  app/layout.tsx             (root defaults)                    |
|      |                                                          |
|      v                                                          |
|  app/shop/layout.tsx        (shop section overrides)           |
|      |                                                          |
|      v                                                          |
|  app/shop/products/layout.tsx  (products section)              |
|      |                                                          |
|      v                                                          |
|  app/shop/products/[id]/page.tsx  (individual product)         |
|                                                                 |
|  Final metadata = merge of all levels, child wins              |
|  Title template from root applies unless overridden            |
|  Open Graph object replaced wholesale (not merged field-by-field) |
|                                                                 |
+---------------------------------------------------------------+
// app/shop/layout.tsx
// Shop section adds a title template applied to all shop pages
export const metadata: Metadata = {
  title: {
    template: '%s - Example Shop',
    default: 'Shop',
  },
  openGraph: {
    siteName: 'Example Shop',
    type: 'website',
  },
};

// app/shop/products/[id]/page.tsx
// Leaf page inherits the template, provides its own title
export async function generateMetadata({ params }): Promise<Metadata> {
  const product = await getProduct((await params).id);
  return {
    title: product.name,                    // Becomes "Nike Air Zoom - Example Shop"
    openGraph: {
      // WARNING: this REPLACES the parent OG entirely, does not merge siteName
      title: product.name,
      images: [product.image],
    },
  };
}

Common Mistakes

1. Forgetting to set metadataBase. Without metadataBase in the root layout, relative URLs in openGraph.images and alternates.canonical fail to resolve, and you'll see warnings in development. Always set it to your production domain: metadataBase: new URL('https://example.com'). You can use an environment variable to point to preview URLs in staging.

2. Assuming Open Graph objects merge across layouts. They do not. If the root layout sets openGraph.siteName and a child page sets openGraph.title, the child's OG object replaces the parent's entirely — siteName is lost. Either duplicate the fields in each child or factor them into a shared helper that returns a complete OG object.

3. Setting metadata in a Client Component. The metadata export and generateMetadata only work in Server Components. If you mark a page with 'use client' and try to export metadata, Next.js throws an error. Keep the page as a Server Component and push client-only logic into child components.

4. Fetching data twice for metadata and the page body. Beginners often duplicate fetch calls — once in generateMetadata and once in the page component. Thanks to React's cache deduping and Next.js fetch caching, calling the same URL with the same options in both functions results in a single network request. Just call the same helper twice and trust the cache.

5. Putting JSON-LD inside a Client Component or using React state. JSON-LD must be present in the server-rendered HTML. If you render it in a Client Component that mounts after hydration, Google won't see it during the initial crawl. Render it directly from a Server Component using dangerouslySetInnerHTML so it ships in the first HTML response.


Interview Questions

1. "Why can't you use react-helmet or manual head manipulation in a Next.js app for SEO?"

react-helmet and similar libraries mutate the <head> tag on the client after React hydrates. That means the metadata only appears in the DOM after JavaScript executes — which never happens for social media scrapers (Facebook, Twitter, LinkedIn, Slack) that fetch HTML once and never run JS. Even Googlebot, which does render JavaScript, indexes the initial HTML first and processes JS-rendered changes on a delayed second pass. Next.js's metadata API solves this by rendering meta tags during server-side rendering, so they appear in the initial HTML response every single crawler can see. It also gives you full type safety, eliminates the FOUC where the title briefly shows a default value, and integrates with layout inheritance.

2. "What is the difference between metadata and generateMetadata, and when would you use each?"

metadata is a static exported object — Next.js reads it once at build time (for static pages) or per-request (for dynamic routes). Use it for pages where the metadata doesn't depend on data: the about page, contact page, or marketing pages. generateMetadata is an async function that receives params and searchParams, fetches data, and returns a Metadata object. Use it whenever the title, description, or OG image depends on something you have to fetch — blog posts, product pages, user profiles. The critical optimization is that Next.js deduplicates fetch calls between generateMetadata and the page component itself, so you don't double-fetch by using both.

3. "How do title templates and metadata inheritance work across nested layouts?"

Layouts form a tree, and Next.js merges metadata from the root layout down to the leaf page. For simple fields like description or keywords, child values override parent values. Title templates have special behavior: a parent's title.template like '%s | Example Shop' applies to any child that sets a plain string title — the %s is replaced with the child's title. A child can opt out with title: { absolute: 'Home' }, which ignores the template entirely. One gotcha: Open Graph and Twitter objects are not deep-merged — if a child sets openGraph.title, the entire parent openGraph object is replaced, losing fields like siteName. You have to redefine them in the child or use a helper function.

4. "How does Next.js handle robots.txt and sitemap.xml differently from a static hosting setup?"

Traditional hosting requires you to write robots.txt and sitemap.xml as static files, which means any dynamic URLs have to be regenerated manually every time content changes. Next.js replaces this with app/robots.ts and app/sitemap.ts — default-exported functions that return structured objects which Next.js converts to the correct file format. The sitemap function can be async and fetch URLs from your database or CMS at build time, so every new blog post or product is automatically included. For large sites, the generateSitemaps convention splits output into multiple files with an index, staying under the 50,000-URL per-file limit. You get type safety, dynamic data, and the files are still served at the canonical /robots.txt and /sitemap.xml paths.

5. "Why is JSON-LD structured data important, and why must it be server-rendered?"

JSON-LD is the format Google uses to show rich results — star ratings on product pages, cooking times on recipes, FAQ accordions, author photos on articles. It's a JSON object following schema.org vocabulary, embedded in a <script type="application/ld+json"> tag in <head> or <body>. Google parses it to understand the exact semantic type of your content. It must be server-rendered because Google's first-pass indexer reads the initial HTML response and extracts structured data immediately. If you inject JSON-LD from a Client Component after hydration, you miss that window, and rich results may never show. In Next.js, you render it directly from a Server Component using dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }} so it ships in the initial HTML every time.


Quick Reference — Metadata & SEO Cheat Sheet

+---------------------------------------------------------------+
|           METADATA API CHEAT SHEET                            |
+---------------------------------------------------------------+
|                                                                |
|  STATIC METADATA:                                              |
|  export const metadata: Metadata = {                           |
|    title: 'Page Title',                                        |
|    description: '...',                                         |
|  }                                                             |
|                                                                |
|  DYNAMIC METADATA:                                             |
|  export async function generateMetadata({ params }) {          |
|    const data = await fetch(...)                               |
|    return { title: data.title }                                |
|  }                                                             |
|                                                                |
|  TITLE TEMPLATE (root layout):                                 |
|  title: { default: 'Site', template: '%s | Site' }             |
|                                                                |
|  OPEN GRAPH:                                                   |
|  openGraph: { title, description, images: [{url,width,height}]}|
|                                                                |
|  TWITTER CARD:                                                 |
|  twitter: { card: 'summary_large_image', title, images }       |
|                                                                |
|  ROBOTS FILE:                                                  |
|  app/robots.ts -> export default () => ({ rules, sitemap })    |
|                                                                |
|  SITEMAP FILE:                                                 |
|  app/sitemap.ts -> export default async () => [ { url, ... } ] |
|                                                                |
|  CANONICAL URL:                                                |
|  alternates: { canonical: 'https://...' }                      |
|                                                                |
|  JSON-LD:                                                      |
|  <script type="application/ld+json"                            |
|    dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }} /> |
|                                                                |
|  VIEWPORT (separate export):                                   |
|  export const viewport: Viewport = { themeColor, width }       |
|                                                                |
+---------------------------------------------------------------+

+---------------------------------------------------------------+
|           KEY RULES                                            |
+---------------------------------------------------------------+
|                                                                |
|  1. Always set metadataBase in root layout                     |
|  2. Metadata only works in Server Components                   |
|  3. OG and Twitter objects REPLACE, do not merge               |
|  4. generateMetadata shares cache with page fetches            |
|  5. JSON-LD must be rendered server-side, not after hydration  |
|  6. Use file conventions for icons (favicon.ico, icon.png)     |
|  7. Viewport and themeColor use separate `viewport` export     |
|  8. 1200x630 is the target OG image size                       |
|                                                                |
+---------------------------------------------------------------+
FeatureStatic metadatagenerateMetadata
SyntaxExported objectExported async function
Data fetchingNoYes
Receives paramsNoYes
Receives searchParamsNoYes
Typical useMarketing pagesProduct / blog / user pages
Fetch dedupingN/AShared with page component
Runs atBuild time (static)Request time (dynamic)
Tag CategoryWhere It GoesWho Uses It
title, descriptionmetadata / generateMetadataGoogle, Bing, all crawlers
openGraphmetadata.openGraphFacebook, LinkedIn, Slack, iMessage
twittermetadata.twitterTwitter / X
alternates.canonicalmetadata.alternatesGoogle (duplicate content)
robotsmetadata.robots / app/robots.tsAll crawlers
JSON-LDInline <script> in Server ComponentGoogle rich results
viewport, themeColorviewport exportMobile browsers
IconsFile convention or metadata.iconsBrowsers, iOS, Android

Prev: Lesson 7.3 -- Script Optimization in Next.js Next: Lesson 8.1 -- Build and Output in Next.js


This is Lesson 7.4 of the Next.js Interview Prep Course -- 8 chapters, 33 lessons.

On this page