メインコンテンツへスキップ
#Web制作 約11分で読めます

Next.js 15 Dynamic Routes で多言語SEO対応を実装する実践ガイド【言語別sitemap自動生成】

Next.js 15のDynamic Routesとi18n機能を組み合わせて多言語サイトを構築し、言語別サイトマップの自動生成でSEO効果を最大化する実装方法を解説します。

Next.js 15 で多言語サイトのSEO課題を解決する

グローバル展開を目指すWebサイトでは、多言語対応とSEO最適化の両立が大きな課題です。特に以下の問題に直面することが多いでしょう。

  • 各言語版のURLをどう設計すればSEOに有利か
  • サイトマップを言語ごとに自動生成する方法
  • hreflangタグの実装ミスによるインデックス問題
  • 動的ルートと多言語の組み合わせによる複雑化

Next.js 15のDynamic Routesとi18n機能を組み合わせることで、これらの課題を体系的に解決できます。本記事では、実際に動作するコード例とともに、言語別サイトマップの自動生成まで含めた実装方法を解説します。

Next.js 15 における多言語ルート設計の基本戦略

URL構造の選択肢とSEO影響

多言語サイトのURL設計には主に3つのパターンがあります。

1. サブディレクトリ方式(推奨)

example.com/en/blog/post-1
example.com/ja/blog/post-1
example.com/fr/blog/post-1

2. サブドメイン方式

en.example.com/blog/post-1
ja.example.com/blog/post-1
fr.example.com/blog/post-1

3. パラメータ方式(非推奨)

example.com/blog/post-1?lang=en
example.com/blog/post-1?lang=ja

Googleの国際化SEOガイドラインでは、サブディレクトリ方式が最もシンプルで管理しやすいと推奨されています。Next.js 15ではDynamic Routesを活用してこれを実装します。

Next.js 15 の i18n Routing 実装

Next.js 15のApp Routerでは、next.config.jsi18n設定は廃止されました。代わりにMiddlewareとDynamic Routesで実装します。

// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

const locales = ['en', 'ja', 'fr', 'de', 'zh']
const defaultLocale = 'en'

export function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname
  
  // パスに言語コードが含まれているかチェック
  const pathnameHasLocale = locales.some(
    (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  )

  if (pathnameHasLocale) return

  // ブラウザの言語設定から優先言語を取得
  const locale = getLocale(request) || defaultLocale
  request.nextUrl.pathname = `/${locale}${pathname}`
  
  return NextResponse.redirect(request.nextUrl)
}

function getLocale(request: NextRequest): string | undefined {
  const acceptLanguage = request.headers.get('accept-language')
  if (!acceptLanguage) return undefined

  // Accept-Languageヘッダーをパース
  const languages = acceptLanguage
    .split(',')
    .map(lang => lang.split(';')[0].trim().toLowerCase())

  // サポートする言語と照合
  for (const lang of languages) {
    const primaryLang = lang.split('-')[0]
    if (locales.includes(primaryLang)) {
      return primaryLang
    }
  }

  return undefined
}

export const config = {
  matcher: [
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ],
}

Dynamic Segments による言語別ルート構造

言語コードをDynamic Segmentとして扱うフォルダ構造を構築します。

app/
├── [lang]/
│   ├── layout.tsx          # 言語別レイアウト
│   ├── page.tsx            # トップページ
│   ├── blog/
│   │   ├── page.tsx        # 記事一覧
│   │   └── [slug]/
│   │       └── page.tsx    # 個別記事
│   ├── products/
│   │   ├── page.tsx
│   │   └── [id]/
│   │       └── page.tsx
│   └── about/
│       └── page.tsx
// app/[lang]/layout.tsx
import { notFound } from 'next/navigation'

const locales = ['en', 'ja', 'fr', 'de', 'zh']

export async function generateStaticParams() {
  return locales.map((lang) => ({ lang }))
}

