React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

seometadatagenerateMetadatasitemaprobotsopengraphstructured-data

SEO and Metadata

Recipe

Implement SEO best practices in Next.js 15+ App Router using the Metadata API, dynamic generateMetadata, sitemaps, robots.txt, Open Graph images, and JSON-LD structured data.

Working Example

Static Metadata

// app/layout.tsx
import type { Metadata } from "next";
 
export const metadata: Metadata = {
  title: {
    default: "My App",
    template: "%s | My App",
  },
  description: "A modern web application built with Next.js",
  metadataBase: new URL("https://myapp.com"),
  openGraph: {
    type: "website",
    locale: "en_US",
    siteName: "My App",
  },
  twitter: {
    card: "summary_large_image",
    creator: "@myapp",
  },
  robots: {
    index: true,
    follow: true,
  },
};
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

Dynamic Metadata with generateMetadata

// app/posts/[slug]/page.tsx
import type { Metadata, ResolvingMetadata } from "next";
import { notFound } from "next/navigation";
 
type Props = {
  params: Promise<{ slug: string }>;
};
 
export async function generateMetadata(
  { params }: Props,
  parent: ResolvingMetadata
): Promise<Metadata> {
  const { slug } = await params;
  const post = await db.post.findUnique({ where: { slug } });
 
  if (!post) {
    return {};
  }
 
  const parentMetadata = await parent;
  const previousImages = parentMetadata.openGraph?.images ?? [];
 
  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: "article",
      publishedTime: post.createdAt.toISOString(),
      authors: [post.author.name],
      images: [
        {
          url: `/api/og?title=${encodeURIComponent(post.title)}`,
          width: 1200,
          height: 630,
          alt: post.title,
        },
        ...previousImages,
      ],
    },
    twitter: {
      card: "summary_large_image",
      title: post.title,
      description: post.excerpt,
    },
  };
}
 
export default async function PostPage({ params }: Props) {
  const { slug } = await params;
  const post = await db.post.findUnique({ where: { slug } });
 
  if (!post) notFound();
 
  return <article>{post.content}</article>;
}

Sitemap Generation

// app/sitemap.ts
import type { MetadataRoute } from "next";
 
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await db.post.findMany({
    select: { slug: true, updatedAt: true },
    orderBy: { updatedAt: "desc" },
  });
 
  const postEntries = posts.map((post) => ({
    url: `https://myapp.com/posts/${post.slug}`,
    lastModified: post.updatedAt,
    changeFrequency: "weekly" as const,
    priority: 0.8,
  }));
 
  const staticPages = [
    {
      url: "https://myapp.com",
      lastModified: new Date(),
      changeFrequency: "daily" as const,
      priority: 1.0,
    },
    {
      url: "https://myapp.com/about",
      lastModified: new Date(),
      changeFrequency: "monthly" as const,
      priority: 0.5,
    },
  ];
 
  return [...staticPages, ...postEntries];
}

Robots.txt

// app/robots.ts
import type { MetadataRoute } from "next";
 
export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      {
        userAgent: "*",
        allow: "/",
        disallow: ["/api/", "/admin/", "/dashboard/"],
      },
    ],
    sitemap: "https://myapp.com/sitemap.xml",
  };
}

JSON-LD Structured Data

// app/posts/[slug]/page.tsx
import type { WithContext, Article } from "schema-dts";
 
function JsonLd({ data }: { data: WithContext<Article> }) {
  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
    />
  );
}
 
export default async function PostPage({ params }: Props) {
  const { slug } = await params;
  const post = await db.post.findUnique({ where: { slug } });
 
  if (!post) notFound();
 
  const jsonLd: WithContext<Article> = {
    "@context": "https://schema.org",
    "@type": "Article",
    headline: post.title,
    description: post.excerpt,
    datePublished: post.createdAt.toISOString(),
    dateModified: post.updatedAt.toISOString(),
    author: {
      "@type": "Person",
      name: post.author.name,
    },
  };
 
  return (
    <>
      <JsonLd data={jsonLd} />
      <article>{post.content}</article>
    </>
  );
}

Dynamic OG Image

// app/api/og/route.tsx
import { ImageResponse } from "next/og";
import { NextRequest } from "next/server";
 
export const runtime = "edge";
 
export async function GET(request: NextRequest) {
  const title = request.nextUrl.searchParams.get("title") ?? "My App";
 
  return new ImageResponse(
    (
      <div
        style={{
          fontSize: 60,
          color: "white",
          background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
          width: "100%",
          height: "100%",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          padding: 60,
          textAlign: "center",
        }}
      >
        {title}
      </div>
    ),
    {
      width: 1200,
      height: 630,
    }
  );
}

Deep Dive

How It Works

  • The Metadata API is the primary way to set <head> tags in App Router. Export a metadata object or generateMetadata function from page.tsx or layout.tsx.
  • Metadata merges down the component tree. Child metadata overrides parent metadata. The title.template pattern in the root layout ("%s | My App") is applied to child page titles.
  • generateMetadata receives resolved parent metadata via the second argument. This lets you extend parent Open Graph images or other inherited values.
  • sitemap.ts and robots.ts are special file conventions. Next.js serves them at /sitemap.xml and /robots.txt automatically.
  • metadataBase sets the base URL for all relative metadata URLs (Open Graph images, canonical URLs). Always set it in the root layout.
  • ImageResponse from next/og generates dynamic Open Graph images using JSX at the edge. It uses Satori under the hood, which supports a subset of CSS (flexbox only, no grid).

Variations

Multiple Sitemaps (Large Sites):

// app/sitemap/[id]/route.ts
import { NextRequest } from "next/server";
 
