//
Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
// 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>
);
}// 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>;
}// 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];
}// 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",
};
}// 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>
</>
);
}// 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,
}
);
}<head> tags in App Router. Export a metadata object or generateMetadata function from page.tsx or layout.tsx.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).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",
},
},
};Metadata and ResolvingMetadata from "next" for full type inference.MetadataRoute.Sitemap is an array of objects with url, lastModified, changeFrequency, and priority.schema-dts package for typed JSON-LD structured data.generateMetadata params are async in Next.js 15+ (same Promise<{ slug: string }> pattern as pages).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.metadataBase must be an absolute URL. Relative URLs in Open Graph images will be broken without it.metadata object and generateMetadata function only work in Server Components (page.tsx and layout.tsx).ImageResponse only supports flexbox. CSS Grid, position: absolute (with exceptions), and many CSS properties are not supported by Satori.title.template only applies to child pages, not to the page where it is defined. The page itself uses title.default.| 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 |
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).page.tsx or layout.tsx and are Server Component only.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.metadataBase sets the base URL for all relative metadata URLs (OG images, canonical URLs).metadataBase: new URL("https://myapp.com").app/sitemap.ts auto-serves it at /sitemap.xml.app/robots.ts auto-serves it at /robots.txt.metadata and generateMetadata must be exported from page.tsx or layout.tsx without the "use client" directive.position: absolute has limited support.<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLdObject),
}}
/><script type="application/ld+json"> tag in the page component.schema-dts package for typed JSON-LD objects.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.generateMetadata, providing resolved parent metadata.const prev = (await parent).openGraph?.images ?? [].sitemap.ts convention has no pagination support.generateMetadata runs before the page component.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.Reviewed by Chris St. John·Last updated Jul 7, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