Next.js のSEO対策を業務で実装する実践パターン集

React / Next.js

Next.js のSEO対策を業務で実装する実践パターン集

企業のWebサイトやECプラットフォームを構築する際、Next.jsは非常に強力なフレームワークです。しかし、単に高速で美しいUIを実装するだけでは不十分。検索エンジンに正しく認識させ、オーガニック検索流入を増やすためのSEO対策が必須です。本記事では、実務で頻出するNext.jsのSEO実装パターンを、具体的なコード例とともに解説します。

簡易的な解説:Next.jsとSEOの基礎

Next.jsは、Reactベースのフルスタックフレームワークで、Server-Side Rendering(SSR)とStatic Site Generation(SSG)という2つの強力なレンダリング機能を提供します。従来のクライアント側でのみレンダリングするSPAと異なり、これらの機能により検索エンジンボットが完全にレンダリングされたHTMLを取得できるため、SEOに有利です。

具体的には、メタタグ、構造化データ、サイトマップ、robots.txt、動的ページの最適化など、複数のSEO対策が必要です。Next.jsはこれらの実装を比較的シンプルに実現できる仕組みを備えています。

業務でのユースケース

実務ではどのようなシーンでこれらの対策が必要になるでしょうか。代表的なケースを3つ紹介します。

ケース1:企業コーポレートサイトの新規構築
複数のサービスページ、ブログ記事、お知らせなどが存在する大規模サイト。各ページのタイトルやディスクリプション、OGPタグを正確に設定し、検索結果での表示を最適化する必要があります。また、Googleにサイト構造を正確に伝えるためのサイトマップも重要です。

ケース2:動的コンテンツを含むECサイト
商品ページが数千~数万存在する場合、静的にすべてをビルドするのは現実的ではありません。動的ルーティングとISR(Incremental Static Regeneration)を組み合わせ、SEOを損なわずに高速配信を実現する必要があります。

ケース3:多言語対応サイト
国際展開するサービスでは、hreflang属性で言語バージョンを正確に指定し、ユーザーが正しいバージョンにアクセスできるようにしつつ、検索順位の分散を防ぐ必要があります。

実装コード:実務で使う具体例

1. メタタグと基本的なSEO設定

Next.jsで最も基本的なSEO対策は、各ページに適切なメタタグを設定することです。13系以降のApp Routerを使用した実装例を示します。

// app/products/[id]/page.tsx
import { Metadata } from 'next';
import { getProduct } from '@/lib/products';
import { notFound } from 'next/navigation';

interface Props {
  params: Promise<{ id: string }>;
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { id } = await params;
  const product = await getProduct(id);
  
  if (!product) {
    notFound();
  }

  return {
    title: `${product.name} | 商品ページ | YourShop`,
    description: product.shortDescription,
    keywords: product.tags.join(','),
    openGraph: {
      title: product.name,
      description: product.shortDescription,
      url: `https://yourshop.com/products/${id}`,
      type: 'website',
      images: [
        {
          url: product.imageUrl,
          width: 1200,
          height: 630,
          alt: product.name,
        },
      ],
    },
    twitter: {
      card: 'summary_large_image',
      title: product.name,
      description: product.shortDescription,
      images: [product.imageUrl],
    },
    alternates: {
      canonical: `https://yourshop.com/products/${id}`,
    },
  };
}

export async function generateStaticParams() {
  const products = await getProduct('all');
  return products.map((product) => ({
    id: product.id,
  }));
}

export default async function ProductPage({ params }: Props) {
  const { id } = await params;
  const product = await getProduct(id);

  if (!product) {
    notFound();
  }

  return (
    <div>
      <h1>{product.name}</h1>
      <img src={product.imageUrl} alt={product.name} />
      <p>{product.shortDescription}</p>
      <!-- その他のコンテンツ -->
    </div>
  );
}

重要なポイント:

  • generateMetadata関数で動的にメタタグを生成。各ページ固有の情報が反映されます
  • OGP(Open Graph Protocol)タグにより、SNS共有時の表示を制御
  • canonicalタグで正規URLを明示し、重複コンテンツの問題を回避
  • generateStaticParamsで事前にパラメータを指定し、ビルド時の静的生成対象を定義

