チュートリアル//10 読了時間

SvelteKit i18n: Svelte 5で型安全な多言語アプリを構築する

Eray Gündoğmuş
共有

SvelteKit i18n: Svelte 5で型安全な多言語アプリを構築する

SvelteKitのアーキテクチャは、国際化との相性が驚くほど良好です。サーバーサイドのload関数はレンダリング前に実行されるため、ロケール検出とメッセージの読み込みが自然な流れで行えます。レイアウト階層を持つURLベースのルーティングにより、/en//de/のようなルートプレフィックスをフレームワークに逆らうことなく実装できます。また、Svelte 5のルーン — $state$derived$effect — を使えば、Svelte 4で必要だったボイラープレートなストアを使わずに、リアクティブなロケール切り替えが実現できます。

このガイドでは、Svelte 5を使ってプロダクション対応の多言語SvelteKitアプリを構築する方法を解説します。ロケール検出、型安全な翻訳関数、Intl APIを使った複数形処理、SEOの考慮事項について説明します。さらに、翻訳ファイルをバンドルする方法とCDNから配信する方法のトレードオフについても正直にお伝えします。

プロジェクトのセットアップ

新しいSvelteKitプロジェクトを作成します(TypeScriptテンプレート):

npx sv create my-i18n-app
cd my-i18n-app
npm install

このガイドでは依存関係を最小限に抑えます — モダンブラウザに組み込まれているIntl APIがフォーマットを担当し、薄い翻訳レイヤーは自分たちで実装します。ICUメッセージフォーマットのサポートが必要な場合は、@messageformat/coreのようなライブラリを組み込むことができます。

目標とするフォルダ構成:

src/
  lib/
    i18n/
      index.ts          # コア翻訳ユーティリティ
      types.ts          # 生成済み/共有型定義
      locales/
        en.json
        de.json
        fr.json
  hooks.server.ts       # ロケール検出
  routes/
    [locale]/
      +layout.server.ts # 翻訳の読み込み
      +layout.svelte    # ツリーへの翻訳の提供
      +page.svelte

共有設定ファイルでサポートするロケールを定義します:

// src/lib/i18n/config.ts
export const SUPPORTED_LOCALES = ['en', 'de', 'fr'] as const;
export type Locale = typeof SUPPORTED_LOCALES[number];
export const DEFAULT_LOCALE: Locale = 'en';

export function isSupportedLocale(locale: string): locale is Locale {
  return SUPPORTED_LOCALES.includes(locale as Locale);
}

ロケール検出とルーティング

SvelteKitのhooks.server.tsは、どのルートが実行される前にもユーザーのロケールを検出するのに最適な場所です。まずURLから読み取り、Accept-Languageヘッダーにフォールバックし、さらにデフォルト値にフォールバックします。

// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { DEFAULT_LOCALE, isSupportedLocale } from '$lib/i18n/config';

export const handle: Handle = async ({ event, resolve }) => {
  // URLパスセグメントからロケールを抽出
  const pathLocale = event.url.pathname.split('/')[1];

  const locale = isSupportedLocale(pathLocale)
    ? pathLocale
    : detectFromHeader(event.request.headers.get('accept-language'));

  event.locals.locale = locale;

  return resolve(event, {
    transformPageChunk: ({ html }) => html.replace('%lang%', locale),
  });
};

function detectFromHeader(header: string | null): import('$lib/i18n/config').Locale {
  if (!header) return DEFAULT_LOCALE;

  const accepted = header
    .split(',')
    .map((part) => {
      const [lang, q = 'q=1'] = part.trim().split(';');
      return { lang: lang.split('-')[0].trim(), q: parseFloat(q.split('=')[1]) };
    })
    .sort((a, b) => b.q - a.q);

  for (const { lang } of accepted) {
    if (isSupportedLocale(lang)) return lang;
  }

  return DEFAULT_LOCALE;
}

アプリのlocals型にlocaleフィールドを追加します:

// src/app.d.ts
import type { Locale } from '$lib/i18n/config';

declare global {
  namespace App {
    interface Locals {
      locale: Locale;
    }
  }
}

URLベースのルーティングには、トップレベルに[locale]セグメントを作成し、それを検証するparamsマッチャーを追加します:

// src/params/locale.ts
import { isSupportedLocale } from '$lib/i18n/config';

