2026年版:i18n対応AIモダンWebアプリの構築方法
Lingui + AI翻訳を活用した多言語Webアプリ構築の完全ガイド。Next.js、Claude、T3 Turboを組み合わせ、17言語への対応を自動化する手法を解説します。

title: 2026年のi18n:AIとLinguiで実現する、次世代の多言語対応アーキテクチャ content: さて、2026年のi18n(国際化)について真剣に話しましょう。
ほとんどのチュートリアルでは、手作業で文字列を翻訳したり、翻訳者を雇ったり、あるいは微妙なGoogle翻訳APIを使ったりするように教えています。しかし、よく考えてください。私たちは今、Claude Sonnet 4.5の時代に生きているんですよ。なぜ2019年のような翻訳のやり方をしているんですか?
今回は、私たちが実際に構築した、17言語を流暢に話すプロダクションレベルのWebアプリの裏側をお見せします。理にかなった「2ピース構成」のi18nアーキテクチャです。
- Lingui: 抽出、コンパイル、ランタイムの魔法を担当
- カスタムi18nパッケージ: LLMを活用し、文脈を理解した自動翻訳を担当
使用するスタックは? Create T3 Turbo(Next.js, tRPC, Drizzle, Postgres, Tailwind)そして AI SDKです。もし2026年にもなってこれを使っていないなら、別の議論が必要ですね。
さあ、作りましょう。
従来のi18nの問題点
従来のi18nワークフローはこんな感じでした:
# 文字列を抽出
$ lingui extract
# ??? どうにかして翻訳を入手 ???
# (翻訳者を雇う、怪しいサービスを使う、泣き寝入りする)
# コンパイル
$ lingui compile
この真ん中のステップ、悪夢ですよね。
- 人間の翻訳者に高いお金を払う(遅い、高い)
- 基本的な翻訳APIを使う(文脈無視、ロボットっぽい)
- 手動で翻訳する(スケールしない)
私たちは、もっとうまくやれます。
2ピース・アーキテクチャ
これが私たちのセットアップです:
┌─────────────────────────────────────────────┐
│ Next.js App (Lingui Integration) │
│ ├─ マクロで文字列を抽出 │
│ ├─ コード内の Trans/t コンポーネント │
│ └─ コンパイル済みカタログによるランタイムi18n │
└─────────────────────────────────────────────┘
↓ .poファイルを生成
┌─────────────────────────────────────────────┐
│ @acme/i18n Package (LLM Translation) │
│ ├─ .poファイルを読み込み │
│ ├─ Claude/GPT-5でバッチ翻訳 │
│ ├─ 文脈認識、プロダクト固有の用語に対応 │
│ └─ 翻訳された.poファイルを書き込み │
└─────────────────────────────────────────────┘
↓ TypeScriptにコンパイル
┌─────────────────────────────────────────────┐
│ Compiled Message Catalogs │
│ └─ 高速で型安全なランタイム翻訳 │
└─────────────────────────────────────────────┘
ピース1 (Lingui) は開発者体験(DX)を担当します。 ピース2 (カスタムi18nパッケージ) は翻訳の魔法を担当します。
それぞれ詳しく見ていきましょう。