2. 構造化データ(JSON-LD)の実装

検索エンジンにページの意味をより正確に伝えるため、JSON-LD形式の構造化データを埋め込みます。実務では、商品ページでの実装が最も頻出です。

// app/products/[id]/StructuredData.tsx
import { Product } from '@/types/products';

interface StructuredDataProps {
  product: Product;
  baseUrl: string;
}

export function ProductStructuredData({
  product,
  baseUrl,
}: StructuredDataProps) {
  const structuredData = {
    '@context': 'https://schema.org/',
    '@type': 'Product',
    name: product.name,
    description: product.description,
    image: product.imageUrl,
    brand: {
      '@type': 'Brand',
      name: product.brand,
    },
    offers: {
      '@type': 'Offer',
      url: `${baseUrl}/products/${product.id}`,
      priceCurrency: 'JPY',
      price: product.price.toString(),
      availability: product.inStock
        ? 'https://schema.org/InStock'
        : 'https://schema.org/OutOfStock',
      seller: {
        '@type': 'Organization',
        name: 'YourShop',
      },
    },
    aggregateRating: product.rating
      ? {
          '@type': 'AggregateRating',
          ratingValue: product.rating.average,
          reviewCount: product.rating.count,
        }
      : undefined,
  };

  return (
    <script
      type=\"application/ld+json\"
      dangerouslySetInnerHTML={{
        __html: JSON.stringify(structuredData),
      }}
    />
  );
}

ページコンポーネントでの使用例:

// app/products/[id]/page.tsxの一部
export default async function ProductPage({ params }: Props) {
  const { id } = await params;
  const product = await getProduct(id);
  const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://yourshop.com';

  return (
    <>
      <ProductStructuredData product={product} baseUrl={baseUrl} />
      <div>
        <!-- ページコンテンツ -->
      </div>
    </>
  );
}

3. 動的サイトマップの生成

数千ページ以上のサイトでは、動的にサイトマップを生成することが現実的です。以下はRoute Handlersを使用した実装です。

// app/sitemap.xml/route.ts
import { getAllProducts } from '@/lib/products';
import { getAllBlogPosts } from '@/lib/blog';
import { type MetadataRoute } from 'next';

const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://yourshop.com';

export async function GET() {
  const products = await getAllProducts();
  const blogPosts = await getAllBlogPosts();

  const productUrls = products.map((product) => ({
    url: `${baseUrl}/products/${product.id}`,
    lastModified: product.updatedAt,
    changefreq: 'weekly' as const,
    priority: 0.8,
  }));

  const blogUrls = blogPosts.map((post) => ({
    url: `${baseUrl}/blog/${post.slug}`,
    lastModified: post.publishedAt,
    changefreq: 'weekly' as const,
    priority: 0.6,
  }));

  const staticPages = [
    { url: baseUrl, priority: 1.0, changefreq: 'daily' as const },
    {
      url: `${baseUrl}/about`,
      priority: 0.8,
      changefreq: 'monthly' as const,
    },
    {
      url: `${baseUrl}/contact`,
      priority: 0.7,
      changefreq: 'monthly' as const,
    },
  ];

  const allUrls = [...staticPages, ...productUrls, ...blogUrls];

  const sitemap = `<?xml version=\"1.0\" encoding=\"UTF-8\"?>
<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">
${allUrls
  .map(
    (entry) => `
  <url>
    <loc>${entry.url}</loc>
    <lastmod>${
      'lastModified' in entry
        ? new Date(entry.lastModified).toISOString().split('T')[0]
        : new Date().toISOString().split('T')[0]
    }</lastmod>
    <changefreq>${entry.changefreq}</changefreq>
    <priority>${entry.priority}</priority>
  </url>
`
  )
  .join('')}
</urlset>`;

  return new Response(sitemap, {
    headers: {
      'Content-Type': 'application/xml; charset=utf-8',
      'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
    },
  });
}

4. robots.txtとhosts設定

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

export default function robots(): MetadataRoute.Robots {
  const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://yourshop.com';

  return {
    rules: [
      {
        userAgent: '*',
        allow: '/',
        disallow: ['/admin', '/api', '/_next'],
      },
      {
        userAgent: 'AdsBot-Google',
        allow: '/',
      },
    ],
    sitemap: `${baseUrl}/sitemap.xml`,
    host: baseUrl,
  };
}

