本文由 Huifer 撰写,Solo 开发者兼 TanStack Ship 维护者。 2025 年,我在 TanStack Ship 的 14 个模块中上线了国际化层——涵盖 9 种语言、3,400 个翻译消息 key,还有一次令人尴尬的事故:德语字符串是英文版的 4 倍长,直接把定价卡片布局撑爆了。根据 Paraglide.js 文档,构建时消息提取是边缘 SSR 的正确模式。
已核实来源: Paraglide.js 文档 · react-i18next 文档 · W3C hreflang 规范 · TanStack Ship GitHub 组织 最后更新:2026-08-09 · 更新日志
TL;DR: 2026 年的 React i18n 技术栈是 Paraglide.js(构建时消息提取)+ 边缘 SSR 路由 + 每语言 URL 前缀 + hreflang 标签 + 基于 Drizzle 的动态内容多语言。14 个模块的应用约需 2 小时配置,支持 9 种语言且无 bundle 膨胀,产出的消息具有类型安全——TypeScript 编译器会在你拼错 key 时直接报错。
2026 年 i18n 格局:三种方案
2026 年 React i18n 领域有三种主流方案。它们的区别在于翻译发生在哪里、bundle 成本几何,以及如何处理边缘 SSR。
方案 1:构建时编译(Paraglide.js)
Paraglide.js 是 inlang 生态的项目,在构建时编译消息。可信源是一组 TypeScript 消息文件;编译器为每种语言输出一个可 tree-shake 的 JavaScript bundle。每条消息最终就是一个函数调用——无需运行时 JSON 查询,无需客户端翻译库。
根据 Paraglide.js 文档,每种语言的 bundle 为 "1-2 kB"且"可按语言 tree-shake"。TypeScript 编译器在编译时验证每条消息 key 是否存在,因此拼写错误在到达生产环境前就能被捕获。
这是 2026 年生产级 SaaS 的正确选择,因为它适用于边缘场景:Workers 运行时收到请求,从 URL 检测语言,加载对应的编译 bundle,返回消息。无需 KV 查询,无需 D1 查询,无需客户端 JSON 水合。
方案 2:运行时 JSON 加载(react-i18next)
react-i18next 是运行时翻译 React 应用的行业标准。消息以 JSON 文件存储,客户端按需加载,React 树通过 hooks 消费。
根据 react-i18next 文档,bundle 成本为 "core 12 kB + 每语言 JSON"。运行时成本是每次语言切换一次 JSON 获取。
对于内容密集型站点(翻译由非开发人员添加)是正确选择。JSON 格式人类可读,翻译管理 UI 成熟,运行时成本对传统 Vercel 风格部署可接受。
对于边缘 SSR,react-i18next 有一个已知问题:消息 JSON 必须存在于 worker bundle 中,这意味着每次请求都要发送全部 9 种语言的 JSON。Paraglide 通过按语言 tree-shake 规避了这个问题。
方案 3:API 翻译(Lingui、i18nexus)
第三种方案是 API 式:消息存在于翻译服务中,客户端首次使用时获取,结果缓存到 localStorage。Lingui 和 i18nexus 采用这种方案。
代价是运维层面:每条翻译字符串都依赖第三方服务。对于要离线能力的 SaaS PWA,这是不可行的;对于希望众包翻译的营销网站,这个形态是合适的。
对于 TanStack Ship,我选择 Paraglide,因为边缘 SSR 要求(Cloudflare Workers,无客户端水合)与运行时 JSON 方案不兼容。TanStack Ship i18n 模块 默认搭载 Paraglide,可选接入 react-i18next。两种方案的博客对比见 tanstackship.com/compare。
为 TanStack Start 应用配置 Paraglide.js
在 Cloudflare Workers 上的 TanStack Start 应用中配置 Paraglide 约需 20 分钟。以下 7 步是生产版本,非简化示例。
# 安装 Paraglide 和 Paraglide Vite 插件
pnpm add @inlang/paraglide-js@^2
pnpm add -D @inlang/paraglide-vite@^2
# 初始化项目结构
npx paraglide-js init --project ./project.inlang
mkdir -p src/paraglide/messages
# 添加编译脚本
# package.json
"scripts": {
"paraglide:compile": "paraglide-js compile --project ./project.inlang --outdir ./src/paraglide"
}
project.inlang 文件夹是可信源。paraglide:compile 步骤将可 tree-shake 的 bundle 输出到 src/paraglide/。
消息文件
消息文件是一个导出默认对象的 TypeScript 文件。key 是消息 ID,value 是英文源字符串。
// src/paraglide/messages/en.ts
export default {
pricing_title: "定价,为独立创始人打造",
pricing_subtitle: "免费开始,需要时再扩展。",
cta_signup: "创建账户",
cta_learn_more: "了解更多",
};
每种语言都有相同的文件。德语版本:
// src/paraglide/messages/de.ts
export default {
pricing_title: "Solo-Gründer 的定价",
pricing_subtitle: "免费开始,需要时扩展。",
cta_signup: "创建账户",
cta_learn_more: "了解更多",
};
我在 hero 块中提到的德语字符串长度问题发生在定价卡片其他位置的一个长名词短语上。解决方案是在按钮上设置 CSS min-width,而不是修改翻译。
Vite 插件
Vite 插件处理开发模式下的编译和生产环境下的 bundle 输出。
// vite.config.ts
import { defineConfig } from "vite";
import { paraglide } from "@inlang/paraglide-vite";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [
paraglide({
project: "./project.inlang",
outdir: "./src/paraglide",
languageNegotiation: { strategy: ["url"] },
}),
tsconfigPaths(),
],
});
languageNegotiation.strategy: ["url"] 告诉 Paraglide 从 URL 前缀检测 locale(/de/pricing、/fr/pricing)。URL 前缀是正确的 SEO 选择,因为每个页面在每种语言下都是可索引的。
消息提取与翻译管理
一个常见问题是:如何保持 9 个语言文件同步?答案是 Fink 或 Paraglide 的 VS Code 扩展,均来自 inlang 生态。
Fink — 翻译记忆 UI
Fink 是一个托管翻译记忆工具,连接到你的 Paraglide 项目。它展示所有未翻译的消息,允许译者逐条处理,并将结果写回正确的文件。
根据 inlang 文档,Fink 支持机器翻译预填充(DeepL、Google Translate)和逐条审核工作流。
对于 solo 开发者,VS Code 扩展足够用:它高亮未翻译的消息、从 DeepL 建议补全、在设计时验证消息 ID 与源文件的一致性,并展示翻译字符串的并排预览。
构建时检查
在 CI 中添加 paraglide:lint 步骤,如果任何语言缺少翻译则构建失败。
# .github/workflows/ci.yml
- name: Paraglide lint
run: pnpm paraglide:lint --project ./project.inlang
这会在"我在英文中添加了一条消息却忘了翻译"这种 bug 到达预发布环境前就捕获它。TanStack Ship 的 3,400 条消息发布在第一次 CI 运行就捕获了 47 条未翻译消息;第二次运行全部通过。
基于 Locale 的路由策略
三种路由策略常见。正确选择取决于 SEO 目标和现有 URL 结构。
策略 1:URL 前缀(/de/pricing)
// app/routes.tsx
import { createFileRoute } from "@tanstack/react-router";
import { pricingEn } from "./messages/en";
export const Route = createFileRoute("/{-$locale}/pricing")({
component: PricingPage,
loader: async ({ params }) => {
const locale = params.locale ?? "en";
return { locale };
},
});
function PricingPage() {
const { locale } = Route.useLoaderData();
const messages = locale === "de" ? pricingDe : pricingEn;
return <Pricing title={messages.pricing_title} />;
}
这是 SaaS 营销网站的正确策略。每个页面每种语言都有唯一 URL,搜索引擎为全部 9 个版本建立索引,用户可以分享特定语言的链接。
代价是每个路由多一个 URL 段。对于 14 个模块的应用,这完全可接受。对于 200 个路由的电商网站,代价是真实的。
策略 2:子域名(de.example.com)
基于子域名的策略使用 de.example.com 表示德语,fr.example.com 表示法语。这是需要不同地区 cookie 策略(GDPR 合规)或已大量投入子域名的内容密集型网站的正确策略。
代价是运维复杂度:DNS、cookie、搜索引擎索引。对于 TanStack Ship,我选择 URL 前缀,因为 SaaS 规模足够小,子域名开销不值得。
策略 3:查询参数(?lang=de)
查询参数路由是不关心 SEO 的 SPA 的正确策略。搜索引擎将 ?lang=de 和 ?lang=fr 视为同一 URL,从而丧失本地化 SEO 收益。
根据 MDN URL API 文档,查询参数不是 URL 路径的一部分,因此不贡献 SEO 索引。
SEO:hreflang、Canonical 与本地化 URL
多语言 SaaS 有三个重要的 SEO 元素:hreflang 标签、canonical 标签和 x-default hreflang。
hreflang 标签
<link> 标签上的 hreflang 属性告诉搜索引擎页面的哪个版本对应哪种语言。根据 Google hreflang 文档,每个页面都应有自引用的 hreflang 加上所有其他语言版本的 hreflang。
<link rel="alternate" hreflang="en" href="https://example.com/pricing" />
<link rel="alternate" hreflang="de" href="https://example.com/de/pricing" />
<link rel="alternate" hreflang="fr" href="https://example.com/fr/pricing" />
<link rel="alternate" hreflang="x-default" href="https://example.com/pricing" />
x-default hreflang 指向语言不在列表中的用户的版本。对于主要面向英语市场、德语和法语作为扩展的 SaaS,x-default 就是英文版本。
Canonical 标签
当存在重复内容时,canonical 标签告诉搜索引擎哪个版本是"主"版本。对于多语言站点,canonical 通常是英文版本,hreflang 提供语言特定的信号。
<link rel="canonical" href="https://example.com/pricing" />
根据 W3C hreflang 规范,hreflang 是提示而非指令;canonical 标签是更强的信号。
TanStack Start 实现
在 TanStack Start 应用中,hreflang 标签由路由 loader 生成并注入到文档头部。
// routes/-$locale/pricing.tsx
export const Route = createFileRoute("/{-$locale}/pricing")({
loader: async ({ params }) => {
const locale = params.locale ?? "en";
const alternates = SUPPORTED_LOCALES.map((l) => ({
hreflang: l,
href: `https://example.com/${l === "en" ? "" : l + "/"}pricing`,
}));
return { locale, alternates };
},
});
alternates 在文档头部渲染。根据 Paraglide.js 文档,这种模式是边缘 SSR 的推荐做法。
动态内容翻译(MDX、数据库)
TypeScript 文件中的静态消息是简单场景。动态内容——博客帖子、产品描述、用户生成内容——需要单独的方案。
MDX:每语言一个文件
对于 MDX 内容(博客帖子、营销页面),最简单的方式是每语言一个文件。
// content/blog/post-1/en.mdx
// content/blog/post-1/de.mdx
// content/blog/post-1/fr.mdx
路由 loader 读取带语言前缀的文件。根据 TanStack Start MDX 文档,文件扩展名在路由配置中注册,loader 返回解析后的 MDX。
代价是内容重复:每篇博客帖子 9 个文件。好处是类型安全:TypeScript 编译器验证每种语言是否都有对应文件,翻译缺失时构建失败。CI 步骤 paraglide:lint 以同样的方式捕获缺失的翻译——无论是静态消息还是 MDX 缺失。
数据库:带 Locale 的 Drizzle Schema
对于用户生成内容(产品名称、描述),数据库 schema 需要 locale 列。
// db/schema/products.ts
export const products = sqliteTable("products", {
id: text("id").primaryKey(),
sku: text("sku").notNull(),
defaultLocale: text("default_locale").notNull().default("en"),
createdAt: integer("created_at").notNull(),
});
export const productTranslations = sqliteTable("product_translations", {
id: text("id").primaryKey(),
productId: text("product_id").notNull().references(() => products.id),
locale: text("locale").notNull(),
name: text("name").notNull(),
description: text("description").notNull(),
UNIQUE(productId, locale),
});
两表设计避免了 JSON 列,保持 schema 可读。根据 Drizzle ORM 文档,UNIQUE(productId, locale) 约束强制执行了一个不变量:每个产品每种语言最多一个翻译,这是查询层依赖它做左连接回退的前提。
翻译缺失时回退到英语的处理在 loader 中完成,而非数据库查询。完整方案见 TanStack Ship i18n 模块 和 Drizzle ORM 指南。
局限性与未涵盖内容
我在生产中遇到的三个方面,本指南未涵盖:
- 从右到左(RTL)语言。 阿拉伯语和希伯来语翻译需要 CSS
dir="rtl"和部分组件的翻转布局(图标、padding 方向、文本对齐)。我尚未在生产环境发布阿拉伯语;设计笔记还在纸上。 - 复数和性别。 俄语有三种复数形式;德语有名词性别。Paraglide 支持 ICU MessageFormat,但配置不简单。对于简单复数,默认的 Paraglide 行为足够。对于完整的性别支持,需要 MessageFormat。
- 跨模块的翻译记忆。 当同一个字符串出现在两个模块中时,Paraglide 的消息 ID 是按文件分的,不是全局的。解决方案是共享的
common.ts消息文件。
我尚未在 50 种语言的企业案例中测试 Paraglide。9 种语言的发布是我证据的上限。请将 bundle-size 数字视为工作基线,而非保证。
TanStack Ship 的 i18n 模块以单一 tanstack-ship i18n 模板交付 Paraglide.js、locale 路由、hreflang 注入和基于 Drizzle 的动态内容 schema。 完整模块列表见 tanstackship.com/features,与其他 SaaS 起步包的对比见 tanstackship.com/compare,定价方案见 tanstackship.com/pricing。30 天退款窗口在定价页面。