export default function LocaleLayout({
  children,
  params: { lang }
}: {
  children: React.ReactNode
  params: { lang: string }
}) {
  if (!locales.includes(lang)) {
    notFound()
  }

  return (
    <html lang={lang}>
      <body>{children}</body>
    </html>
  )
}

このアーキテクチャにより、すべてのページが自動的に多言語対応されます。

hreflangタグの自動生成とSEO最適化

hreflang実装の重要性

hreflangタグは、同一コンテンツの言語バリエーションをGoogleに伝えるために不可欠です。実装ミスは以下の問題を引き起こします。

  • 言語違いのページが重複コンテンツと見なされる
  • 検索結果で誤った言語版が表示される
  • クロールバジェットの無駄遣い

Next.js Metadata API による自動生成

Next.js 15のMetadata APIを使い、各ページで自動的にhreflangタグを生成します。

// app/[lang]/blog/[slug]/page.tsx
import { Metadata } from 'next'
import { notFound } from 'next/navigation'

const locales = ['en', 'ja', 'fr', 'de', 'zh']
const siteUrl = 'https://example.com'

type Props = {
  params: { lang: string; slug: string }
}

// 記事データを取得(例)
async function getPost(lang: string, slug: string) {
  // CMSやデータベースから取得
  const post = await fetch(`https://api.example.com/posts/${lang}/${slug}`)
  if (!post.ok) return null
  return post.json()
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { lang, slug } = params
  const post = await getPost(lang, slug)

  if (!post) {
    return {}
  }

  // 各言語版のURLを構築
  const alternates: Record<string, string> = {}
  locales.forEach((locale) => {
    alternates[locale] = `${siteUrl}/${locale}/blog/${slug}`
  })

  return {
    title: post.title,
    description: post.excerpt,
    alternates: {
      canonical: `${siteUrl}/${lang}/blog/${slug}`,
      languages: alternates,
    },
    openGraph: {
      title: post.title,
      description: post.excerpt,
      url: `${siteUrl}/${lang}/blog/${slug}`,
      locale: lang,
      type: 'article',
    },
  }
}

export default async function BlogPost({ params }: Props) {
  const { lang, slug } = params
  const post = await getPost(lang, slug)

  if (!post) {
    notFound()
  }

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  )
}

// 静的生成のための全パターンを生成
export async function generateStaticParams() {
  const posts = await fetch('https://api.example.com/posts/all').then(r => r.json())

  return posts.flatMap((post: any) =>
    locales.map((lang) => ({
      lang,
      slug: post.slug,
    }))
  )
}

これにより生成されるHTMLの<head>には以下のようなタグが自動挿入されます。

<link rel="canonical" href="https://example.com/ja/blog/my-post" />
<link rel="alternate" hreflang="en" href="https://example.com/en/blog/my-post" />
<link rel="alternate" hreflang="ja" href="https://example.com/ja/blog/my-post" />
<link rel="alternate" hreflang="fr" href="https://example.com/fr/blog/my-post" />
<link rel="alternate" hreflang="de" href="https://example.com/de/blog/my-post" />
<link rel="alternate" hreflang="zh" href="https://example.com/zh/blog/my-post" />
<link rel="alternate" hreflang="x-default" href="https://example.com/en/blog/my-post" />

x-default の実装

x-defaultは、どの言語にも該当しないユーザー向けのデフォルトページを示します。通常は英語版を指定します。

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  // ...省略...

  return {
    alternates: {
      canonical: `${siteUrl}/${lang}/blog/${slug}`,
      languages: {
        ...alternates,
        'x-default': `${siteUrl}/en/blog/${slug}`, // デフォルトは英語
      },
    },
  }
}

言語別サイトマップの自動生成実装

サイトマップの言語別分割戦略

大規模な多言語サイトでは、サイトマップを言語ごとに分割することが推奨されます。

