はじめに
初めて、アドベントカレンダーなるものに参加してみることにしました!!
めっちゃワクワクしてます。
暖かい目で見ていただけると幸いです笑
こんにちは、keni (@keni_1997)です。
個人で「Pomodoro Flow」というポモドーロタイマーアプリを開発・運営しています。
突然ですが、Reactで開発をしている皆さん、多言語対応(i18n)はどうしていますか?
私は元々、英語圏のユーザーをターゲットにWebアプリを開発していました。
しかし、ありがたいことに日本からのアクセスも少しばかり増えてきたことをきっかけに、react-i18next を使った方法で、日本語や他の言語にも対応し、UIの多言語化を実現しました。
↓詳しくは過去の記事をご覧ください。
これで「グローバル対応完了!」と一息ついたのも束の間、ある問題に気づきました。
- 「Googleで検索しても、日本の検索結果に英語の説明文が表示されてしまう…」
そう、UIを翻訳しただけでは、検索エンジンやSNSはサイトが多言語対応していることを認識してくれなかったのです。これでは、せっかく多言語対応したアプリも、ターゲットとするユーザーにはなかなか届きません。
この記事では、この課題を解決し、検索エンジンとSNSにも多言語対応を施すことで、技術的にもマーケティング的にも"真の"グローバル対応を実現するための、具体的なSEO戦略を実録コードと共に解説します。
🎯 対象読者
-
react-i18nextで基本的な多言語対応を実装済みの方 - 個人開発アプリの海外ユーザーを増やしたい方
- 多言語サイトのSEO(国際化SEO)について知りたい方
🚀 この記事を読めば…
- Googleの検索結果に、ユーザーの言語に合ったタイトルや説明文を表示できるようになる!
- TwitterやFacebookでシェアされた際に、最適な言語でプレビューが表示されるようになる!
-
hreflangやcanonicalといった、国際化SEOの重要概念をコードレベルで理解できる!
💻 開発環境
- React: 19.0.0
- TypeScript: ~5.7.2
- vite: ^6.3.0
- i18next: ^23.11.5
- react-i18next: ^15.5.2
それでは、実装に進みましょう!
Step 1: SEO情報の設計と翻訳ファイルの拡張
まずは、各言語のSEO情報を管理するための設計から始めます。
react-i18next の翻訳ファイル(translation.ts)に、seo という新しいセクションを追加しましょう。
ポイントは、トップページ(landing)とアプリ本体(app)で、表示したいSEO情報を分けて定義することです。
export const jaTranslation = {
// ...既存の翻訳
seo: {
landing: {
title: 'Pomodoro Flow | あなたの集中をデザインする、無料ポモドーロタイマー',
description:
'YouTubeの音楽と共に、ポモドーロテクニックで集中力を高める。Pomodoro Flowは、タスク管理もできる完全無料のWebアプリ。',
keywords: ['ポモドーロタイマー', '集中タイマー', '作業用BGM'],
ogTitle: 'Pomodoro Flow – あなたの集中をデザインする無料ポモドーロタイマー',
ogDescription:
'YouTubeの音楽を作業BGMに。タスク管理もできる無料のポモドーロタイマーで、生産性を最大化しよう。',
twitterTitle: 'Pomodoro Flow | 音楽で集中する無料ポモドーロタイマー',
twitterDescription: 'いつものYouTubeを最高の作業用BGMに。無料で使えるポモドーロタイマーで、集中フロー体験を。',
},
app: {
title: 'タイマー&タスク管理 | Pomodoro Flow',
description:
'集中時間やBGM、タスクリストをあなた好みに設定。Pomodoro Flowで、自分だけの集中環境をデザインしよう。',
// ... appページ用のogTitle, twitterTitleなども同様に定義
},
},
// ...
} as const;
これを、対応するすべての言語ファイル(ko, es...)にそれぞれ追加していきます。地道ですが、ここが土台になります。
Step 2: 最強の <SeoHead> コンポーネントを作る
次に、これらのSEO情報をHTMLの<head>タグに動的に出力するためのコンポーネントを作成します。これこそが今回の実装の心臓部です。
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { supportedLanguages, type SupportedLanguage } from '../i18n/config';
import type { SeoEntry } from '../i18n/types';
const BASE_URL = 'https://pomodoro-flow.com'; // あなたのサイトのURLに変更
const FALLBACK_OG_IMAGE = `${BASE_URL}/ogp.png`; // デフォルトのOGP画像
// i18nextの言語コードとOGPで使うロケール表記をマッピング
const ogLocaleMap: Record<SupportedLanguage, string> = {
en: 'en_US',
ja: 'ja_JP',
'zh-CN': 'zh_CN',
// ... 他言語も同様に追加
};
type SeoPage = 'landing' | 'app';
// URLから言語を解決するヘルパー関数
const resolveLanguage = (language?: string): SupportedLanguage => {
// ... (実装は省略)
};
// 言語別の正規URLを生成するヘルパー関数
const canonicalForLanguage = (language: SupportedLanguage, path: string) => {
const suffix = language === 'en' ? '' : `?lang=${encodeURIComponent(language)}`;
return `${BASE_URL}${path}${suffix}`;
};
export const SeoHead = ({ page }: { page: SeoPage }) => {
const { t, i18n } = useTranslation();
const currentLanguage = resolveLanguage(i18n.language);
const canonicalPath = page === 'app' ? '/app' : '/';
// 翻訳ファイルから現在のページと言語に合ったSEO情報を取得
const seo = t(`seo.${page}`, { returnObjects: true }) as SeoEntry;
const canonicalUrl = canonicalForLanguage(currentLanguage, canonicalPath);
// 全言語の代替ページURLリストを生成
const alternateLinks = useMemo(
() =>
supportedLanguages.map((lang) => ({
lang,
href: canonicalForLanguage(lang, canonicalPath),
})),
[canonicalPath],
);
const keywords = seo.keywords?.filter(Boolean);
// React 18以降はコンポーネントから直接<head>タグ内要素を返せる
return (
<>
<title>{seo.title}</title>
<meta name="description" content={seo.description} />
{keywords && keywords.length > 0 ? <meta name="keywords" content={keywords.join(', ')} /> : null}
{/* --- OGP (Facebook, etc.) --- */}
<meta property="og:title" content={seo.ogTitle ?? seo.title} />
<meta property="og:description" content={seo.ogDescription ?? seo.description} />
<meta property="og:type" content="website" />
<meta property="og:url" content={canonicalUrl} />
<meta property="og:image" content={seo.ogImage ?? FALLBACK_OG_IMAGE} />
<meta property="og:locale" content={ogLocaleMap[currentLanguage]} />
{/* --- Twitter Card --- */}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={seo.twitterTitle ?? seo.title} />
<meta name="twitter:description" content={seo.twitterDescription ?? seo.description} />
<meta name="twitter:image" content={seo.twitterImage ?? FALLBACK_OG_IMAGE} />
{/* --- 国際化SEOの最重要タグ --- */}
<link rel="canonical" href={canonicalUrl} />
{alternateLinks.map(({ lang, href }) => (
<link key={lang} rel="alternate" hrefLang={lang} href={href} />
))}
<link rel="alternate" hrefLang="x-default" href={`${BASE_URL}${canonicalPath}`} />
</>
);
};
💡 SeoHead コンポーネントのポイント解説
-
og:locale: FacebookなどのOGPで、どの地域の言語かを明示的に伝えるためのタグです。jaではなくja_JPのように国コードまで含めるのが一般的です。 -
canonical: ページの「正規の」URLを検索エンジンに伝えます。?lang=jaのようなパラメータが付いていても、評価を一つにまとめてくれます。 -
alternate&hreflang: これが国際化SEOの核です。「このページには、これらの言語のバージョンがありますよ」とGoogleに教えるためのタグです。これにより、Googleはユーザーの言語設定に最適なページを検索結果として提示してくれます。 -
x-default: どの言語にも当てはまらないユーザー向けのデフォルトページを指定します。通常は英語ページなどを指定します。
Step 3: 各ページへの適用
作成した <SeoHead> コンポーネントを、各ページコンポーネントの先頭に配置します。
import { SeoHead } from '../components/SeoHead';
export const LandingPage = () => {
// ...
return (
<div className="bg-base-100">
<SeoHead page="landing" />
{/* ...ページの残りのコンテンツ */}
</div>
);
};
import { SeoHead } from './components/SeoHead';
import { useLocation } from 'react-router-dom';
export const App = () => {
const { pathname } = useLocation();
// /app なら 'app'、それ以外(/)なら 'landing' を渡す
const pageType = pathname.startsWith('/app') ? 'app' : 'landing';
// ...
return (
<div>
<SeoHead page={pageType} />
{/* ...ページの残りのコンテンツ */}
</div>
);
};
Step 4: サイトマップ (sitemap.xml) の多言語対応
最後に、サイトマップも多言語対応させます。これにより、Googleのクローラーがサイトの全言語のページ構成をより効率的に把握できるようになります。
sitemap.xml 内の各 <url> タグに、<xhtml:link> を使って代替言語バージョンをすべてリストアップします。
<?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>https://pomodoro-flow.com/</loc>
<lastmod>2025-11-11T07:16:45+09:00</lastmod>
<xhtml:link rel="alternate" hreflang="en" href="https://pomodoro-flow.com/" />
<xhtml:link rel="alternate" hreflang="ja" href="https://pomodoro-flow.com/?lang=ja" />
<xhtml:link rel="alternate" hreflang="zh-CN" href="https://pomodoro-flow.com/?lang=zh-CN" />
<xhtml:link rel="alternate" hreflang="es" href="https://pomodoro-flow.com/?lang=es" />
{/* ... 他の言語もすべて追加 ... */}
<xhtml:link rel="alternate" hreflang="x-default" href="https://pomodoro-flow.com/" />
</url>
<url>
<loc>https://pomodoro-flow.com/app</loc>
<lastmod>2025-01-19T07:16:45+09:00</lastmod>
<xhtml:link rel="alternate" hreflang="en" href="https://pomodoro-flow.com/app" />
<xhtml:link rel="alternate" hreflang="ja" href="https://pomodoro-flow.com/app?lang=ja" />
{/* ... 他の言語もすべて追加 ... */}
<xhtml:link rel="alternate" hreflang="x-default" href="https://pomodoro-flow.com/app" />
</url>
</urlset>
手動での更新は大変なので、AIにサイトマップを自動生成してもらうと楽ちんです。
🎉 おわりに
お疲れ様でした!
今回の実装をまとめると以下の通りです。
- 翻訳ファイルに各言語のSEO情報を追加した。
-
<SeoHead>コンポーネントを作成し、hreflangやOGPタグを動的に出力するようにした。 - サイトマップに多言語のURL構成を記述した。
これにより、UIの翻訳だけでなく、検索エンジンやSNSに対してもサイトが多言語対応していることを正しく伝えられるようになりました。これは、海外からの流入を増やすための非常に重要で効果的な一歩です。
「多言語対応は react-i18next を入れるだけでは終わらない」。
この記事が、あなたのアプリを世界中のユーザーに届けるための一助となれば幸いです。
また、もしよろしければ、今回実装した「Pomodoro Flow」も触ってみて、フィードバックなどいただけると励みになります!
最後までお読みいただき、ありがとうございました!