export function match(param: string): boolean {
  return isSupportedLocale(param);
}

ルートディレクトリでこのマッチャーを参照します: src/routes/[locale=locale]/。SvelteKitは、セグメントが有効なロケール文字列のパスのみをマッチさせます。

翻訳の読み込み

[locale]セグメント配下の+layout.server.tsで、現在のロケールの翻訳を読み込みます。これはページのレンダリング前にサーバーサイドで実行されるため、未翻訳コンテンツのちらつきが発生しません。

// src/routes/[locale=locale]/+layout.server.ts
import type { LayoutServerLoad } from './$types';

export const load: LayoutServerLoad = async ({ params, fetch }) => {
  const locale = params.locale;

  // オプションA: バンドルされたJSON(直接インポート)
  const messages = await import(`$lib/i18n/locales/${locale}.json`);

  // オプションB: CDNエンドポイント(翻訳更新時に再デプロイ不要)
  // const res = await fetch(`https://cdn.example.com/translations/${locale}.json`);
  // const messages = await res.json();

  return { locale, messages: messages.default };
};

レイアウトコンポーネントで、子コンポーネントに翻訳を提供します:

<!-- src/routes/[locale=locale]/+layout.svelte -->
<script lang="ts">
  import { setContext } from 'svelte';
  import { createTranslator } from '$lib/i18n';
  import type { LayoutData } from './$types';

  const { data }: { data: LayoutData } = $props();

  const t = createTranslator(data.messages);
  setContext('t', t);
  setContext('locale', data.locale);
</script>

<slot />

Svelte 5ルーンを使ったコンポーネントでの翻訳利用

Svelte 5では、$state$derivedを使って、ロケールが変更されたときに翻訳をリアクティブにすることができます。ルーンで綺麗に動作する翻訳ストアのパターンを紹介します:

// src/lib/i18n/index.ts
import type { Messages } from './types';

export function createTranslator(messages: Messages) {
  return function t(key: keyof Messages, params?: Record<string, string | number>): string {
    const template = messages[key] ?? key;

    if (!params) return template;

    return Object.entries(params).reduce<string>(
      (result, [k, v]) => result.replaceAll(`{${k}}`, String(v)),
      template
    );
  };
}

コンポーネントでは、コンテキストから翻訳関数を取得します:

<!-- src/routes/[locale=locale]/+page.svelte -->
<script lang="ts">
  import { getContext } from 'svelte';
  import type { Translator } from '$lib/i18n/types';

  const t = getContext<Translator>('t');
  const locale = getContext<string>('locale');

  // Svelte 5: コンテキストからのderivedステート
  let greeting = $derived(t('home.greeting', { name: 'World' }));
</script>

<h1>{greeting}</h1>
<p>{t('home.description')}</p>

フルページリロードなしでロケールを切り替えるには、$stateを使って翻訳をリアクティブに更新します:

<script lang="ts">
  import { createTranslator } from '$lib/i18n';
  import type { Messages } from '$lib/i18n/types';

  let messages = $state<Messages>({} as Messages);
  let t = $derived(createTranslator(messages));

  async function switchLocale(newLocale: string) {
    const res = await fetch(`/api/translations/${newLocale}`);
    messages = await res.json();
    // リロードなしでURLを更新
    history.pushState({}, '', `/${newLocale}${window.location.pathname.replace(/^\/[^/]+/, '')}`);
  }
</script>

型安全性

型安全な翻訳関数は、存在しないキーをコンパイル時に検出し、IDEのオートコンプリートを有効にします。ベースロケールファイルから型を生成します:

// src/lib/i18n/types.ts
// en.jsonから自動生成 — 手動で編集しないこと
import type en from './locales/en.json';

export type Messages = typeof en;
export type MessageKey = keyof Messages;
export type Translator = (key: MessageKey, params?: Record<string, string | number>) => string;

このステップを自動化することができます。型を再生成するシンプルなスクリプト:

// scripts/generate-i18n-types.ts
import { writeFileSync } from 'fs';

const output = `// Auto-generated from en.json — do not edit manually
import type en from './locales/en.json';

export type Messages = typeof en;
export type MessageKey = keyof Messages;
export type Translator = (key: MessageKey, params?: Record<string, string | number>) => string;
`;

writeFileSync('src/lib/i18n/types.ts', output);
console.log('Types generated.');