sitemap-index.xml          # メインのサイトマップインデックス
├── sitemap-en.xml         # 英語版ページ
├── sitemap-ja.xml         # 日本語版ページ
├── sitemap-fr.xml         # フランス語版ページ
├── sitemap-de.xml         # ドイツ語版ページ
└── sitemap-zh.xml         # 中国語版ページ

Next.js 15 のサイトマップ生成機能

Next.js 15ではappディレクトリ内にsitemap.tsを配置することで、動的にサイトマップを生成できます。

// app/sitemap.ts
import { MetadataRoute } from 'next'

const siteUrl = 'https://example.com'
const locales = ['en', 'ja', 'fr', 'de', 'zh']

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  // サイトマップインデックスを生成
  return locales.map((locale) => ({
    url: `${siteUrl}/sitemap-${locale}.xml`,
    lastModified: new Date(),
  }))
}
// app/sitemap-[lang].xml/route.ts
import { NextRequest } from 'next/server'

const siteUrl = 'https://example.com'
const locales = ['en', 'ja', 'fr', 'de', 'zh']

type Post = {
  slug: string
  updatedAt: string
}

async function getAllPosts(lang: string): Promise<Post[]> {
  const res = await fetch(`https://api.example.com/posts?lang=${lang}`, {
    next: { revalidate: 3600 } // 1時間キャッシュ
  })
  return res.json()
}

export async function GET(
  request: NextRequest,
  { params }: { params: { lang: string } }
) {
  const lang = params.lang

  if (!locales.includes(lang)) {
    return new Response('Not Found', { status: 404 })
  }

  const posts = await getAllPosts(lang)

  // サイトマップXMLを生成
  const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
        xmlns:xhtml="http://www.w3.org/1999/xhtml">
  <!-- トップページ -->
  <url>
    <loc>${siteUrl}/${lang}</loc>
    <lastmod>${new Date().toISOString()}</lastmod>
    <changefreq>daily</changefreq>
    <priority>1.0</priority>
    ${generateHreflangLinks(lang, '')}
  </url>

  <!-- 記事一覧ページ -->
  <url>
    <loc>${siteUrl}/${lang}/blog</loc>
    <lastmod>${new Date().toISOString()}</lastmod>
    <changefreq>daily</changefreq>
    <priority>0.9</priority>
    ${generateHreflangLinks(lang, '/blog')}
  </url>

  <!-- 各記事ページ -->
  ${posts.map((post) => `
  <url>
    <loc>${siteUrl}/${lang}/blog/${post.slug}</loc>
    <lastmod>${post.updatedAt}</lastmod>
    <changefreq>weekly</changefreq>
    <priority>0.8</priority>
    ${generateHreflangLinks(lang, `/blog/${post.slug}`)}
  </url>
  `).join('')}
</urlset>`

  return new Response(sitemap, {
    headers: {
      'Content-Type': 'application/xml',
      'Cache-Control': 'public, max-age=3600, s-maxage=3600',
    },
  })
}

function generateHreflangLinks(currentLang: string, path: string): string {
  return locales.map((locale) => `
    <xhtml:link 
      rel="alternate" 
      hreflang="${locale}" 
      href="${siteUrl}/${locale}${path}" 
    />`).join('') + `
    <xhtml:link 
      rel="alternate" 
      hreflang="x-default" 
      href="${siteUrl}/en${path}" 
    />`
}

// 全言語のルートを生成
export async function generateStaticParams() {
  return locales.map((lang) => ({ lang }))
}

サイトマップインデックスの実装

メインのサイトマップインデックスで各言語版を参照します。

// app/sitemap.xml/route.ts
import { NextResponse } from 'next/server'

const siteUrl = 'https://example.com'
const locales = ['en', 'ja', 'fr', 'de', 'zh']

export async function GET() {
  const sitemapIndex = `<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  ${locales.map((locale) => `
  <sitemap>
    <loc>${siteUrl}/sitemap-${locale}.xml</loc>
    <lastmod>${new Date().toISOString()}</lastmod>
  </sitemap>
  `).join('')}
</sitemapindex>`

  return new NextResponse(sitemapIndex, {
    headers: {
      'Content-Type': 'application/xml',
      'Cache-Control': 'public, max-age=3600, s-maxage=3600',
    },
  })
}

処理フローのダイアグラム

flowchart TD
    A["ユーザーがサイトにアクセス"] --> B["Middleware起動"]
    B --> C{"パスに言語コード<br/>含まれている?"}
    C -->|はい| D["該当ページを表示"]
    C -->|いいえ| E["Accept-Languageヘッダー<br/>から言語を検出"]
    E --> F["/{lang}/ にリダイレクト"]
    F --> D
    D --> G["generateMetadata()で<br/>hreflangタグ生成"]
    G --> H["ページレンダリング"]
    
    I["Googlebot"] --> J["sitemap.xml をクロール"]
    J --> K["sitemap-{lang}.xml を発見"]
    K --> L["各言語版ページをクロール"]
    L --> M["hreflangタグを読み取り"]
    M --> N["言語別インデックス登録"]

このフローにより、ユーザーは自動的に適切な言語版にリダイレクトされ、検索エンジンは各言語版を正しくインデックスできます。

ISR(Incremental Static Regeneration)による大規模サイト対応

ISRを使ったサイトマップの効率的な更新

数千ページを超える多言語サイトでは、ビルド時にすべてのページを生成すると時間がかかりすぎます。ISRを活用して必要に応じて再生成します。

// app/[lang]/blog/[slug]/page.tsx
export const revalidate = 3600 // 1時間ごとに再検証

export async function generateStaticParams() {
  // ビルド時には人気記事のみ生成
  const popularPosts = await fetch(
    'https://api.example.com/posts/popular?limit=100'
  ).then(r => r.json())

  return popularPosts.flatMap((post: any) =>
    locales.map((lang) => ({
      lang,
      slug: post.slug,
    }))
  )
}

export default async function BlogPost({ params }: Props) {
  const { lang, slug } = params
  
  // アクセス時に動的にデータ取得
  const post = await fetch(
    `https://api.example.com/posts/${lang}/${slug}`,
    { next: { revalidate: 3600 } }
  ).then(r => r.json())

  if (!post) {
    notFound()
  }

  return <article>...</article>
}