パート1: Next.jsでのLinguiセットアップ
インストール
T3 Turboモノレポ内で以下を実行します:
# apps/nextjs ディレクトリにて
pnpm add @lingui/core @lingui/react @lingui/macro
pnpm add -D @lingui/cli @lingui/swc-plugin
Lingui設定
apps/nextjs/lingui.config.tsを作成します:
import type { LinguiConfig } from "@lingui/conf";
const config: LinguiConfig = {
locales: [
"en", "zh_CN", "zh_TW", "ja", "ko",
"de", "fr", "es", "pt", "ar", "it",
"ru", "tr", "th", "id", "vi", "hi"
],
sourceLocale: "en",
fallbackLocales: {
default: "en"
},
catalogs: [
{
path: "<rootDir>/src/locales/{locale}/messages",
include: ["src"],
},
],
};
export default config;
最初から17言語対応です。 やらない理由はありませんよね?
Next.jsとの統合
next.config.jsを更新して、LinguiのSWCプラグインを使用するようにします:
const linguiConfig = require("./lingui.config");
module.exports = {
experimental: {
swcPlugins: [
[
"@lingui/swc-plugin",
{
// これによりビルドが高速化されます
},
],
],
},
// ... その他の設定
};
サーバーサイドのセットアップ
src/utils/i18n/appRouterI18n.tsを作成します:
import { setupI18n } from "@lingui/core";
import { allMessages } from "./initLingui";
const locales = ["en", "zh_CN", "zh_TW", /* ... */] as const;
const instances = new Map<string, ReturnType<typeof setupI18n>>();
// 全ロケールのi18nインスタンスを事前に作成
locales.forEach((locale) => {
const i18n = setupI18n({
locale,
messages: { [locale]: allMessages[locale] },
});
instances.set(locale, i18n);
});
export function getI18nInstance(locale: string) {
return instances.get(locale) ?? instances.get("en")!;
}
なぜこれが必要か? Server ComponentsにはReact Contextがないからです。これでサーバーサイド翻訳が可能になります。
クライアントサイドのProvider
src/providers/LinguiClientProvider.tsxを作成します:
"use client";
import { I18nProvider } from "@lingui/react";
import { setupI18n } from "@lingui/core";
import { useEffect, useState } from "react";
export function LinguiClientProvider({
children,
locale,
messages
}: {
children: React.ReactNode;
locale: string;
messages: any;
}) {
const [i18n] = useState(() =>
setupI18n({
locale,
messages: { [locale]: messages },
})
);
useEffect(() => {
i18n.load(locale, messages);
i18n.activate(locale);
}, [locale, messages, i18n]);
return <I18nProvider i18n={i18n}>{children}</I18nProvider>;
}
layout.tsxでアプリをラップします:
import { LinguiClientProvider } from "@/providers/LinguiClientProvider";
import { getLocale } from "@/utils/i18n/localeDetection";
import { allMessages } from "@/utils/i18n/initLingui";
export default function RootLayout({ children }: { children: React.ReactNode }) {
const locale = getLocale();
return (
<html lang={locale}>
<body>
<LinguiClientProvider locale={locale} messages={allMessages[locale]}>
{children}
</LinguiClientProvider>
</body>
</html>
);
}
コード内での翻訳の使用
Server Componentsの場合:
import { msg } from "@lingui/core/macro";
import { getI18nInstance } from "@/utils/i18n/appRouterI18n";
export async function generateMetadata({ params }) {
const locale = getLocale();
const i18n = getI18nInstance(locale);
return {
title: i18n._(msg`Pricing Plans | acme`),
description: i18n._(msg`Choose the perfect plan for you`),
};
}
Client Componentsの場合:
"use client";
import { Trans, useLingui } from "@lingui/react/macro";
export function PricingCard() {
const { t } = useLingui();
return (
<div>
<h1><Trans>Pricing Plans</Trans></h1>
<p>{t`Ultimate entertainment experience`}</p>
{/* 変数付き */}
<p>{t`${credits} credits remaining`}</p>
</div>
);
}
このマクロ構文が鍵です。 Linguiはビルド時にこれらを抽出します。
パート2: AI搭載の翻訳パッケージ
ここからが面白いところです。
パッケージ構造
packages/i18n/を作成します:
packages/i18n/
├── package.json
├── src/
│ ├── translateWithLLM.ts # コアとなるLLM翻訳ロジック
│ ├── enhanceTranslations.ts # バッチ処理プロセッサ
│ └── utils.ts # ヘルパー関数
package.json
{
"name": "@acme/i18n",
"version": "0.1.0",
"dependencies": {
"@acme/ai": "workspace:*",
"openai": "^4.77.3",
"pofile": "^1.1.4",
"zod": "^3.23.8"
}
}
LLM翻訳エンジン
これが秘密のソース、translateWithLLM.tsです:
import { openai } from "@ai-sdk/openai";
import { generateText } from "ai";
import { z } from "zod";
const translationSchema = z.object({
translations: z.array(
z.object({
msgid: z.string(),
msgstr: z.string(),
})
),
});
export async function translateWithLLM(
messages: Array<{ msgid: string; msgstr: string }>,
targetLocale: string,
options?: { model?: string }
) {
const prompt = `You are a professional translator for acme, an AI-powered creative platform.
Translate the following strings from English to ${getLanguageName(targetLocale)}.
CONTEXT:
- acme is a platform for AI chat, image generation, and creative content
- Keep brand names unchanged (acme, Claude, etc.)
- Preserve HTML tags, variables like {count}, and placeholders
- Adapt culturally where appropriate
- Maintain tone: friendly, creative, engaging
STRINGS TO TRANSLATE:
${JSON.stringify(messages, null, 2)}
Return a JSON object with this structure:
{
"translations": [
{ "msgid": "original", "msgstr": "translation" },
...
]
}`;
const result = await generateText({
model: openai(options?.model ?? "gpt-4o"),
prompt,
temperature: 0.3, // 低いほど一貫性が増す
});
const parsed = translationSchema.parse(JSON.parse(result.text));
return parsed.translations;
}
function getLanguageName(locale: string): string {
const names: Record<string, string> = {
zh_CN: "Simplified Chinese",
zh_TW: "Traditional Chinese",
ja: "Japanese",
ko: "Korean",
de: "German",
fr: "French",
es: "Spanish",
pt: "Portuguese",
ar: "Arabic",
// ... etc
};
return names[locale] ?? locale;
}
なぜこれが機能するのか:
- 文脈認識: LLMは「acme」が何であるかを知っています。
- 構造化された出力: Zodスキーマにより有効なJSONを保証します。
- 低いTemperature: 翻訳の一貫性を保ちます。
- フォーマット維持: HTMLや変数はそのまま保持されます。
バッチ翻訳プロセッサ
enhanceTranslations.tsを作成します:
import fs from "fs";
import path from "path";
import pofile from "pofile";
import { translateWithLLM } from "./translateWithLLM";
const BATCH_SIZE = 30; // 一度に30文字列ずつ翻訳
const DELAY_MS = 1000; // レート制限対策
export async function enhanceTranslations(
locale: string,
catalogPath: string
) {
const poPath = path.join(catalogPath, locale, "messages.po");
const po = pofile.parse(fs.readFileSync(poPath, "utf-8"));
// 未翻訳の項目を探す
const untranslated = po.items.filter(
(item) => item.msgid && (!item.msgstr || item.msgstr[0] === "")
);
if (untranslated.length === 0) {
console.log(`✓ ${locale}: All strings translated`);
return;
}
console.log(`Translating ${untranslated.length} strings for ${locale}...`);
// バッチ処理
for (let i = 0; i < untranslated.length; i += BATCH_SIZE) {
const batch = untranslated.slice(i, i + BATCH_SIZE);
const messages = batch.map((item) => ({
msgid: item.msgid,
msgstr: item.msgstr?.[0] ?? "",
}));
try {
const translations = await translateWithLLM(messages, locale);
// POファイルを更新
translations.forEach((translation, index) => {
const item = batch[index];
if (item) {
item.msgstr = [translation.msgstr];
}
});
console.log(` ${i + batch.length}/${untranslated.length} translated`);
// 進捗を保存
fs.writeFileSync(poPath, po.toString());
// レート制限
if (i + BATCH_SIZE < untranslated.length) {
await new Promise((resolve) => setTimeout(resolve, DELAY_MS));
}
} catch (error) {
console.error(` Error translating batch: ${error}`);
// 次のバッチへ継続
}
}
console.log(`✓ ${locale}: Translation complete!`);
}
バッチ処理により、トークン制限を防ぎ、コストを節約します。
翻訳スクリプト
apps/nextjs/script/i18n.tsを作成します:
import { enhanceTranslations } from "@acme/i18n";
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
const LOCALES = [
"zh_CN", "zh_TW", "ja", "ko", "de",
"fr", "es", "pt", "ar", "it", "ru"
];
async function main() {
// ステップ 1: コードから文字列を抽出
console.log("📝 Extracting strings...");
await execAsync("pnpm run lingui:extract --clean");
// ステップ 2: 欠けている文字列を自動翻訳
console.log("\n🤖 Translating with AI...");
const catalogPath = "./src/locales";
for (const locale of LOCALES) {
await enhanceTranslations(locale, catalogPath);
}
// ステップ 3: TypeScriptにコンパイル
console.log("\n⚡ Compiling catalogs...");
await execAsync("npx lingui compile --typescript");
console.log("\n✅ Done! All translations updated.");
}
main().catch(console.error);
package.jsonに追加します:
{
"scripts": {
"i18n": "tsx script/i18n.ts",
"lingui:extract": "lingui extract",
"lingui:compile": "lingui compile --typescript"
}
}
i18nパイプラインの実行
# すべてを支配するたった一つのコマンド
$ pnpm run i18n
📝 Extracting strings...
Catalog statistics for src/locales/{locale}/messages:
┌──────────┬─────────────┬─────────┐
│ Language │ Total count │ Missing │
├──────────┼─────────────┼─────────┤
│ en │ 847 │ 0 │
│ zh_CN │ 847 │ 123 │
│ ja │ 847 │ 89 │
└──────────┴─────────────┴─────────┘
🤖 Translating with AI...
Translating 123 strings for zh_CN...
30/123 translated
60/123 translated
90/123 translated
123/123 translated
✓ zh_CN: Translation complete!
⚡ Compiling catalogs...
✅ Done! All translations updated.
これだけです。 コードに新しい文字列を追加し、pnpm i18nを実行すれば、ドカンと17言語に翻訳されます。

