Next.js SEO: Meta Tags, Sitemap, and Structured Data
Optimize your Next.js app for search engines with the Metadata API, dynamic Open Graph images, sitemaps, robots.txt, and JSON-LD structured data.
What you'll learn
- ✓Using the Metadata API for static and dynamic meta tags
- ✓Generating sitemaps and robots.txt programmatically
- ✓Adding JSON-LD structured data to pages
- ✓Creating dynamic Open Graph images with next/og
Prerequisites
- •Next.js App Router basics
- •Basic understanding of HTML meta tags and SEO concepts
The Metadata API
Next.js provides a built-in Metadata API that handles all of your <head> tags. You export a metadata object or a generateMetadata function from any layout or page, and Next.js merges them automatically from the root down.
Static Metadata
For pages with fixed content, export a metadata object:
// app/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
metadataBase: new URL("https://mysite.com"),
title: {
template: "%s | My Site",
default: "My Site - Build Better Web Apps",
},
description:
"Learn modern web development with practical tutorials and guides.",
keywords: ["web development", "nextjs", "react", "tutorials"],
authors: [{ name: "Your Name" }],
creator: "Your Name",
openGraph: {
type: "website",
locale: "en_US",
siteName: "My Site",
},
twitter: {
card: "summary_large_image",
creator: "@yourhandle",
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
};
The title.template uses %s as a placeholder. When a child page sets title: "About", the rendered title becomes “About | My Site”.
Dynamic Metadata
For pages where metadata depends on data, use generateMetadata:
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
type 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,
openGraph: {
title: post.title,
description: post.excerpt,
type: "article",
publishedTime: post.publishedAt,
modifiedTime: post.updatedAt,
authors: [post.author.name],
images: [
{
url: post.coverImage || `/api/og?title=${encodeURIComponent(post.title)}`,
width: 1200,
height: 630,
alt: post.title,
},
],
},
twitter: {
card: "summary_large_image",
title: post.title,
description: post.excerpt,
images: [post.coverImage || `/api/og?title=${encodeURIComponent(post.title)}`],
},
};
}
export default async function BlogPost({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
Next.js deduplicates the getPost call between generateMetadata and the page component, so you are not making two separate database queries.
Generating a Sitemap
A sitemap tells search engines about all the pages on your site. Create it programmatically:
// app/sitemap.ts
import type { MetadataRoute } from "next";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = "https://mysite.com";
// Static pages
const staticPages: MetadataRoute.Sitemap = [
{
url: baseUrl,
lastModified: new Date(),
changeFrequency: "daily",
priority: 1,
},
{
url: `${baseUrl}/about`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.8,
},
{
url: `${baseUrl}/blog`,
lastModified: new Date(),
changeFrequency: "daily",
priority: 0.9,
},
{
url: `${baseUrl}/contact`,
lastModified: new Date(),
changeFrequency: "yearly",
priority: 0.5,
},
];
// Dynamic pages from database
const posts = await db.post.findMany({
where: { published: true },
select: { slug: true, updatedAt: true },
orderBy: { updatedAt: "desc" },
});
const postPages: MetadataRoute.Sitemap = posts.map((post) => ({
url: `${baseUrl}/blog/${post.slug}`,
lastModified: post.updatedAt,
changeFrequency: "weekly" as const,
priority: 0.7,
}));
return [...staticPages, ...postPages];
}
Next.js generates the XML sitemap at /sitemap.xml automatically.
Large Sitemaps with Multiple Files
If your site has thousands of pages, split the sitemap into multiple files:
// app/sitemap.ts
import type { MetadataRoute } from "next";
export async function generateSitemaps() {
const totalPosts = await db.post.count({ where: { published: true } });
const perSitemap = 50000;
const count = Math.ceil(totalPosts / perSitemap);
return Array.from({ length: count }, (_, i) => ({ id: i }));
}
export default async function sitemap({
id,
}: {
id: number;
}): Promise<MetadataRoute.Sitemap> {
const baseUrl = "https://mysite.com";
const perSitemap = 50000;
const posts = await db.post.findMany({
where: { published: true },
select: { slug: true, updatedAt: true },
skip: id * perSitemap,
take: perSitemap,
orderBy: { createdAt: "asc" },
});
return posts.map((post) => ({
url: `${baseUrl}/blog/${post.slug}`,
lastModified: post.updatedAt,
}));
}
This generates /sitemap/0.xml, /sitemap/1.xml, etc., along with a sitemap index file.
Configuring robots.txt
Control which pages search engines can crawl:
// app/robots.ts
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
const baseUrl = "https://mysite.com";
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/api/", "/admin/", "/dashboard/", "/settings/"],
},
{
userAgent: "GPTBot",
disallow: "/",
},
],
sitemap: `${baseUrl}/sitemap.xml`,
};
}
This produces a robots.txt that allows general crawling but blocks private sections and specific bots.
JSON-LD Structured Data
Structured data helps search engines understand your content and can earn rich results like star ratings, FAQ dropdowns, and breadcrumbs in search results.
Article Structured Data
// components/StructuredData.tsx
type ArticleProps = {
title: string;
description: string;
author: string;
publishedAt: string;
modifiedAt: string;
url: string;
imageUrl: string;
};
export function ArticleJsonLd({
title,
description,
author,
publishedAt,
modifiedAt,
url,
imageUrl,
}: ArticleProps) {
const jsonLd = {
"@context": "https://schema.org",
"@type": "Article",
headline: title,
description: description,
author: {
"@type": "Person",
name: author,
},
datePublished: publishedAt,
dateModified: modifiedAt,
image: imageUrl,
url: url,
publisher: {
"@type": "Organization",
name: "My Site",
logo: {
"@type": "ImageObject",
url: "https://mysite.com/logo.png",
},
},
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
);
}
Use it in your blog post page:
// app/blog/[slug]/page.tsx
import { ArticleJsonLd } from "@/components/StructuredData";
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}
author={post.author.name}
publishedAt={post.publishedAt}
modifiedAt={post.updatedAt}
url={`https://mysite.com/blog/${slug}`}
imageUrl={post.coverImage}
/>
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
</>
);
}
FAQ Structured Data
FAQ schema can get your content displayed as expandable answers in search results:
// components/FaqJsonLd.tsx
type 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) }}
/>
);
}
Breadcrumb Structured Data
Breadcrumbs help search engines understand your site hierarchy:
// components/BreadcrumbJsonLd.tsx
type BreadcrumbItem = {
name: string;
url: string;
};
export function BreadcrumbJsonLd({ items }: { items: BreadcrumbItem[] }) {
const jsonLd = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: items.map((item, index) => ({
"@type": "ListItem",
position: index + 1,
name: item.name,
item: item.url,
})),
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
);
}
Dynamic Open Graph Images
Generate social media preview images on-the-fly using next/og:
// app/api/og/route.tsx
import { ImageResponse } from "next/og";
export const runtime = "edge";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const title = searchParams.get("title") || "My Site";
const description = searchParams.get("description") || "";
return new ImageResponse(
(
<div
style={{
height: "100%",
width: "100%",
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
justifyContent: "center",
backgroundColor: "#0f172a",
padding: "60px 80px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
marginBottom: "40px",
}}
>
<div
style={{
width: "48px",
height: "48px",
borderRadius: "12px",
backgroundColor: "#3b82f6",
marginRight: "16px",
}}
/>
<span style={{ color: "#94a3b8", fontSize: "24px" }}>
mysite.com
</span>
</div>
<h1
style={{
fontSize: "64px",
fontWeight: "bold",
color: "#f8fafc",
lineHeight: 1.2,
marginBottom: "20px",
}}
>
{title}
</h1>
{description && (
<p
style={{
fontSize: "28px",
color: "#94a3b8",
lineHeight: 1.4,
}}
>
{description}
</p>
)}
</div>
),
{
width: 1200,
height: 630,
}
);
}
Reference this endpoint in your metadata:
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
return {
openGraph: {
images: [
{
url: `/api/og?title=${encodeURIComponent(post.title)}&description=${encodeURIComponent(post.excerpt)}`,
width: 1200,
height: 630,
},
],
},
};
}
Canonical URLs and Pagination
Set canonical URLs to prevent duplicate content issues:
// app/blog/page.tsx
import type { Metadata } from "next";
type Props = {
searchParams: Promise<{ page?: string }>;
};
export async function generateMetadata({ searchParams }: Props): Promise<Metadata> {
const { page } = await searchParams;
const currentPage = parseInt(page || "1");
return {
title: currentPage > 1 ? `Blog - Page ${currentPage}` : "Blog",
alternates: {
canonical: currentPage === 1 ? "/blog" : `/blog?page=${currentPage}`,
},
};
}
Performance and Core Web Vitals
SEO is not just about meta tags. Google considers page performance through Core Web Vitals. Here are patterns that help:
// app/blog/[slug]/page.tsx
import Image from "next/image";
export default async function BlogPost({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug);
return (
<article>
{/* Priority loading for the hero image (LCP optimization) */}
<Image
src={post.coverImage}
alt={post.title}
width={1200}
height={630}
priority
className="w-full rounded-lg"
/>
<h1 className="text-4xl font-bold mt-8 mb-4">{post.title}</h1>
{/* Preload critical fonts in your layout */}
<div className="prose prose-lg max-w-none">
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</div>
</article>
);
}
Key performance tips for SEO:
- Use
priorityon above-the-fold images to optimize Largest Contentful Paint. - Use
next/imagefor automatic format conversion, sizing, and lazy loading. - Minimize client-side JavaScript with Server Components.
- Use
loading.tsxand Suspense boundaries for Cumulative Layout Shift prevention.
SEO Checklist Component
Here is a practical checklist you can reference when setting up SEO for any Next.js page:
// Example: complete SEO setup for a product page
// app/products/[id]/page.tsx
import type { Metadata } from "next";
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const product = await getProduct(id);
return {
title: product.name,
description: product.description.slice(0, 160),
alternates: {
canonical: `/products/${id}`,
},
openGraph: {
title: product.name,
description: product.description.slice(0, 160),
type: "website",
images: [{ url: product.imageUrl, width: 1200, height: 630 }],
},
twitter: {
card: "summary_large_image",
title: product.name,
description: product.description.slice(0, 160),
images: [product.imageUrl],
},
};
}
Every page should have: a unique title under 60 characters, a description between 120 and 160 characters, a canonical URL, Open Graph tags, and Twitter Card tags. Structured data is a bonus that unlocks rich results.
Wrapping Up
Next.js gives you first-class tools for SEO through the Metadata API, file-based sitemap and robots.txt generation, and the next/og package for dynamic social images. Structured data with JSON-LD earns you rich search results, and Server Components naturally produce fast, crawlable pages.
The key is to set up your root layout with sensible defaults (template titles, global Open Graph settings, robots configuration) and then override specific metadata in each page with generateMetadata. Combined with a programmatic sitemap and structured data components, your Next.js app will be fully optimized for search engines from day one.
Related articles
- Next.js Dynamic OG Image Generation in Next.js
Generate dynamic Open Graph images in Next.js using @vercel/og and the ImageResponse API for social media previews with custom fonts and layouts.
- Next.js Next.js Metadata API for SEO
Master the Next.js Metadata API with static and dynamic titles, OpenGraph tags, sitemaps, and robots.txt to ship pages that rank and share well.
- Next.js Next.js API Routes: Best Practices and Patterns
Build robust Next.js API routes with proper validation, error handling, authentication, rate limiting, and testing patterns for production apps.
- Next.js Next.js i18n: Complete Internationalization Guide
Build multilingual Next.js apps with the App Router using locale routing, server-side translations, dynamic language switching, and SEO best practices.