5. 多言語対応とhreflang

複数言語のサイトでは、hreflang属性で言語バージョンを正確に指定します。

// lib/i18n-metadata.ts
import { Metadata } from 'next';

const languages = ['ja', 'en', 'zh'] as const;
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://yourshop.com';

export function generateAlternates(
  pathname: string,
): Metadata['alternates'] {
  const alternates: Metadata['alternates'] = {
    canonical: `${baseUrl}${pathname}`,
    languages: {},
  };

  languages.forEach((lang) => {
    const href =
      lang === 'ja'
        ? `${baseUrl}${pathname}`
        : `${baseUrl}/${lang}${pathname}`;

    alternates.languages![lang] = href;
  });

  // x-default: 言語不明ユーザーへのフォールバック
  alternates.languages!['x-default'] = `${baseUrl}${pathname}`;

  return alternates;
}

ページコンポーネントでの使用:

// app/[lang]/products/[id]/page.tsx
export async function generateMetadata({
  params,
}: Props): Promise<Metadata> {
  const { lang, id } = await params;
  const product = await getProduct(id);

  return {
    title: product.names[lang],
    description: product.descriptions[lang],
    alternates: generateAlternates(`/${lang}/products/${id}`),
  };
}

よくある応用パターン

パターン1:ISR(Incremental Static Regeneration)による高速化とSEO両立

数万ページのECサイトでは、すべてのページをビルド時に静的生成するのは現実的ではありません。ISRを使用して、定期的に再生成することで、キャッシュの利点を享受しつつ、新しいコンテンツに対応します。

// app/products/[id]/page.tsx
export const revalidate = 3600; // 1時間ごとに再検証

export async function generateStaticParams() {
  // 最初のビルド時は人気商品のみ生成
  const popularProducts = await getPopularProducts(100);
  return popularProducts.map((p) => ({ id: p.id }));
}

export default async function ProductPage({ params }: Props) {
  const { id } = await params;
  // 初回リクエストで未生成のページは動的に生成され、その後キャッシュされます
  const product = await getProduct(id);
  // ...
}

パターン2:カテゴリーページでのファセット検索SEO対策

フィルタリング機能が多いサイトでは、すべての組み合わせをページ化するのはSEO的に悪手です。正規URLを明確に設定し、検索順位の分散を防ぎます。

// app/products/page.tsx
interface Props {
  searchParams: Promise<{
    category?: string;
    sort?: string;
    page?: string;
  }>;
}

export async function generateMetadata({ searchParams }: Props): Promise<Metadata> {
  const { category, sort } = await searchParams;

  // カテゴリーのみを正規URLとして扱う
  const canonicalPath = category ? `/products?category=${category}` : '/products';

  return {
    title: category ? `${category}商品一覧` : '商品一覧',
    description: category
      ? `${category}の商品を豊富に取り揃えています。`
      : '当店の全商品をご紹介します。',
    alternates: {
      canonical: `${baseUrl}${canonicalPath}`,
    },
    robots: {
      // sort パラメータは検索エンジンからクロールしない
      ...(sort && { noindex: true }),
    },
  };
}

パターン3:ブログ記事のSEO最適化

ブログコンテンツはオーガニック流入の重要な源です。記事ごとに細かなSEO設定を行います。

// app/blog/[slug]/page.tsx
import { getArticleBySlug } from '@/lib/blog';

interface Props {
  params: Promise<{ slug: string }>;
}

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

  if (!article) notFound();

  // 記事内の最初の画像をOGP画像として使用
  const ogImage = article.featuredImage || extractFirstImageFromContent(article.content);

  return {
    title: `${article.title} | ブログ | YourShop`,
    description: article.excerpt || article.content.substring(0, 120),
    authors: [{ name: article.author }],
    publishedTime: article.publishedAt,
    modifiedTime: article.updatedAt,
    tags: article.tags,
    openGraph: {
      title: article.title,
      description: article.excerpt,
      type: 'article',
      publishedTime: article.publishedAt,
      authors: [article.author],
      images: [
        {
          url: ogImage,
          width: 1200,
          height: 630,
        },
      ],
    },
    alternates: {
      canonical: `${baseUrl}/blog/${slug}`,
    },
  };
}

