Next.js

SEO and Metadata in Next.js

38 min Lesson 23 of 40

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>;
}
Important: The Metadata API only works in Server Components. For Client Components, you need to use the generateMetadata function or move metadata to a parent Server Component.

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],
    },
  };
}
Tip: Always include Open Graph images sized at 1200x630 pixels for optimal display across all social platforms.

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];
}
Warning: Large sitemaps with thousands of URLs can cause performance issues. Consider splitting into multiple sitemap files or using sitemap indexes.

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
/>
Tip: Use the Lighthouse tool in Chrome DevTools to measure and improve your SEO score, performance, and Core Web Vitals.

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:

  1. Create dynamic metadata for blog posts with generateMetadata
  2. Add Open Graph and Twitter Card tags with proper images
  3. Implement Article structured data with author and publisher info
  4. Generate a sitemap.xml with all blog posts and categories
  5. Create a robots.txt that allows all crawlers
  6. Add breadcrumb navigation with structured data
  7. Implement hreflang tags for English and Arabic versions
  8. 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