DEV Community

Cover image for Next.js SEO Optimization: Complete Guide to Ranking Higher
Sepehr Mohseni
Sepehr Mohseni

Posted on

Next.js SEO Optimization: Complete Guide to Ranking Higher

SEO is crucial for any website's success. Next.js provides powerful tools for building SEO-friendly applications. This guide covers everything from basic metadata to advanced optimization techniques.

Metadata API in Next.js 15

Static Metadata

// app/page.tsx
import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'Home | My Website',
  description: 'Welcome to my website. We offer the best products and services.',
  keywords: ['products', 'services', 'quality'],
  authors: [{ name: 'John Doe', url: 'https://johndoe.com' }],
  creator: 'John Doe',
  publisher: 'My Company',
  robots: {
    index: true,
    follow: true,
    googleBot: {
      index: true,
      follow: true,
      'max-video-preview': -1,
      'max-image-preview': 'large',
      'max-snippet': -1,
    },
  },
  openGraph: {
    type: 'website',
    locale: 'en_US',
    url: 'https://mywebsite.com',
    siteName: 'My Website',
    title: 'Home | My Website',
    description: 'Welcome to my website',
    images: [
      {
        url: 'https://mywebsite.com/og-image.jpg',
        width: 1200,
        height: 630,
        alt: 'My Website',
      },
    ],
  },
  twitter: {
    card: 'summary_large_image',
    title: 'Home | My Website',
    description: 'Welcome to my website',
    creator: '@johndoe',
    images: ['https://mywebsite.com/twitter-image.jpg'],
  },
  alternates: {
    canonical: 'https://mywebsite.com',
    languages: {
      'en-US': 'https://mywebsite.com/en',
      'fa-IR': 'https://mywebsite.com/fa',
    },
  },
};

export default function HomePage() {
  return <main>...</main>;
}
Enter fullscreen mode Exit fullscreen mode


Always include Open Graph and Twitter Card metadata for better social media sharing previews.

Dynamic Metadata

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
import { getPostBySlug } from '@/lib/posts';

interface Props {
  params: { slug: string };
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const post = await getPostBySlug(params.slug);

  if (!post) {
    return {
      title: 'Post Not Found',
    };
  }

  return {
    title: `${post.title} | My Blog`,
    description: post.excerpt,
    authors: [{ name: post.author.name }],
    openGraph: {
      type: 'article',
      title: post.title,
      description: post.excerpt,
      url: `https://mywebsite.com/blog/${params.slug}`,
      publishedTime: post.publishedAt,
      modifiedTime: post.updatedAt,
      authors: [post.author.name],
      images: [
        {
          url: post.coverImage,
          width: 1200,
          height: 630,
          alt: post.title,
        },
      ],
      tags: post.tags,
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

Metadata Template

// app/layout.tsx
import type { Metadata } from 'next';

export const metadata: Metadata = {
  metadataBase: new URL('https://mywebsite.com'),
  title: {
    default: 'My Website',
    template: '%s | My Website', // Dynamic pages will use this template
  },
  description: 'Default description for my website',
  openGraph: {
    type: 'website',
    siteName: 'My Website',
  },
};
Enter fullscreen mode Exit fullscreen mode

Structured Data (JSON-LD)

Organization Schema

// components/StructuredData.tsx
export function OrganizationSchema() {
  const schema = {
    '@context': 'https://schema.org',
    '@type': 'Organization',
    name: 'My Company',
    url: 'https://mywebsite.com',
    logo: 'https://mywebsite.com/logo.png',
    sameAs: [
      'https://twitter.com/mycompany',
      'https://linkedin.com/company/mycompany',
      'https://github.com/mycompany',
    ],
    contactPoint: {
      '@type': 'ContactPoint',
      telephone: '+1-555-555-5555',
      contactType: 'customer service',
      availableLanguage: ['English', 'Persian'],
    },
  };

  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Article Schema

// components/ArticleSchema.tsx
interface ArticleSchemaProps {
  title: string;
  description: string;
  image: string;
  datePublished: string;
  dateModified: string;
  author: {
    name: string;
    url: string;
  };
  url: string;
}

export function ArticleSchema({
  title,
  description,
  image,
  datePublished,
  dateModified,
  author,
  url,
}: ArticleSchemaProps) {
  const schema = {
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: title,
    description: description,
    image: image,
    datePublished: datePublished,
    dateModified: dateModified,
    author: {
      '@type': 'Person',
      name: author.name,
      url: author.url,
    },
    publisher: {
      '@type': 'Organization',
      name: 'My Website',
      logo: {
        '@type': 'ImageObject',
        url: 'https://mywebsite.com/logo.png',
      },
    },
    mainEntityOfPage: {
      '@type': 'WebPage',
      '@id': url,
    },
  };

  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode


Use Google's Rich Results Test to validate your structured data: https://search.google.com/test/rich-results

Product Schema for E-commerce

export function ProductSchema({ product }) {
  const schema = {
    '@context': 'https://schema.org',
    '@type': 'Product',
    name: product.name,
    description: product.description,
    image: product.images,
    sku: product.sku,
    brand: {
      '@type': 'Brand',
      name: product.brand,
    },
    offers: {
      '@type': 'Offer',
      url: `https://mywebsite.com/products/${product.slug}`,
      priceCurrency: 'USD',
      price: product.price,
      availability: product.inStock
        ? 'https://schema.org/InStock'
        : 'https://schema.org/OutOfStock',
      seller: {
        '@type': 'Organization',
        name: 'My Store',
      },
    },
    aggregateRating: product.reviews.length > 0 ? {
      '@type': 'AggregateRating',
      ratingValue: product.averageRating,
      reviewCount: product.reviews.length,
    } : undefined,
  };

  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Sitemap Generation

Dynamic Sitemap

// app/sitemap.ts
import { MetadataRoute } from 'next';
import { getAllPosts } from '@/lib/posts';
import { getAllProducts } from '@/lib/products';

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const baseUrl = 'https://mywebsite.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: 'monthly' as const,
      priority: 0.8,
    },
  ];

  // Blog posts
  const posts = await getAllPosts();
  const blogPages = posts.map((post) => ({
    url: `${baseUrl}/blog/${post.slug}`,
    lastModified: new Date(post.updatedAt),
    changeFrequency: 'weekly' as const,
    priority: 0.7,
  }));

  // Products
  const products = await getAllProducts();
  const productPages = products.map((product) => ({
    url: `${baseUrl}/products/${product.slug}`,
    lastModified: new Date(product.updatedAt),
    changeFrequency: 'daily' as const,
    priority: 0.9,
  }));

  return [...staticPages, ...blogPages, ...productPages];
}
Enter fullscreen mode Exit fullscreen mode

Robots.txt

// app/robots.ts
import { MetadataRoute } from 'next';

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      {
        userAgent: '*',
        allow: '/',
        disallow: ['/api/', '/admin/', '/private/'],
      },
      {
        userAgent: 'Googlebot',
        allow: '/',
        disallow: '/api/',
      },
    ],
    sitemap: 'https://mywebsite.com/sitemap.xml',
  };
}
Enter fullscreen mode Exit fullscreen mode