export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const page = parseInt(id, 10);
  const perPage = 50000;
 
  const posts = await db.post.findMany({
    skip: page * perPage,
    take: perPage,
    select: { slug: true, updatedAt: true },
  });
 
  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  ${posts.map((post) => `
  <url>
    <loc>https://myapp.com/posts/${post.slug}</loc>
    <lastmod>${post.updatedAt.toISOString()}</lastmod>
  </url>`).join("")}
</urlset>`;
 
  return new Response(xml, {
    headers: { "Content-Type": "application/xml" },
  });
}

Canonical URLs:

export const metadata: Metadata = {
  alternates: {
    canonical: "/posts/my-post",
    languages: {
      "en-US": "/en/posts/my-post",
      "de-DE": "/de/posts/my-post",
    },
  },
};

TypeScript Notes

  • Import Metadata and ResolvingMetadata from "next" for full type inference.
  • MetadataRoute.Sitemap is an array of objects with url, lastModified, changeFrequency, and priority.
  • Use the schema-dts package for typed JSON-LD structured data.
  • generateMetadata params are async in Next.js 15+ (same Promise<{ slug: string }> pattern as pages).

Gotchas

  1. generateMetadata runs before the page component. Its data fetch is deduped with the page's fetch if the same URL is requested, but the function itself runs separately.
  2. metadataBase must be an absolute URL. Relative URLs in Open Graph images will be broken without it.
  3. Client Components cannot export metadata. The metadata object and generateMetadata function only work in Server Components (page.tsx and layout.tsx).
  4. ImageResponse only supports flexbox. CSS Grid, position: absolute (with exceptions), and many CSS properties are not supported by Satori.
  5. Sitemap files must return all URLs. There is no built-in pagination. For sites with more than 50,000 URLs, implement a sitemap index with multiple sitemap files manually.
  6. title.template only applies to child pages, not to the page where it is defined. The page itself uses title.default.

Alternatives

ApproachProsCons
Metadata API (built-in)Type-safe, automatic, colocatedCannot use in Client Components
next-seo packageFamiliar API from Pages Router eraRedundant with built-in Metadata API
Manual <head> tagsFull controlNo type safety, easy to miss tags
schema-dts for JSON-LDTyped structured dataExtra dependency
next-sitemap packageAutomatic generation, ISR supportExtra dependency, config overhead

FAQs

What is the difference between the metadata export and generateMetadata?
  • metadata is a static object for pages with fixed metadata (e.g., the home page).
  • generateMetadata is an async function for pages where metadata depends on dynamic data (e.g., a blog post).
  • Both are exported from page.tsx or layout.tsx and are Server Component only.
How does metadata merging work across the component tree?
  • Child metadata overrides parent metadata for the same fields.
  • The title.template in the root layout (e.g., "%s | My App") is applied to child page titles.
  • title.template only applies to child pages, not to the page where it is defined.
What is metadataBase and why is it required?
  • metadataBase sets the base URL for all relative metadata URLs (OG images, canonical URLs).
  • Without it, relative URLs in Open Graph tags will be broken.
  • Set it to your production URL in the root layout: metadataBase: new URL("https://myapp.com").
How do sitemap.ts and robots.ts work as file conventions?
  • Exporting a default function from app/sitemap.ts auto-serves it at /sitemap.xml.
  • Exporting a default function from app/robots.ts auto-serves it at /robots.txt.
  • Both can be async and fetch data from a database.
Gotcha: Can Client Components export metadata or generateMetadata?
  • No. The Metadata API only works in Server Components.
  • metadata and generateMetadata must be exported from page.tsx or layout.tsx without the "use client" directive.
What CSS limitations does ImageResponse (Satori) have for dynamic OG images?
  • Only flexbox layout is supported; CSS Grid does not work.
  • position: absolute has limited support.
  • Many CSS properties are unsupported; keep OG image styles simple.
How do you add JSON-LD structured data to a page?
<script
  type="application/ld+json"
  dangerouslySetInnerHTML={{
    __html: JSON.stringify(jsonLdObject),
  }}
/>
  • Render a <script type="application/ld+json"> tag in the page component.
  • Use the schema-dts package for typed JSON-LD objects.
How do you type generateMetadata params in TypeScript for Next.js 15+?
import type { Metadata, ResolvingMetadata } from "next";
 
type Props = {
  params: Promise<{ slug: string }>;
};
 
export async function generateMetadata(
  { params }: Props,
  parent: ResolvingMetadata
): Promise<Metadata> {
  const { slug } = await params;
  // ...
}
  • params is a Promise in Next.js 15+ and must be awaited.
What is ResolvingMetadata and how do you use it?
  • It is the second argument to generateMetadata, providing resolved parent metadata.
  • Use it to extend parent Open Graph images: const prev = (await parent).openGraph?.images ?? [].
  • This allows child pages to inherit and augment parent metadata.
How do you handle sitemaps for sites with more than 50,000 URLs?
  • The built-in sitemap.ts convention has no pagination support.
  • Implement a sitemap index with multiple sitemap files using Route Handlers.
  • Each sitemap file should contain up to 50,000 URLs per the sitemap protocol spec.
Gotcha: Does generateMetadata run before or after the page component?
  • generateMetadata runs before the page component.
  • If both fetch the same URL, Next.js deduplicates the request.
  • However, the function itself executes separately from the page render.
How would you type a JSON-LD structured data object using schema-dts?
import type { WithContext, Article } from "schema-dts";
 
const jsonLd: WithContext<Article> = {
  "@context": "https://schema.org",
  "@type": "Article",
  headline: post.title,
  datePublished: post.createdAt.toISOString(),
};
  • schema-dts provides TypeScript types for all Schema.org types.