SEO and Metadata in Next.js
Understanding SEO in Next.js
Search Engine Optimization (SEO) is critical for making your website discoverable. Next.js provides comprehensive built-in features for managing metadata, generating sitemaps, and controlling search engine indexing. Proper SEO implementation can significantly improve your website's visibility and ranking in search results.
The Metadata API
Next.js 13+ introduces the Metadata API for managing page metadata in Server Components:
// app/page.tsx
import { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Home - My Awesome Website',
description: 'Welcome to my awesome website with great products and services',
keywords: ['products', 'services', 'e-commerce'],
};
export default function HomePage() {
return <h1>Welcome</h1>;
}
Dynamic Metadata with generateMetadata
For dynamic pages, use the generateMetadata function to create metadata based on route parameters or data:
// app/products/[id]/page.tsx
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
interface Product {
id: string;
name: string;
description: string;
price: number;
image: string;
category: string;
}
async function getProduct(id: string): Promise<Product | null> {
const res = await fetch(`https://api.example.com/products/${id}`);
if (!res.ok) return null;
return res.json();
}
export async function generateMetadata({
params,
}: {
params: { id: string };
}): Promise<Metadata> {
const product = await getProduct(params.id);
if (!product) {
return {
title: 'Product Not Found',
};
}
return {
title: `${product.name} - Buy Now`,
description: product.description,
keywords: [product.name, product.category, 'buy online'],
openGraph: {
title: product.name,
description: product.description,
images: [
{
url: product.image,
width: 1200,
height: 630,
alt: product.name,
},
],
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: product.name,
description: product.description,
images: [product.image],
},
};
}
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id);
if (!product) {
notFound();
}
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p>${product.price}</p>
</div>
);
}
Open Graph and Social Media Tags
Configure Open Graph tags for better social media sharing:
// app/blog/[slug]/page.tsx
import { Metadata } from 'next';
export async function generateMetadata({
params,
}: {
params: { slug: string };
}): Promise<Metadata> {
const post = await getPost(params.slug);
return {
title: post.title,
description: post.excerpt,
authors: [{ name: post.author }],
openGraph: {
title: post.title,
description: post.excerpt,
url: `https://example.com/blog/${params.slug}`,
siteName: 'My Blog',
images: [
{
url: post.coverImage,
width: 1200,
height: 630,
alt: post.title,
},
],
locale: 'en_US',
type: 'article',
publishedTime: post.publishedAt,
modifiedTime: post.updatedAt,
authors: [post.author],
tags: post.tags,
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
creator: '@myhandle',
images: [post.coverImage],
},
};
}
Canonical URLs and Alternate Languages
Configure canonical URLs and language alternatives for better SEO:
// app/[locale]/products/[id]/page.tsx
import { Metadata } from 'next';
export async function generateMetadata({
params,
}: {
params: { locale: string; id: string };
}): Promise<Metadata> {
const baseUrl = 'https://example.com';
const productPath = `/products/${params.id}`;
return {
title: 'Product Name',
description: 'Product description',
alternates: {
canonical: `${baseUrl}/${params.locale}${productPath}`,
languages: {
'en-US': `${baseUrl}/en${productPath}`,
'ar-SA': `${baseUrl}/ar${productPath}`,
'fr-FR': `${baseUrl}/fr${productPath}`,
'x-default': `${baseUrl}/en${productPath}`,
},
},
};
}
Robots Meta Tags
Control search engine crawling and indexing:
// app/admin/page.tsx - Don't index admin pages
import { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Admin Dashboard',
robots: {
index: false,
follow: false,
nocache: true,
},
};
// app/products/page.tsx - Allow indexing
export const metadata: Metadata = {
title: 'Products',
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
};
Creating robots.txt
Generate a robots.txt file to control crawler access:
// app/robots.ts
import { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/admin', '/api', '/_next'],
},
{
userAgent: 'Googlebot',
allow: '/',
disallow: ['/admin'],
},
],
sitemap: 'https://example.com/sitemap.xml',
};
}
// This generates:
// User-agent: *
// Allow: /
// Disallow: /admin
// Disallow: /api
// Disallow: /_next
//
// User-agent: Googlebot
// Allow: /
// Disallow: /admin
//
// Sitemap: https://example.com/sitemap.xml
Generating Sitemap.xml
Create a dynamic sitemap for search engines:
// app/sitemap.ts
import { MetadataRoute } from 'next';
async function getProducts() {
const res = await fetch('https://api.example.com/products');
return res.json();
}
async function getBlogPosts() {
const res = await fetch('https://api.example.com/posts');
return res.json();
}
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = 'https://example.com';
// Static pages
const staticPages = [
{
url: baseUrl,
lastModified: new Date(),
changeFrequency: 'daily' as const,
priority: 1,
},
{
url: `${baseUrl}/about`,
lastModified: new Date(),
changeFrequency: 'monthly' as const,
priority: 0.8,
},
{
url: `${baseUrl}/contact`,
lastModified: new Date(),
changeFrequency: 'yearly' as const,
priority: 0.5,
},
];
// Dynamic product pages
const products = await getProducts();
const productPages = products.map((product: any) => ({
url: `${baseUrl}/products/${product.id}`,
lastModified: new Date(product.updatedAt),
changeFrequency: 'weekly' as const,
priority: 0.7,
}));
// Dynamic blog posts
const posts = await getBlogPosts();
const blogPages = posts.map((post: any) => ({
url: `${baseUrl}/blog/${post.slug}`,
lastModified: new Date(post.updatedAt),
changeFrequency: 'monthly' as const,
priority: 0.6,
}));
return [...staticPages, ...productPages, ...blogPages];
}
Sitemap Index for Large Sites
For sites with many pages, create a sitemap index:
// app/sitemap-index.ts
import { MetadataRoute } from 'next';
export default function sitemapIndex(): MetadataRoute.Sitemap {
return [
{
url: 'https://example.com/sitemap-products.xml',
lastModified: new Date(),
},
{
url: 'https://example.com/sitemap-blog.xml',
lastModified: new Date(),
},
{
url: 'https://example.com/sitemap-pages.xml',
lastModified: new Date(),
},
];
}
// app/sitemap-products.xml.ts
export default async function productsSitemap(): Promise<MetadataRoute.Sitemap> {
const products = await getProducts();
return products.map((product: any) => ({
url: `https://example.com/products/${product.id}`,
lastModified: new Date(product.updatedAt),
}));
}
JSON-LD Structured Data
Add structured data for rich search results:
// components/StructuredData.tsx
export default function StructuredData({ data }: { data: any }) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>
);
}
// app/products/[id]/page.tsx
import StructuredData from '@/components/StructuredData';
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id);
const structuredData = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
description: product.description,
image: product.image,
offers: {
'@type': 'Offer',
price: product.price,
priceCurrency: 'USD',
availability: 'https://schema.org/InStock',
url: `https://example.com/products/${params.id}`,
},
aggregateRating: {
'@type': 'AggregateRating',
ratingValue: product.rating,
reviewCount: product.reviewCount,
},
};
return (
<>
<StructuredData data={structuredData} />
<div>
<h1>{product.name}</h1>
{/* ... */}
</div>
</>
);
}
Article Structured Data
Add structured data for blog posts and articles:
// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
const structuredData = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
image: post.coverImage,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: {
'@type': 'Person',
name: post.author,
url: `https://example.com/authors/${post.authorSlug}`,
},
publisher: {
'@type': 'Organization',
name: 'My Blog',
logo: {
'@type': 'ImageObject',
url: 'https://example.com/logo.png',
},
},
description: post.excerpt,
mainEntityOfPage: {
'@type': 'WebPage',
'@id': `https://example.com/blog/${params.slug}`,
},
};
return (
<>
<StructuredData data={structuredData} />
{/* Article content */}
</>
);
}
Breadcrumb Structured Data
Enhance navigation with breadcrumb structured data:
// components/Breadcrumbs.tsx
interface BreadcrumbItem {
label: string;
href: string;
}
export default function Breadcrumbs({ items }: { items: BreadcrumbItem[] }) {
const structuredData = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.label,
item: `https://example.com${item.href}`,
})),
};
return (
<>
<StructuredData data={structuredData} />
<nav aria-label="Breadcrumb">
<ol>
{items.map((item, index) => (
<li key={index}>
<Link href={item.href}>{item.label}</Link>
</li>
))}
</ol>
</nav>
</>
);
}
// Usage
<Breadcrumbs
items={[
{ label: 'Home', href: '/' },
{ label: 'Products', href: '/products' },
{ label: 'Laptop', href: '/products/laptop-123' },
]}
/>
Verification Tags
Add verification tags for search engines and social platforms:
// app/layout.tsx
import { Metadata } from 'next';
export const metadata: Metadata = {
verification: {
google: 'your-google-verification-code',
yandex: 'your-yandex-verification-code',
bing: 'your-bing-verification-code',
},
other: {
'facebook-domain-verification': 'your-facebook-verification-code',
},
};
Template Metadata
Create consistent metadata across pages with templates:
// app/layout.tsx
import { Metadata } from 'next';
export const metadata: Metadata = {
title: {
template: '%s | My Awesome Website',
default: 'My Awesome Website - Best Products Online',
},
description: 'Default description for the website',
};
// app/products/page.tsx
export const metadata: Metadata = {
title: 'Products', // Becomes "Products | My Awesome Website"
};
// app/about/page.tsx
export const metadata: Metadata = {
title: 'About Us', // Becomes "About Us | My Awesome Website"
};
Performance Optimization for SEO
SEO ranking is affected by performance. Optimize Core Web Vitals:
// Image optimization
import Image from 'next/image';
<Image
src="/hero.jpg"
alt="Hero image"
width={1200}
height={600}
priority // Load above-the-fold images first
placeholder="blur"
/>
// Font optimization
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap', // Prevent invisible text during font loading
});
// Script optimization
import Script from 'next/script';
<Script
src="https://analytics.example.com/script.js"
strategy="lazyOnload" // Load after page is interactive
/>
Metadata Inheritance and Merging
Child routes inherit and can override parent metadata:
// app/layout.tsx (Root)
export const metadata = {
title: {
template: '%s | Website',
default: 'Website',
},
openGraph: {
siteName: 'My Website',
},
};
// app/blog/layout.tsx (Blog layout)
export const metadata = {
openGraph: {
type: 'article', // Overrides for all blog pages
},
};
// app/blog/[slug]/page.tsx (Individual post)
export async function generateMetadata({ params }) {
return {
title: 'My Blog Post', // Inherits template from root
// Inherits siteName and type from parents
};
}
Practice Exercise
Task: Build a fully SEO-optimized blog:
- Create dynamic metadata for blog posts with generateMetadata
- Add Open Graph and Twitter Card tags with proper images
- Implement Article structured data with author and publisher info
- Generate a sitemap.xml with all blog posts and categories
- Create a robots.txt that allows all crawlers
- Add breadcrumb navigation with structured data
- Implement hreflang tags for English and Arabic versions
- Add verification tags for Google Search Console
Bonus: Use Google's Rich Results Test to validate your structured data implementation.
Summary
Key takeaways about SEO in Next.js:
- Use the Metadata API for static metadata and generateMetadata for dynamic metadata
- Always include Open Graph and Twitter Card tags for social sharing
- Implement JSON-LD structured data for rich search results
- Generate sitemaps dynamically with the sitemap.ts route
- Control crawler access with robots.txt and robots meta tags
- Add canonical URLs and hreflang tags for multilingual sites
- Optimize images, fonts, and scripts for better Core Web Vitals
- Use verification tags to integrate with search engine tools