Core Web Vitals Optimization

Image Optimization

import Image from 'next/image';

// Optimized image component
export function OptimizedImage({ src, alt, priority = false }) {
  return (
    <Image
      src={src}
      alt={alt}
      width={800}
      height={600}
      priority={priority} // Set true for above-the-fold images
      placeholder="blur"
      blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRg..."
      sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
      className="object-cover"
    />
  );
}

// Hero image with priority loading
export function HeroSection() {
  return (
    <section>
      <Image
        src="/hero.jpg"
        alt="Hero image"
        fill
        priority // Critical for LCP
        sizes="100vw"
        className="object-cover"
      />
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

Font Optimization

// app/layout.tsx
import { Inter, Vazirmatn } from 'next/font/google';

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter',
});

const vazirmatn = Vazirmatn({
  subsets: ['arabic'],
  display: 'swap',
  variable: '--font-vazirmatn',
});

export default function RootLayout({ children }) {
  return (
    <html className={`${inter.variable} ${vazirmatn.variable}`}>
      <body>{children}</body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

Script Optimization

import Script from 'next/script';

export function Analytics() {
  return (
    <>
      {/* Load after page is interactive */}
      <Script
        src="https://www.googletagmanager.com/gtag/js?id=GA_ID"
        strategy="afterInteractive"
      />
      <Script id="google-analytics" strategy="afterInteractive">
        {`
          window.dataLayer = window.dataLayer || [];
          function gtag(){dataLayer.push(arguments);}
          gtag('js', new Date());
          gtag('config', 'GA_ID');
        `}
      </Script>

      {/* Load when browser is idle */}
      <Script
        src="https://third-party-script.js"
        strategy="lazyOnload"
      />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Internationalization SEO

Hreflang Tags

// app/[locale]/layout.tsx
import type { Metadata } from 'next';

export async function generateMetadata({ params }): Promise<Metadata> {
  const { locale } = params;

  return {
    alternates: {
      canonical: `https://mywebsite.com/${locale}`,
      languages: {
        'en': 'https://mywebsite.com/en',
        'fa': 'https://mywebsite.com/fa',
        'x-default': 'https://mywebsite.com/en',
      },
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

Localized Sitemap

// app/sitemap.ts
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const locales = ['en', 'fa'];
  const baseUrl = 'https://mywebsite.com';

  const pages = ['', '/about', '/contact', '/blog'];

  return pages.flatMap((page) =>
    locales.map((locale) => ({
      url: `${baseUrl}/${locale}${page}`,
      lastModified: new Date(),
      alternates: {
        languages: Object.fromEntries(
          locales.map((l) => [l, `${baseUrl}/${l}${page}`])
        ),
      },
    }))
  );
}
Enter fullscreen mode Exit fullscreen mode

Performance Monitoring

Web Vitals Tracking

// app/components/WebVitals.tsx
'use client';

import { useReportWebVitals } from 'next/web-vitals';

export function WebVitals() {
  useReportWebVitals((metric) => {
    // Send to analytics
    const body = JSON.stringify({
      name: metric.name,
      value: metric.value,
      rating: metric.rating,
      delta: metric.delta,
      id: metric.id,
    });

    // Use sendBeacon for reliability
    if (navigator.sendBeacon) {
      navigator.sendBeacon('/api/analytics/vitals', body);
    } else {
      fetch('/api/analytics/vitals', {
        body,
        method: 'POST',
        keepalive: true,
      });
    }
  });

  return null;
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

SEO in Next.js is about combining technical optimization with great content. By implementing proper metadata, structured data, sitemaps, and Core Web Vitals optimization, you can significantly improve your search engine rankings.

Key takeaways:

  • Use the Metadata API for comprehensive meta tags
  • Implement structured data for rich search results
  • Generate dynamic sitemaps for all content
  • Optimize images, fonts, and scripts for Core Web Vitals
  • Handle internationalization with proper hreflang tags

Top comments (0)