package.jsonのビルドプロセスに組み込みます:

{
  "scripts": {
    "generate:i18n": "tsx scripts/generate-i18n-types.ts",
    "prebuild": "npm run generate:i18n",
    "predev": "npm run generate:i18n"
  }
}

これでt('home.greetng')はTypeScriptエラーになります。'home.greetng'Messagesに存在しないためです。ユーザーがプロダクション環境で発見するのではなく、コンパイル時にタイポが検出されます。

これはBetter i18nのようなプラットフォームが本当の価値を発揮する領域の一つです。翻訳スキーマからSDK型を自動生成するため、翻訳者がキーを追加またはリネームするたびに、手動でスクリプトを実行することなくTypeScript型が更新されます。

Svelteと並行してReactやNext.jsアプリを構築している場合、同じ型安全なパターンが適用されます — フレームワーク固有の実装についてはReact i18n: Reactの国際化に関する完全ガイドNext.js App Router i18n: サーバーコンポーネントとRSCパターンを参照してください。

複数形処理とフォーマット

JavaScriptに組み込まれているIntl APIは、外部依存なしにロケールをまたいで複数形処理を正しく扱います:

// src/lib/i18n/format.ts

export function pluralize(
  locale: string,
  count: number,
  forms: Record<Intl.LDMLPluralRule, string>
): string {
  const rule = new Intl.PluralRules(locale).select(count);
  const template = forms[rule] ?? forms.other;
  return template.replace('{count}', String(count));
}

export function formatNumber(locale: string, value: number, options?: Intl.NumberFormatOptions): string {
  return new Intl.NumberFormat(locale, options).format(value);
}

export function formatDate(locale: string, date: Date, options?: Intl.DateTimeFormatOptions): string {
  return new Intl.DateTimeFormat(locale, options).format(date);
}

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

<script lang="ts">
  import { pluralize, formatNumber, formatDate } from '$lib/i18n/format';
  import { getContext } from 'svelte';

  const locale = getContext<string>('locale');

  const itemCount = 3;
  const price = 1499.99;
  const publishedAt = new Date('2024-06-15');

  let itemLabel = $derived(
    pluralize(locale, itemCount, {
      one: '{count} item',
      other: '{count} items',
      zero: 'no items',
      two: '{count} items',
      few: '{count} items',
      many: '{count} items',
    })
  );

  let formattedPrice = $derived(formatNumber(locale, price, { style: 'currency', currency: 'USD' }));
  let formattedDate = $derived(formatDate(locale, publishedAt, { dateStyle: 'long' }));
</script>

<p>{itemLabel}</p>
<p>{formattedPrice}</p>
<p>{formattedDate}</p>

ドイツ語ロケールでは、1499.991.499,99 $、日付は15. Juni 2024としてレンダリングされます — 追加ライブラリは不要です。

多言語SvelteKitのSEO

検索エンジンは、ページの代替言語バージョンについて明示的なシグナルを必要とします。レイアウトの<svelte:head>hreflangタグとローカライズされたメタデータを追加します。これを正しく実装することは多言語ランキングにとって不可欠です — hreflangタグとロケールURLに関するi18n SEOの完全ガイドでは、すべての実装の詳細と一般的な落とし穴を網羅しています。

<!-- src/routes/[locale=locale]/+layout.svelte -->
<script lang="ts">
  import { page } from '$app/stores';
  import { SUPPORTED_LOCALES } from '$lib/i18n/config';

  const { data }: { data: LayoutData } = $props();

  // サポートされているすべてのロケールの代替URLを構築
  let alternates = $derived(
    SUPPORTED_LOCALES.map((loc) => ({
      locale: loc,
      url: $page.url.href.replace(`/${data.locale}/`, `/${loc}/`),
    }))
  );
</script>

