Search engine optimization in Next.js has changed a lot with the App Router. Like, fundamentally changed. You no longer need to install third-party SEO packages or manually wire up <Head> tags everywhere. Next.js now ships with a first-class Metadata API that handles static metadata, dynamic metadata, sitemaps, robots directives, and even programmatic OG image generation — all through type-safe, server-rendered conventions.
Honestly, it's one of those features that makes you wonder how we ever lived without it.
This guide walks you through every SEO feature the App Router offers, with production-ready code examples you can drop into your project today.
How the Metadata API Works in the App Router
The App Router takes a declarative approach to metadata. Instead of placing <meta> tags inside a <Head> component, you export a metadata object or a generateMetadata function from any layout.tsx or page.tsx file. Next.js automatically merges metadata across nested layouts, streams it into the HTML <head>, and deduplicates tags.
That last part is huge — it eliminates an entire class of "forgot to update the title on that one page" bugs.
There are two approaches:
- Config-based metadata — export a static
metadataobject or a dynamicgenerateMetadatafunction - File-based metadata — drop special files like
opengraph-image.tsx,sitemap.ts, orrobots.tsinto route segments
File-based metadata always takes higher priority when both are used in the same segment.
Setting Up Static Metadata
For pages with fixed content — your homepage, about page, pricing page — static metadata is the simplest option. Just export a Metadata object from your layout or page:
// app/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
metadataBase: new URL("https://yoursite.com"),
title: {
default: "Next.js Launchpad",
template: "%s | Next.js Launchpad",
},
description:
"Tutorials, patterns, and deployment guides for Next.js App Router",
openGraph: {
type: "website",
locale: "en_US",
siteName: "Next.js Launchpad",
},
twitter: {
card: "summary_large_image",
},
robots: {
index: true,
follow: true,
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
A few key details worth noting:
metadataBase— Set this once in your root layout. All relative URLs in metadata (OG images, canonical links, sitemaps) resolve against it. Miss this and you'll get broken social previews.title.template— The%splaceholder gets replaced by the title from child segments. So a page exportingtitle: "Blog"renders as "Blog | Next.js Launchpad".title.default— Used when a child segment doesn't define its own title.
Dynamic Metadata with generateMetadata
Static metadata works great for fixed pages, but what about blog posts, product pages, or user profiles? You need metadata that depends on route parameters or fetched data. That's where generateMetadata comes in:
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getPost } from "@/lib/posts";
interface Props {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
if (!post) return {};
return {
title: post.title,
description: post.excerpt,
alternates: {
canonical: `/blog/${slug}`,
},
openGraph: {
title: post.title,
description: post.excerpt,
type: "article",
publishedTime: post.publishedAt,
modifiedTime: post.updatedAt,
authors: [post.author.name],
images: [
{
url: `/blog/${slug}/opengraph-image`,
width: 1200,
height: 630,
alt: post.title,
},
],
},
};
}
export default async function BlogPost({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
return <article>{/* render post */}</article>;
}
There are a few important behaviors to understand here:
- Automatic request memoization — The
getPost(slug)call ingenerateMetadataand in the page component is deduplicated by Next.js. The data is fetched once and shared. No wasted requests. - Async params in Next.js 16 — Starting with Next.js 16,
paramsis a Promise and must be awaited. The synchronous access pattern from Next.js 14 has been fully removed, so don't skip this. - Streaming — For dynamically rendered pages, Next.js streams metadata separately so the UI can begin rendering before
generateMetadataresolves. - Canonical URLs — Always set
alternates.canonicalon dynamic routes. Without it, URL variations like/blog/my-post?ref=twittercreate duplicate content issues.
Metadata Inheritance and Merging
Next.js merges metadata from parent layouts into child pages, but the merging rules matter — and they've tripped me up more than once:
- Primitive fields (title, description) — child values replace parent values
- Object fields (openGraph, robots) — child values shallow merge with parent values
- Array fields (openGraph.images) — child values replace parent values entirely
So if your root layout sets openGraph.siteName and a child page sets openGraph.title, both are preserved. But if the child sets openGraph.images, it completely replaces the parent images. That caught me off guard the first time.
To share common metadata across sibling routes, extract it into a variable:
// app/blog/shared-metadata.ts
export const blogOpenGraph = {
siteName: "Next.js Launchpad",
type: "article" as const,
locale: "en_US",
};
// app/blog/[slug]/page.tsx
import { blogOpenGraph } from "../shared-metadata";
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
return {
openGraph: {
...blogOpenGraph,
title: post.title,
description: post.excerpt,
},
};
}
Generating Dynamic OG Images
Social sharing images can genuinely make or break click-through rates. I've seen posts go from getting ignored to getting shared consistently just by adding a decent OG image. Instead of manually creating one for every page, Next.js lets you generate them programmatically using the ImageResponse API (powered by Satori and @vercel/og).
Option 1: The opengraph-image File Convention
The cleanest approach is placing an opengraph-image.tsx file inside any route segment. Next.js automatically registers it as the OG image for that route:
// 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({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
return new ImageResponse(
(
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
padding: "60px 80px",
background: "linear-gradient(135deg, #0f172a 0%, #1e293b 100%)",
width: "100%",
height: "100%",
fontFamily: "sans-serif",
color: "white",
}}
>
<div style={{ fontSize: 24, color: "#38bdf8", marginBottom: 16 }}>
Next.js Launchpad
</div>
<div
style={{
fontSize: 56,
fontWeight: 700,
lineHeight: 1.2,
maxWidth: "80%",
}}
>
{post.title}
</div>
<div
style={{
fontSize: 22,
color: "#94a3b8",
marginTop: 24,
}}
>
{post.author.name} · {post.readTime} min read
</div>
</div>
),
{ ...size }
);
}
This OG image is statically generated at build time by default and cached. Next.js automatically adds the correct <meta property="og:image"> tag — you don't need to wire anything manually in generateMetadata. Pretty slick.
Option 2: Route Handler for Full Control
When you need OG images with custom paths or query parameters, a Route Handler gives you more flexibility:
// app/api/og/route.tsx
import { ImageResponse } from "next/og";
import { type NextRequest } from "next/server";
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const title = searchParams.get("title") ?? "Next.js Launchpad";
const category = searchParams.get("category") ?? "Tutorial";
return new ImageResponse(
(
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "flex-end",
padding: "60px 80px",
background: "#000",
width: "100%",
height: "100%",
color: "#fff",
}}
>
<div style={{ fontSize: 18, color: "#3b82f6", textTransform: "uppercase" }}>
{category}
</div>
<div style={{ fontSize: 52, fontWeight: 700, marginTop: 12 }}>
{title}
</div>
</div>
),
{ width: 1200, height: 630 }
);
}
Limitations of ImageResponse
One thing to keep in mind: the underlying Satori engine only supports a subset of CSS. You get Flexbox, basic typography, borders, shadows, gradients, and absolute positioning. But it does not support CSS Grid, calc(), CSS variables, transform, or animations. Also, keep the total bundle (fonts + images + code) under 500KB or you'll hit silent deployment errors.
Programmatic Sitemaps
A sitemap tells search engines exactly what pages exist and when they were last updated. Without one, crawlers have to rely on link discovery alone, which can miss orphaned pages or delay indexing by days.
Basic Static Sitemap
// app/sitemap.ts
import type { MetadataRoute } from "next";
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: "https://yoursite.com",
lastModified: new Date(),
changeFrequency: "monthly",
priority: 1,
},
{
url: "https://yoursite.com/blog",
lastModified: new Date(),
changeFrequency: "weekly",
priority: 0.8,
},
{
url: "https://yoursite.com/about",
lastModified: new Date(),
changeFrequency: "yearly",
priority: 0.5,
},
];
}
Next.js automatically serves this at /sitemap.xml. No extra configuration needed.
Dynamic Sitemap with Database Content
For blogs, e-commerce sites, or anything with dynamic content, you'll want to generate the sitemap from your data source:
// app/sitemap.ts
import type { MetadataRoute } from "next";
import { getAllPosts } from "@/lib/posts";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts();
const baseUrl = "https://yoursite.com";
const staticRoutes: MetadataRoute.Sitemap = [
{ url: baseUrl, lastModified: new Date(), changeFrequency: "monthly", priority: 1 },
{ url: `${baseUrl}/blog`, lastModified: new Date(), changeFrequency: "weekly", priority: 0.8 },
];
const postRoutes: MetadataRoute.Sitemap = posts.map((post) => ({
url: `${baseUrl}/blog/${post.slug}`,
lastModified: new Date(post.updatedAt),
changeFrequency: "monthly",
priority: 0.6,
}));
return [...staticRoutes, ...postRoutes];
}
Multiple Sitemaps for Large Sites
Google limits sitemaps to 50,000 URLs each. If you're running a large site, use generateSitemaps to split things up:
// app/blog/sitemap.ts
import type { MetadataRoute } from "next";
import { getPostCount, getPostsBatch } from "@/lib/posts";
const URLS_PER_SITEMAP = 50000;
export async function generateSitemaps() {
const count = await getPostCount();
const totalSitemaps = Math.ceil(count / URLS_PER_SITEMAP);
return Array.from({ length: totalSitemaps }, (_, i) => ({ id: i }));
}
export default async function sitemap(props: {
id: Promise<string>;
}): Promise<MetadataRoute.Sitemap> {
const id = Number(await props.id);
const start = id * URLS_PER_SITEMAP;
const posts = await getPostsBatch(start, URLS_PER_SITEMAP);
return posts.map((post) => ({
url: `https://yoursite.com/blog/${post.slug}`,
lastModified: new Date(post.updatedAt),
}));
}
This generates a sitemap index at /blog/sitemap.xml pointing to /blog/sitemap/0.xml, /blog/sitemap/1.xml, and so on.
Configuring robots.ts
Control which pages search engines can crawl by exporting a function from app/robots.ts:
// app/robots.ts
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/api/", "/admin/", "/_next/"],
},
],
sitemap: "https://yoursite.com/sitemap.xml",
};
}
This serves automatically at /robots.txt. Always include the sitemap field — it's often the first thing bots check when they hit a new domain.
Adding JSON-LD Structured Data
JSON-LD is the format Google recommends for structured data. It's what enables those rich snippets in search results — star ratings, recipe cards, FAQ accordions, article metadata. The good stuff that actually gets clicks.
In Next.js, you render JSON-LD as a <script> tag in your Server Component:
Article Structured Data
// components/article-json-ld.tsx
interface ArticleJsonLdProps {
title: string;
description: string;
publishedAt: string;
updatedAt: string;
authorName: string;
image: string;
url: string;
}
export function ArticleJsonLd({
title,
description,
publishedAt,
updatedAt,
authorName,
image,
url,
}: ArticleJsonLdProps) {
const jsonLd = {
"@context": "https://schema.org",
"@type": "Article",
headline: title,
description,
image,
datePublished: publishedAt,
dateModified: updatedAt,
url,
author: {
"@type": "Person",
name: authorName,
},
publisher: {
"@type": "Organization",
name: "Next.js Launchpad",
},
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLd).replace(/</g, "\u003c"),
}}
/>
);
}
That .replace(/</g, "\u003c") call is easy to overlook, but it's important — it prevents XSS injection from untrusted data by escaping HTML tags within the JSON payload.
FAQ Structured Data
FAQ schema is especially valuable. It can trigger those expandable Q&A sections directly in Google search results, which take up way more real estate on the SERP:
// components/faq-json-ld.tsx
interface FaqItem {
question: string;
answer: string;
}
export function FaqJsonLd({ items }: { items: FaqItem[] }) {
const jsonLd = {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: items.map((item) => ({
"@type": "Question",
name: item.question,
acceptedAnswer: {
"@type": "Answer",
text: item.answer,
},
})),
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLd).replace(/</g, "\u003c"),
}}
/>
);
}
Type Safety with schema-dts
For larger projects, the schema-dts package gives you TypeScript types covering the full Schema.org vocabulary. It's a nice safety net:
import type { Article, WithContext } from "schema-dts";
const jsonLd: WithContext<Article> = {
"@context": "https://schema.org",
"@type": "Article",
headline: "Your Article Title",
// TypeScript will enforce valid properties
};
The Complete SEO Page: Putting It All Together
So, let's tie everything together. Here's a full blog post page with metadata, JSON-LD, and an OG image all wired up:
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getPost, getAllPostSlugs } from "@/lib/posts";
import { ArticleJsonLd } from "@/components/article-json-ld";
interface Props {
params: Promise<{ slug: string }>;
}
export async function generateStaticParams() {
const slugs = await getAllPostSlugs();
return slugs.map((slug) => ({ slug }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
if (!post) return {};
const url = `https://yoursite.com/blog/${slug}`;
return {
title: post.title,
description: post.excerpt,
alternates: { canonical: url },
openGraph: {
title: post.title,
description: post.excerpt,
url,
type: "article",
publishedTime: post.publishedAt,
modifiedTime: post.updatedAt,
authors: [post.author.name],
},
twitter: {
card: "summary_large_image",
title: post.title,
description: post.excerpt,
},
};
}
export default async function BlogPost({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
return (
<>
<ArticleJsonLd
title={post.title}
description={post.excerpt}
publishedAt={post.publishedAt}
updatedAt={post.updatedAt}
authorName={post.author.name}
image={`https://yoursite.com/blog/${slug}/opengraph-image`}
url={`https://yoursite.com/blog/${slug}`}
/>
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
</>
);
}
With this setup, every blog post automatically gets: a unique title and description, a canonical URL, OpenGraph and Twitter Card metadata, a dynamically generated OG image, and Article structured data for rich snippets. That's a pretty solid SEO foundation without reaching for any third-party packages.
Common SEO Mistakes to Avoid
Even with the Metadata API making things easier, these mistakes still trip up developers regularly. I've made a few of them myself:
- Missing
metadataBase— Without it, relative URLs in OG images and canonicals resolve incorrectly, and social media previews just break. Set it once in your root layout and forget about it. - Using
next-seoalongside the Metadata API — These two systems conflict. The Metadata API is the official replacement, so removenext-seowhen migrating to the App Router. - Rendering SEO-critical content client-side — Search engines index what the server sends. If your primary content depends on
useEffector client-only fetches, Google may never see it. Keep your content in Server Components. - Forgetting canonical URLs on dynamic routes — Without
alternates.canonical, URL variations create duplicate content. Always normalize. - Blocking assets in robots.txt — Disallowing
/_next/prevents Googlebot from loading your CSS and JavaScript, which tanks your Core Web Vitals scores. - Duplicate metadata from missing
generateMetadata— When dynamic routes inherit parent layout metadata without overriding it, every page gets identical titles and descriptions. Search engines don't love that. - Exceeding the ImageResponse 500KB bundle limit — Loading multiple font weights or large images into OG generation causes silent deployment failures. Stick to a single font weight and optimize your image assets.
Testing and Validating Your SEO Setup
After implementing all this metadata, you'll want to verify everything actually works. Here's what I'd recommend checking:
- Browser DevTools — Inspect
<head>to confirm correct<title>,<meta>, and<link rel="canonical">tags on every page. - Google Rich Results Test — Paste any URL to validate your JSON-LD structured data and check eligibility for rich snippets.
- Schema Markup Validator — For broader Schema.org validation beyond Google-specific types.
- Facebook Sharing Debugger — Verify Open Graph tags and preview how your pages appear when shared on Facebook and LinkedIn.
- Twitter Card Validator — Confirm your Twitter Card metadata renders correctly.
- Google Search Console — Monitor indexing status, crawl errors, Core Web Vitals, and structured data eligibility over time. This one's non-negotiable.
- Lighthouse — Run the SEO audit to catch missing alt text, incorrect viewport settings, and other accessibility issues that impact SEO.
Frequently Asked Questions
Can I use generateMetadata and the metadata object in the same file?
No. Next.js doesn't allow exporting both metadata and generateMetadata from the same route segment. Use metadata for static pages and generateMetadata when metadata depends on route params or fetched data. You can mix approaches across different files though — for example, a static metadata in layout.tsx and generateMetadata in a dynamic page.tsx.
How does metadata merging work across nested layouts?
Next.js merges metadata from root layout through each nested layout down to the page. Primitive fields like title and description are replaced by the deepest segment that defines them. Object fields like openGraph are shallow-merged — child properties override matching parent properties while unmatched parent properties are preserved. Array fields like openGraph.images are replaced entirely by the child definition.
Do I still need next-seo with the App Router?
Nope. The built-in Metadata API fully replaces next-seo. It offers static and dynamic metadata, OG image generation, sitemaps, and robots.txt configuration out of the box. Using both simultaneously can cause duplicate or conflicting meta tags. If you're migrating from the Pages Router, remove next-seo and switch to the native Metadata API.
How do I generate OG images for dynamic routes without exceeding bundle limits?
Use the opengraph-image.tsx file convention inside your route segment. Keep fonts to a single weight (one .ttf file), and avoid embedding large images directly — reference external URLs instead. The 500KB limit applies to the total bundle of your OG image route including fonts and code. If you need complex designs, consider pre-generating images in a build step and serving them as static assets.
How often should I revalidate my sitemap?
Sitemaps generated with sitemap.ts are cached by default. For sites with frequently published content, use ISR by adding export const revalidate = 3600 to regenerate the sitemap hourly. For real-time accuracy, you can call revalidatePath("/sitemap.xml") in a Server Action or webhook handler whenever content is published or updated.