On-Demand Revalidation でコンテンツ更新を即反映

CMSでコンテンツが更新されたとき、Webhookを使って該当ページだけ再生成できます。

// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'

export async function POST(request: NextRequest) {
  const secret = request.nextUrl.searchParams.get('secret')
  
  // シークレットトークンで認証
  if (secret !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json({ message: 'Invalid token' }, { status: 401 })
  }

  const { lang, slug, type } = await request.json()

  try {
    if (type === 'post') {
      // 該当記事ページを再生成
      await revalidatePath(`/${lang}/blog/${slug}`)
      
      // 記事一覧ページも再生成
      await revalidatePath(`/${lang}/blog`)
      
      // サイトマップも再生成
      await revalidatePath(`/sitemap-${lang}.xml`)
    }

    return NextResponse.json({ revalidated: true, now: Date.now() })
  } catch (err) {
    return NextResponse.json(
      { message: 'Error revalidating' },
      { status: 500 }
    )
  }
}

CMSのWebhook設定例(Contentful):

{
  "url": "https://example.com/api/revalidate?secret=YOUR_SECRET",
  "method": "POST",
  "headers": {
    "Content-Type": "application/json"
  },
  "body": {
    "lang": "{{ locale }}",
    "slug": "{{ fields.slug }}",
    "type": "post"
  }
}

ISRとサイトマップの連携ダイアグラム