ロケールの切り替え
UXの部分も忘れてはいけません。ロケールスイッチャーの実装です:
"use client";
import { useLocaleSwitcher } from "@/hooks/useLocaleSwitcher";
import { useLocale } from "@/hooks/useLocale";
const LOCALES = {
en: "English",
zh_CN: "简体中文",
zh_TW: "繁體中文",
ja: "日本語",
ko: "한국어",
// ... etc
};
export function LocaleSelector() {
const currentLocale = useLocale();
const { switchLocale } = useLocaleSwitcher();
return (
<select
value={currentLocale}
onChange={(e) => switchLocale(e.target.value)}
>
{Object.entries(LOCALES).map(([code, name]) => (
<option key={code} value={code}>
{name}
</option>
))}
</select>
);
}
フックの実装:
// hooks/useLocaleSwitcher.tsx
"use client";
import { setUserLocale } from "@/utils/i18n/localeDetection";
export function useLocaleSwitcher() {
const switchLocale = (locale: string) => {
setUserLocale(locale);
window.location.reload(); // ロケール適用のために強制リロード
};
return { switchLocale };
}
設定をCookieに保存します:
// utils/i18n/localeDetection.ts
import { cookies } from "next/headers";
export function setUserLocale(locale: string) {
cookies().set("NEXT_LOCALE", locale, {
maxAge: 365 * 24 * 60 * 60, // 1年
});
}
export function getLocale(): string {
const cookieStore = cookies();
return cookieStore.get("NEXT_LOCALE")?.value ?? "en";
}
上級編:型安全な翻訳
型安全性が欲しいですか? Linguiなら大丈夫です。
// こうではなく:
t`Hello ${name}`
// msg記述子を使用:
import { msg } from "@lingui/core/macro";
const greeting = msg`Hello ${name}`;
const translated = i18n._(greeting);
IDEが翻訳キーをオートコンプリートしてくれます。美しいですね。
パフォーマンスに関する考慮事項
1. ビルド時のコンパイル
Linguiは翻訳を最小化されたJSONにコンパイルします。ランタイムでのパース負荷はありません。
// コンパイル後の出力(最小化):
export const messages = JSON.parse('{"ICt8/V":["動画"],"..."}');
2. サーバーカタログのプリロード
起動時にすべてのカタログを一度だけロードします(前述の appRouterI18n.ts を参照)。リクエストごとのファイルI/Oは発生しません。
3. クライアントバンドルサイズ
アクティブなロケールのみをクライアントに送信します:
<LinguiClientProvider
locale={locale}
messages={allMessages[locale]} // 1つのロケールのみ
>
4. LLMコストの最適化
- バッチ翻訳: API呼び出しごとに30文字列
- 翻訳のキャッシュ: 変更のない文字列は再翻訳しない
- 安価なモデルの使用: 重要度の低い言語にはGPT-4o-miniを使用
私たちのコストは? 800以上の文字列 × 16言語でわずか2〜3ドル程度です。人間の翻訳者に比べれば小銭のようなものです。
フルスタック統合
T3 Turboの他の部分とどう連携するか見てみましょう。
tRPCとi18n
// server/api/routers/user.ts
import { createTRPCRouter, publicProcedure } from "../trpc";
import { msg } from "@lingui/core/macro";
export const userRouter = createTRPCRouter({
subscribe: publicProcedure
.mutation(async ({ ctx }) => {
// エラーも翻訳可能です!
if (!ctx.session?.user) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: ctx.i18n._(msg`You must be logged in`),
});
}
// ... サブスクリプションのロジック
}),
});
コンテキスト経由でi18nインスタンスを渡します:
// server/api/trpc.ts
import { getI18nInstance } from "@/utils/i18n/appRouterI18n";
export const createTRPCContext = async (opts: CreateNextContextOptions) => {
const locale = getLocale();
const i18n = getI18nInstance(locale);
return {
session: await getServerAuthSession(),
i18n,
locale,
};
};
Drizzleによるデータベース
ユーザーのロケール設定を保存します:
// packages/db/schema/user.ts
import { pgTable, text, varchar } from "drizzle-orm/pg-core";
export const users = pgTable("user", {
id: varchar("id", { length: 255 }).primaryKey(),
locale: varchar("locale", { length: 10 }).default("en"),
// ... その他のフィールド
});
AI SDKとの統合
AIの応答をオンザフライで翻訳します:
import { openai } from "@ai-sdk/openai";
import { generateText } from "ai";
import { useLingui } from "@lingui/react/macro";
export function useAIChat() {
const { i18n } = useLingui();
const chat = async (prompt: string) => {
const systemPrompt = i18n._(msg`You are a helpful AI assistant for acme.`);
return generateText({
model: openai("gpt-4"),
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: prompt },
],
});
};
return { chat };
}
学んだベストプラクティス
1. 常にマクロを使用する
// ❌ Bad: ランタイム翻訳(抽出されない)
const text = t("Hello world");
// ✅ Good: マクロ(ビルド時に抽出される)
const text = t`Hello world`;
2. 文脈がすべて
翻訳者のためにコメントを追加しましょう:
// i18n: This appears in the pricing table header
<Trans>Monthly</Trans>
// i18n: Button to submit payment form
<button>{t`Subscribe Now`}</button>
Linguiはこれらを翻訳者へのメモとして抽出します。
3. 複数形を適切に扱う
import { Plural } from "@lingui/react/macro";
<Plural
value={count}
one="# credit remaining"
other="# credits remaining"
/>
言語によって複数形のルールは異なります。Linguiがそれを処理してくれます。
4. 日付/数値のフォーマット
Intl APIを使用しましょう:
const date = new Intl.DateTimeFormat(locale, {
dateStyle: "long",
}).format(new Date());
const price = new Intl.NumberFormat(locale, {
style: "currency",
currency: "USD",
}).format(29.99);
5. RTL(右書き)サポート
アラビア語などのために、方向を処理します:
export default function RootLayout({ children }) {
const locale = getLocale();
const direction = locale === "ar" ? "rtl" : "ltr";
return (
<html lang={locale} dir={direction}>
<body>{children}</body>
</html>
);
}
Tailwind設定に追加:
module.exports = {
plugins: [
require('tailwindcss-rtl'),
],
};
方向に対応したクラスを使用:
<div className="ms-4"> {/* margin-start, LTR/RTL両方で機能 */}
デプロイ前のチェックリスト
出荷する前に確認しましょう:
-
pnpm i18nを実行して、すべての翻訳が最新であることを確認 - 本番モードで各ロケールをテスト
- ロケールCookieの永続性を確認
- アラビア語のRTLレイアウトを確認
- ロケールスイッチャーのUXをテスト
- SEO用のhreflangタグを追加
- 必要に応じてロケールベースのルーティングを設定
- LLM翻訳コストを監視
結果
このシステムを導入した結果:
- 17言語をサポート(初期状態で)
- 約850の文字列を自動翻訳
- 完全翻訳にかかった総コストは2〜3ドル
- 新しい文字列を追加した際の更新サイクルは2分
- 手動翻訳作業はゼロ
- 文脈を理解した高品質な翻訳
これと比較してみてください:
- 人間の翻訳者:1単語あたり0.10〜0.30ドル = 1,000ドル以上
- 従来のサービス:依然として高価で遅い
- 手作業:スケールしない
なぜ2026年にこれが重要なのか
いいですか、Webはグローバルです。2026年にもなって英語だけでリリースしているなら、世界の90%を置き去りにしていることになります。
しかし、従来のi18nは苦痛でした。このアプローチなら些細なことになります:
- Trans/tマクロでコードを書く(2秒)
pnpm i18nを実行する(自動)- 世界に出荷する(利益)
Linguiの開発者体験 + LLMによる翻訳の組み合わせは、ゲームチェンジャーです。得られるものは:
- 型安全な翻訳
- ゼロオーバーヘッドのランタイム
- 自動抽出
- 文脈を理解したAI翻訳
- 1言語あたり数円のコスト
- 無限にスケール可能
さらに先へ
レベルアップしたいですか? 以下を試してみてください:
動的コンテンツの翻訳
翻訳をデータベースに保存します:
// packages/db/schema/content.ts
export const blogPosts = pgTable("blog_post", {
id: varchar("id", { length: 255 }).primaryKey(),
titleEn: text("title_en"),
titleZhCn: text("title_zh_cn"),
titleJa: text("title_ja"),
// ... etc
});
保存時に自動翻訳:
import { translateWithLLM } from "@acme/i18n";
export const blogRouter = createTRPCRouter({
create: protectedProcedure
.input(z.object({ title: z.string() }))
.mutation(async ({ input }) => {
// 全言語に翻訳
const translations = await Promise.all(
LOCALES.map(async (locale) => {
const result = await translateWithLLM(
[{ msgid: input.title, msgstr: "" }],
locale
);
return [locale, result[0].msgstr];
})
);
await db.insert(blogPosts).values({
id: generateId(),
titleEn: input.title,
...Object.fromEntries(translations),
});
}),
});
ユーザー提供の翻訳
ユーザーにより良い翻訳を提案してもらいましょう:
export const i18nRouter = createTRPCRouter({
suggestTranslation: publicProcedure
.input(z.object({
msgid: z.string(),
locale: z.string(),
suggestion: z.string(),
}))
.mutation(async ({ input }) => {
await db.insert(translationSuggestions).values(input);
// メンテナーに通知
await sendEmail({
to: "i18n@acme.com",
subject: `New translation suggestion for ${input.locale}`,
body: `"${input.msgid}" → "${input.suggestion}"`,
});
}),
});
翻訳のA/Bテスト
どの翻訳がコンバージョンにつながるかテストします:
const variant = await abTest.getVariant("pricing-cta", locale);
const ctaText = variant === "A"
? t`Start Your Free Trial`
: t`Try acme Free`;
コード
これらはすべて、実際のアプリで稼働しているプロダクションコードです。完全な実装は私たちのモノレポにあります:
t3-acme-app/
├── apps/nextjs/
│ ├── lingui.config.ts
│ ├── src/
│ │ ├── locales/ # コンパイル済みカタログ
│ │ ├── utils/i18n/ # i18nユーティリティ
│ │ └── providers/ # LinguiClientProvider
│ └── script/i18n.ts # 翻訳スクリプト
└── packages/i18n/
└── src/
├── translateWithLLM.ts
├── enhanceTranslations.ts
└── utils.ts
最後に
2026年において、多言語対応のAIアプリを作ることはもはや難しくありません。ツールは揃っています:
- Lingui: 抽出とランタイムのために
- Claude/GPT: 文脈を理解した翻訳のために
- T3 Turbo: 最高のDXのために
翻訳に何千ドルも払うのはやめましょう。アプリを英語だけに限定するのもやめましょう。
世界に向けて作り、速く出荷し、AIを使う。
それが2026年のやり方です。
質問や問題点はありますか? Twitterで私を見つけるか、LinguiドキュメントおよびAI SDKドキュメントをチェックしてください。
さあ、その多言語アプリを出荷しに行きましょう。世界が待っています。 excerpt: 2026年のi18nは、手動翻訳や高価な外注に頼る必要はありません。LinguiとLLM(Claude/GPT)を組み合わせた「2ピース・アーキテクチャ」により、17言語対応のWebアプリを低コストかつ自動で構築する方法を解説します。T3 Turboスタックを用いた実践的なコード例とともに、次世代の国際化フローを紹介します。
シェア

Feng Liu
shenjian8628@gmail.com