export async function generateStaticParams() {
  const articles = await getAllArticles();
  return articles.map((article) => ({
    slug: article.slug,
  }));
}

export default async function BlogArticle({ params }: Props) {
  const { slug } = await params;
  const article = await getArticleBySlug(slug);

  return (
    <article>
      <h1>{article.title}</h1>
      <time dateTime={article.publishedAt}>
        {new Date(article.publishedAt).toLocaleDateString('ja-JP')}
      </time>
      <div dangerouslySetInnerHTML={{ __html: article.content }} />
    </article>
  );
}

実装時の注意点

1. キャッシュの適切な設定

動的メタデータ生成では、キャッシュが自動的に無効化されない場合があります。必要に応じて明示的にキャッシュ制御を行いましょう。

// lib/api.ts
export async function getProduct(id: string) {
  const res = await fetch(`${API_URL}/products/${id}`, {
    // SSG時はデフォルトでキャッシュ。ISR有効時は自動無効化
    next: { revalidate: 3600 },
  });

  if (!res.ok) throw new Error('Product not found');
  return res.json();
}

2. 大規模サイトでのビルド時間

generateStaticParamsで数万ページ生成する場合、ビルド時間が大幅に増加します。初期値は最小限にして、ISRで段階的に生成することをお勧めします。

3. SEOメタデータのバリデーション

実務では、メタデータの不正な値がページに埋め込まれるのを防ぐため、バリデーション層を設けることが重要です。

// lib/seo-validation.ts
export function validateMetadata(metadata: Metadata): Metadata {
  // titleは60文字以内が理想的
  if (metadata.title) {
    const title =
      typeof metadata.title === 'string' ? metadata.title : metadata.title.absolute;
    if (title && title.length > 60) {
      console.warn(
        `Title exceeds recommended length: ${title.length} characters`,
      );
    }
  }

  // descriptionは120文字以内が推奨
  if (metadata.description && metadata.description.length > 120) {
    console.warn(
      `Description exceeds recommended length: ${metadata.description.length} characters`,
    );
  }

  return metadata;
}

4. robots.txtとメタロボッツタグの整合性

noindexを設定したページは、robots.txtでディスアロー設定する必要があります。逆は不要です。

5. パフォーマンスへの影響

複雑なメタデータ生成ロジックはページの読み込み速度に影響します。データベースクエリやAPI呼び出しは、キャッシュやデータローダーパターンで最適化しましょう。

よくある質問への回答

Q: SSR と SSG どちらを選ぶべき?
A: 更新頻度が低いコンテンツ(製品ページ、ブログなど)はSSG + ISR。ユーザーごとにカスタマイズされるコンテンツ(アカウントページなど)はSSRが向いています。

Q: 非常に多くのページがある場合どうすればいい?
A: generateStaticParamsで初期値を限定し、ISRで段階的に生成。または、事前生成を夜間に実行するバッチジョブで対応します。

Q: Google Search ConsoleでエラーがでていますがNext.jsが原因?
A: メタデータの欠落や不正なスキーマ、重複コンテンツが主な原因です。本記事で紹介したバリデーションとcanonicalタグの設定で大半は解決します。

まとめ

Next.jsは、modern Webフレームワークの中でも特にSEOに強い設計になっています。しかし、その利点を活かすには、適切な実装が不可欠です。本記事で紹介した5つの実装パターン—メタタグ、構造化データ、サイトマップ、robots.txt、多言語対応—をマスターすることで、ほぼすべての業務ケースに対応できます。

重要なのは、単にメタデータを埋め込むだけではなく、コンテンツの更新頻度やページ規模に応じて、キャッシング戦略を適切に設計することです。ISRとアンオンデマンド生成を組み合わせることで、スケーラブルで検索エンジンフレンドリーなサイトを構築できます。

実装後は、Google Search ConsoleやPageSpeed Insightsで定期的に監視し、検索パフォーマンスとサイト速度の両面から最適化を継続することをお忘れなく。

タイトルとURLをコピーしました