sequenceDiagram
    participant CMS
    participant Webhook
    participant API as /api/revalidate
    participant Cache as Next.js Cache
    participant Sitemap as Sitemap Generator

    CMS->>Webhook: コンテンツ更新
    Webhook->>API: POST /api/revalidate
    API->>Cache: revalidatePath(/{lang}/blog/{slug})
    API->>Cache: revalidatePath(/{lang}/blog)
    API->>Sitemap: revalidatePath(/sitemap-{lang}.xml)
    Sitemap->>Sitemap: サイトマップ再生成
    API-->>Webhook: { revalidated: true }
    
    Note over Cache,Sitemap: 次回アクセス時に<br/>再生成されたページを配信

実装チェックリストと運用のベストプラクティス

実装完了時の確認項目

以下のチェックリストで実装漏れを防ぎます。

基本設定

  • Middlewareで言語検出・リダイレクトが動作する
  • [lang]フォルダ構造が正しく設定されている
  • サポートする全言語がlocales配列に含まれている
  • デフォルト言語(x-default)が設定されている

SEO対策

  • 各ページでgenerateMetadata()を実装している
  • hreflangタグが自動生成されている
  • canonicalタグが正しく設定されている
  • OGPタグにlocaleが含まれている

サイトマップ

  • メインサイトマップインデックスが生成される
  • 各言語版サイトマップが独立して生成される
  • サイトマップ内にhreflangリンクが含まれている
  • robots.txtにサイトマップURLが記載されている

パフォーマンス

  • ISRのrevalidate時間が適切に設定されている
  • 人気ページがビルド時に事前生成されている
  • On-Demand Revalidation APIが実装されている

Google Search Console での検証手順

  1. 各言語版をプロパティとして追加

    • example.com/en/ をURLプレフィックスとして登録
    • example.com/ja/ を同様に登録(他言語も同様)
  2. サイトマップを送信

    • メインプロパティにsitemap.xmlを送信
    • 各言語プロパティにsitemap-{lang}.xmlを個別送信
  3. hreflangエラーをチェック

    • 「インデックス カバレッジ」→「代替ページの言語」
    • エラーがあれば修正(リンク切れ、相互参照漏れなど)
  4. インデックス状況を監視

    • 各言語版が適切にインデックスされているか確認
    • 検索パフォーマンスで言語別の流入を分析

ありがちなトラブルと解決策

問題1: 言語自動検出が働かない

  • 原因: Middlewareのmatcher設定が不適切
  • 解決策: 静的ファイル(_next/static等)を除外する

問題2: hreflangタグが重複している

  • 原因: レイアウトとページの両方でgenerateMetadata()を実装
  • 解決策: ページレベルのみで実装する

問題3: サイトマップが404になる

  • 原因: sitemap-[lang].xml/route.tsのパスが間違っている
  • 解決策: app/sitemap-[lang].xml/route.tsとして配置

問題4: ISRが効かない

  • 原因: fetchnext: { revalidate }オプションがない
  • 解決策: データ取得時に明示的にキャッシュ設定を指定

まとめ

Next.js 15のDynamic Routesとi18n機能を組み合わせることで、以下を実現できます。

  • 体系的な多言語URL設計: サブディレクトリ方式で管理しやすい構造
  • 自動hreflang生成: Metadata APIで実装ミスを防止
  • 言語別サイトマップ: 大規模サイトでもクローラビリティを確保
  • 効率的な更新: ISRとOn-Demand Revalidationでビルド時間を削減

これらの実装により、グローバルなSEO効果を最大化しながら、保守性の高い多言語サイトを構築できます。既存のNext.jsプロジェクトへの導入も、段階的に進められます。

特に重要なのは、hreflangタグとサイトマップの正確性です。Google Search Consoleで定期的にエラーをチェックし、各言語版が正しくインデックスされているか確認しましょう。

参考リンク

#Next.js #i18n #SEO #Dynamic Routes #サイトマップ
シェア