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 ametadataobject orgenerateMetadatafunction frompage.tsxorlayout.tsx. - Metadata merges down the component tree. Child metadata overrides parent metadata. The
title.templatepattern in the root layout ("%s | My App") is applied to child page titles. generateMetadatareceives resolved parent metadata via the second argument. This lets you extend parent Open Graph images or other inherited values.sitemap.tsandrobots.tsare special file conventions. Next.js serves them at/sitemap.xmland/robots.txtautomatically.metadataBasesets the base URL for all relative metadata URLs (Open Graph images, canonical URLs). Always set it in the root layout.ImageResponsefromnext/oggenerates 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
MetadataandResolvingMetadatafrom"next"for full type inference. MetadataRoute.Sitemapis an array of objects withurl,lastModified,changeFrequency, andpriority.- Use the
schema-dtspackage for typed JSON-LD structured data. generateMetadataparams are async in Next.js 15+ (samePromise<{ slug: string }>pattern as pages).
Gotchas
generateMetadataruns 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.metadataBasemust be an absolute URL. Relative URLs in Open Graph images will be broken without it.- Client Components cannot export metadata. The
metadataobject andgenerateMetadatafunction only work in Server Components (page.tsxandlayout.tsx). ImageResponseonly supports flexbox. CSS Grid,position: absolute(with exceptions), and many CSS properties are not supported by Satori.- 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.
title.templateonly applies to child pages, not to the page where it is defined. The page itself usestitle.default.
Alternatives
| Approach | Pros | Cons |
|---|---|---|
| Metadata API (built-in) | Type-safe, automatic, colocated | Cannot use in Client Components |
| next-seo package | Familiar API from Pages Router era | Redundant with built-in Metadata API |
Manual <head> tags | Full control | No type safety, easy to miss tags |
schema-dts for JSON-LD | Typed structured data | Extra dependency |
next-sitemap package | Automatic generation, ISR support | Extra dependency, config overhead |
FAQs
What is the difference between the metadata export and generateMetadata?
metadatais a static object for pages with fixed metadata (e.g., the home page).generateMetadatais an async function for pages where metadata depends on dynamic data (e.g., a blog post).- Both are exported from
page.tsxorlayout.tsxand are Server Component only.
How does metadata merging work across the component tree?
- Child metadata overrides parent metadata for the same fields.
- The
title.templatein the root layout (e.g.,"%s | My App") is applied to child page titles. title.templateonly applies to child pages, not to the page where it is defined.
What is metadataBase and why is it required?
metadataBasesets 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.tsauto-serves it at/sitemap.xml. - Exporting a default function from
app/robots.tsauto-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.
metadataandgenerateMetadatamust be exported frompage.tsxorlayout.tsxwithout 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: absolutehas 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-dtspackage 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;
// ...
}paramsis aPromisein 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.tsconvention 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?
generateMetadataruns 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-dtsprovides TypeScript types for all Schema.org types.
Related
- Internationalization - hreflang and locale-specific metadata
- Deployment - ensuring metadata works in production
- API Route Handlers - dynamic OG image routes
- Next.js Metadata Docs