<svelte:head>
  {#each alternates as { locale, url }}
    <link rel="alternate" hreflang={locale} href={url} />
  {/each}
  <link rel="alternate" hreflang="x-default" href={alternates.find(a => a.locale === 'en')?.url} />
</svelte:head>

<slot />

事前レンダリングの場合、SvelteKitがすべてのロケールバリアントを生成するよう設定します:

// svelte.config.js
const config = {
  kit: {
    prerender: {
      entries: [
        '/en',
        '/de',
        '/fr',
        '/en/about',
        '/de/about',
        '/fr/about',
        // ... または動的に生成
      ],
    },
  },
};

動的な事前レンダリングエントリには、entriesエクスポートを含む+page.server.tsを使用します:

// src/routes/[locale=locale]/+page.server.ts
import { SUPPORTED_LOCALES } from '$lib/i18n/config';

export function entries() {
  return SUPPORTED_LOCALES.map((locale) => ({ locale }));
}

CDN優先 vs バンドル翻訳

どちらのアプローチも機能します — 正しい選択はワークフローによって異なります。

バンドル翻訳(JSONからインポート):

  • ページ読み込みごとの追加HTTPリクエストがゼロ
  • 翻訳がデプロイメントにバージョンロックされる
  • オフラインでも外部依存なしで動作する
  • 翻訳のタイポを修正するには再デプロイが必要

CDN配信:

  • アプリを再デプロイせずに翻訳の更新が反映される
  • 翻訳者がエンジニアとは独立して変更を公開できる
  • ネットワークリクエストが追加される(積極的なキャッシュで軽減)
  • キャッシュ無効化戦略が必要

i18nの取り組みを始めたばかりのほとんどのチームには、バンドルJSONがよりシンプルです。翻訳チームが成長し、コンテンツ更新のペースが上がるにつれて、デプロイとの結合が苦痛になります。ドイツ語のボタンラベルを修正するためだけにコードリリースを出荷するのは持続可能ではありません。バンドルアプローチを卒業したら、CI/CDパイプラインにおけるi18nの自動化が自然な次のステップです。

これはBetter i18nのようなプラットフォームが構築された核心的な問題です: 翻訳メッセージはCDN上に保存され、長いキャッシュTTLを持つバージョン付きURLで配信されます。エンジニアはコードを更新し、翻訳者は翻訳を更新します — 互いにブロックすることなく、独立して。featuresページでは、翻訳の公開時のキャッシュ無効化の仕組みを含め、これがどのように機能するかを詳しく説明しています。

中間的なアプローチもあります: ビルド時にCDNから翻訳を取得し、その結果をデプロイメントアーティファクトにバンドルします。ロケールファイルをリポジトリに入れることなく高速なローカル読み込みが得られ、更新には依然として再デプロイが必要です。

まとめ

SvelteKitとSvelte 5は、多言語アプリの堅固な基盤を形成します:

  • フックがレンダリング前にサーバーサイドでロケール検出を処理する
  • paramsマッチャーを持つ動的ルートセグメントがクリーンなURLベースのロケールルーティングを提供する
  • load関数が翻訳をレイアウトごとに一度取得し、コンポーネントツリー下に渡す
  • Svelte 5のルーン$state$derived)が手動ストアサブスクリプションなしでロケール切り替えをリアクティブにする
  • ベースロケールから生成されたTypeScript型が欠落または誤字キーをコンパイル時に検出する
  • Intl APIがすべてのロケールで複数形処理と数値/日付フォーマットを正しく扱う
  • **svelte:head**のhreflangタグと事前レンダリングされたロケールルートが検索エンジンに情報を提供する

ここで説明したアーキテクチャは、ほとんどのプロダクション要件を満たしています。主なギャップは運用面にあります: アプリが成長するにつれて、リポジトリ内の翻訳ファイルの管理がエンジニアと翻訳者の間に摩擦を生み出します。CDN配信と自動型生成は、専用ツールが最も素早く効果を発揮する二つの領域です。

実際の翻訳チームとSvelteKitアプリを構築しているなら、Better i18nのSvelte統合を見てみてください — 型安全なSDK、すぐに使えるCDN配信、そして翻訳の変更がコードと同じレビュープロセスを経るGitベースのレビューワークフローを提供しています。

Better i18nはモダンなフロントエンドチームのために構築された、開発者ファーストのローカリゼーションプラットフォームです。型安全なSDK、Gitベースのワークフロー、CDN配信、用語集に準拠したAI翻訳 — リポジトリにロケールファイルを置くことなく実現できます。


better-i18nでアプリをグローバルに展開しよう

better-i18nは、AI駆動の翻訳、Gitネイティブなワークフロー、グローバルCDN配信を一つの開発者ファーストのプラットフォームに統合しています。スプレッドシートの管理をやめて、すべての言語でリリースを始めましょう。

無料で始める → · 機能を探索する · ドキュメントを読む

Comments

Loading comments...