著者:
    作成:2025-09-28最終更新:2025-09-28

    Next.jsにおけるSEOとi18n:翻訳だけでは不十分

    開発者が国際化(i18n)を考えるとき、最初の反応はしばしば「コンテンツを翻訳すること」です。しかし、多くの人は国際化の主な目的が、あなたのウェブサイトを世界により見えるようにすることだということを忘れがちです。 多言語対応のNext.jsアプリが、検索エンジンに対して異なる言語バージョンのクロールや理解の方法を伝えなければ、あなたの多くの努力は見過ごされてしまうかもしれません。

    このブログでは、なぜi18nがSEOの強力な武器となるのか、そしてnext-intlnext-i18nextIntlayerを使ってNext.jsで正しく実装する方法を探っていきます。


    なぜSEOとi18nが重要なのか

    言語を追加することは単なるUXの向上だけではありません。それはオーガニックな可視性を高める強力な手段でもあります。理由は以下の通りです:

    1. より良い発見性: 検索エンジンはローカライズされたバージョンをインデックスし、ユーザーが母国語で検索した際にそれらをランク付けします。
    2. 重複コンテンツの回避: 適切なカノニカルタグと代替タグが、クローラーにどのページがどのロケールに属するかを伝えます。
    3. より良いUX: 訪問者はすぐにサイトの適切なバージョンにアクセスできます。
    4. 競争優位性: 多言語SEOを適切に実装しているサイトは少ないため、あなたのサイトは際立つことができます。

    Next.jsにおける多言語SEOのベストプラクティス

    すべての多言語アプリが実装すべきチェックリストはこちらです:

    • <head>内にhreflangメタタグを設定する
      Googleが各言語のバージョンを理解するのに役立ちます。

    • 翻訳されたすべてのページをsitemap.xmlにリストアップする
      xhtmlスキーマを使用して、クローラーが代替ページを簡単に見つけられるようにします。

    • robots.txtでプライベート/ローカライズされたルートを除外する
      例:/dashboard/fr/dashboard/es/dashboardなどがインデックスされないようにします。

    • ローカライズされたリンクを使用する
      例:デフォルトの/aboutではなく、<a href="/fr/about">À propos</a>のようにリンクします。

    これらは簡単なステップですが、スキップすると可視性を失う可能性があります。


    実装例

    開発者はしばしばロケール間でページを適切に参照することを忘れがちなので、さまざまなライブラリでどのように機能するかを見てみましょう。

    next-intl

    src/app/[locale]/about/layout.tsx
    import type { Metadata } from "next";
    import { locales, defaultLocale } from "@/i18n";
    import { getTranslations, unstable_setRequestLocale } from "next-intl/server";
    
    function localizedPath(locale: string, path: string) {
      return locale === defaultLocale ? path : `/${locale}${path}`;
    }
    
    export async function generateMetadata({
      params,
    }: {
      params: { locale: string };
    }): Promise<Metadata> {
      const { locale } = params;
      const t = await getTranslations({ locale, namespace: "about" });
    
      const url = "/about";
      const languages = Object.fromEntries(
        locales.map((l) => [l, localizedPath(l, url)])
      );
    
      return {
        title: t("title"),
        description: t("description"),
        alternates: {
          canonical: localizedPath(locale, url),
          languages: { ...languages, "x-default": url },
        },
      };
    }
    
    // ... ページの残りのコード
    
    src/app/sitemap.ts
    import type { MetadataRoute } from "next";
    import { locales, defaultLocale } from "@/i18n";
    
    const origin = "https://example.com";
    
    const formatterLocalizedPath = (locale: string, path: string) =>
      locale === defaultLocale ? `${origin}${path}` : `${origin}/${locale}${path}`;
    
    export default function sitemap(): MetadataRoute.Sitemap {
      const aboutLanguages = Object.fromEntries(
        locales.map((l) => [l, formatterLocalizedPath(l, "/about")])
      );
    
      return [
        {
          url: formatterLocalizedPath(defaultLocale, "/about"),
          lastModified: new Date(),
          changeFrequency: "monthly", // 毎月の更新頻度
          priority: 0.7, // 優先度
          alternates: { languages: aboutLanguages }, // 言語別の代替URL
        },
      ];
    }
    
    src/app/robots.ts
    import type { MetadataRoute } from "next";
    import { locales, defaultLocale } from "@/i18n";
    
    const origin = "https://example.com";
    const withAllLocales = (path: string) => [
      path,
      ...locales.filter((l) => l !== defaultLocale).map((l) => `/${l}${path}`), // デフォルトロケール以外のパスを追加
    ];
    
    export default function robots(): MetadataRoute.Robots {
      const disallow = [
        ...withAllLocales("/dashboard"), // /dashboard へのアクセス禁止
        ...withAllLocales("/admin"), // /admin へのアクセス禁止
      ];
    
      return {
        rules: { userAgent: "*", allow: ["/"], disallow },
        host: origin,
        sitemap: `${origin}/sitemap.xml`,
      };
    }
    

    next-i18next

    i18n.config.ts
    export const locales = ["en", "fr"] as const;
    export type Locale = (typeof locales)[number];
    export const defaultLocale: Locale = "en";
    
    /** デフォルトロケールでない場合はロケールをパスの前に付ける */
    export function localizedPath(locale: string, path: string) {
      return locale === defaultLocale ? path : `/${locale}${path}`;
    }
    
    /** 絶対URLヘルパー */
    const ORIGIN = "https://example.com";
    export function abs(locale: string, path: string) {
      return `${ORIGIN}${localizedPath(locale, path)}`;
    }
    
    src/app/[locale]/about/layout.tsx
    import type { Metadata } from "next";
    import { locales, defaultLocale, localizedPath } from "@/i18n.config";
    
    export async function generateMetadata({
      params,
    }: {
      params: { locale: string };
    }): Promise<Metadata> {
      const { locale } = params;
    
      // 正しいJSONファイルを動的にインポートする
      const messages = (await import(`@/../public/locales/${locale}/about.json`))
        .default;
    
      const languages = Object.fromEntries(
        locales.map((l) => [l, localizedPath(l, "/about")])
      );
    
      return {
        title: messages.title,
        description: messages.description,
        alternates: {
          canonical: localizedPath(locale, "/about"),
          languages: { ...languages, "x-default": "/about" },
        },
      };
    }
    
    export default async function AboutPage() {
      return <h1>About</h1>;
    }
    
    src/app/sitemap.ts
    import type { MetadataRoute } from "next";
    import { locales, defaultLocale, abs } from "@/i18n.config";
    
    export default function sitemap(): MetadataRoute.Sitemap {
      const languages = Object.fromEntries(
        locales.map((l) => [l, abs(l, "/about")])
      );
      return [
        {
          url: abs(defaultLocale, "/about"),
          lastModified: new Date(),
          changeFrequency: "monthly",
          priority: 0.7,
          alternates: { languages },
        },
      ];
    }
    
    src/app/robots.ts
    import type { MetadataRoute } from "next";
    import { locales, defaultLocale, localizedPath } from "@/i18n.config";
    
    const ORIGIN = "https://example.com";
    
    const expandAllLocales = (path: string) => [
      localizedPath(defaultLocale, path),
      ...locales
        .filter((l) => l !== defaultLocale)
        .map((l) => localizedPath(l, path)),
    ];
    
    export default function robots(): MetadataRoute.Robots {
      const disallow = [
        ...expandAllLocales("/dashboard"),
        ...expandAllLocales("/admin"),
      ];
    
      return {
        rules: { userAgent: "*", allow: ["/"], disallow },
        host: ORIGIN,
        sitemap: `${ORIGIN}/sitemap.xml`,
      };
    }
    

    Intlayer

    src/app/[locale]/about/layout.tsx
    import { getIntlayer, getMultilingualUrls } from "intlayer";
    import type { Metadata } from "next";
    import type { LocalPromiseParams } from "next-intlayer";
    
    export const generateMetadata = async ({
      params,
    }: LocalPromiseParams): Promise<Metadata> => {
      const { locale } = await params;
    
      const metadata = getIntlayer("page-metadata", locale);
    
      /**
       * 各ロケールのすべてのURLを含むオブジェクトを生成します。
       *
       * 例:
       * ```ts
       *  getMultilingualUrls('/about');
       *
       *  // 戻り値
       *  // {
       *  //   en: '/about',
       *  //   fr: '/fr/about',
       *  //   es: '/es/about',
       *  // }
       * ```
       */
      const multilingualUrls = getMultilingualUrls("/about");
    
      return {
        ...metadata,
        alternates: {
          canonical: multilingualUrls[locale as keyof typeof multilingualUrls],
          languages: { ...multilingualUrls, "x-default": "/about" },
        },
      };
    };
    
    // ... ページの残りのコード
    
    src/app/sitemap.ts
    import { getMultilingualUrls } from "intlayer";
    import type { MetadataRoute } from "next";
    
    const sitemap = (): MetadataRoute.Sitemap => [
      {
        url: "https://example.com/about",
        alternates: {
          languages: { ...getMultilingualUrls("https://example.com/about") },
        },
      },
    ];
    
    src/app/robots.ts
    import { getMultilingualUrls } from "intlayer";
    import type { MetadataRoute } from "next";
    
    // すべての多言語URLを取得する関数
    const getAllMultilingualUrls = (urls: string[]) =>
      urls.flatMap((url) => Object.values(getMultilingualUrls(url)) as string[]);
    
    // robots.txtの設定を生成
    const robots = (): MetadataRoute.Robots => ({
      rules: {
        userAgent: "*", // すべてのユーザーエージェントに適用
        allow: ["/"], // ルートパスは許可
        disallow: getAllMultilingualUrls(["/dashboard"]), // /dashboard以下の多言語URLはクロール禁止
      },
      host: "https://example.com", // ホスト名
      sitemap: `https://example.com/sitemap.xml`, // サイトマップのURL
    });
    
    export default robots;
    
    Intlayerは、サイトマップ用の多言語URLを生成するためのgetMultilingualUrls関数を提供しています。

    Intlayer SEO Scannerでウェブサイトを監査する

    多言語アプリが完全に最適化されていることを確認するために、Intlayer SEO Scannerを使用することをお勧めします。これは多言語ウェブサイトチェックに特化したツールであり、ローカライズされたすべてのページで不足しているhreflangタグ、リンク切れ、その他のSEO問題を特定するのに役立ちます。


    結論

    Next.jsでi18nを正しく実装することは、単にテキストを翻訳するだけでなく、検索エンジンやユーザーがどのバージョンのコンテンツを提供すべきかを正確に認識できるようにすることです。 hreflang、サイトマップ、robotsルールの設定が、翻訳を真のSEO価値に変える鍵となります。

    next-intlやnext-i18nextは、この連携を実現するための堅実な方法を提供しますが、通常はロケール間で一貫性を保つために多くの手動設定が必要です。

    ここでIntlayerが真価を発揮します:

    getMultilingualUrlsのような組み込みヘルパーが付属しており、hreflang、サイトマップ、robotsの統合をほぼ手間なく実現します。

    メタデータはJSONファイルやカスタムユーティリティに散在するのではなく、一元管理されます。

    Next.jsのために最初から設計されているため、設定のデバッグに費やす時間を減らし、リリースに集中できます。

    単に翻訳するだけでなく、多言語SEOをスムーズに拡大することを目指すなら、Intlayerは最もクリーンで将来性のあるセットアップを提供します。

    コメント

    まだコメントはありません。最初のコメントを共有しましょう。

    関連記事

    最新の投稿