# 在 React Router v7 中集成 typesafe-i18n

- **HTML:** <https://blog.asuk.app/zh/p/typesafe-i18n-react-router-v7>
- **Language:** `zh`
- **Author:** Asuka
- **Published:** `2024-12-13T00:00:00.000Z`
- **Updated:** `2026-10-13T10:42:32.000Z`
- **Tags:** React, TypeScript, i18n

> 一份实用指南，介绍如何在 React Router v7 SSR 应用中添加类型安全的国际化支持，并处理 locale 加载、clientLoader hydration、语言路由和代码生成。

## Content

构建多语言应用时，类型安全经常是事后才补上的问题。很多 i18n 方案依赖字符串 key，例如：

```tsx
t("home.welcome")
```

这种方式简单，但也容易出现：

- key 拼错，运行时才发现。
- 参数漏传或类型不对。
- 某个 locale 缺少翻译。
- IDE 自动补全不够好。
- 重构翻译结构时缺少编译期反馈。

`typesafe-i18n` 的思路不同：它通过生成器根据 base locale 生成类型化的翻译函数。你最终调用的是：

```tsx
LL.home.welcome()
```

如果 key 不存在，TypeScript 会直接报错。

这篇文章介绍如何把 `typesafe-i18n` 集成到 React Router v7 Framework Mode SSR 项目中，并处理几个 SSR 场景下容易踩坑的点：locale 检测、异步加载、`clientLoader.hydrate`、`HydrateFallback` 和语言前缀路由。


## 先说适用性

`typesafe-i18n` 适合：

- 你非常重视翻译 key 和参数的类型安全。
- 翻译内容主要在代码仓库中维护。
- 你希望运行时足够轻量。
- 你的项目使用 TypeScript。
- 你愿意把“生成类型”纳入开发流程。

它不一定适合：

- 翻译主要由非技术团队在 CMS/TMS 中维护。
- 你需要非常成熟的企业级 i18n 生态。
- 你已经深度依赖 i18next、FormatJS、Lingui 等方案。
- 你不想维护生成文件和类型生成脚本。

截至本文更新时，`typesafe-i18n` 的仓库已在 `codingcommons/typesafe-i18n` 下维护；README 仍然强调 fully type-safe、轻量、支持 SSR、异步 locale 加载、plural rules、formatter、无外部依赖等特性。


## 为什么选择 typesafe-i18n

和传统字符串 key 方案相比，它的最大优势是类型安全。

```tsx
// ❌ 传统方式：拼错 key 可能运行时才发现
t("home.welcme")

// ✅ typesafe-i18n：key 不存在会编译报错
LL.home.welcome()
```

带参数的翻译也能类型检查：

```ts
const en = {
  greeting: "Hello, {name:string}!",
  items: "{count:number} {count|item}",
} satisfies BaseTranslation;
```

调用时：

```tsx
LL.greeting({ name: "Asuka" });
LL.items({ count: 5 });

// ❌ name 必须是 string
LL.greeting({ name: 123 });

// ❌ 缺少 name
LL.greeting({});
```

这对长期维护多语言页面很有价值。


## 安装与初始化

安装：

```bash
pnpm add typesafe-i18n
```

你可以使用官方 setup 命令生成初始结构：

```bash
pnpm exec typesafe-i18n --setup
```

也可以手动创建配置文件。

```json
// .typesafe-i18n.json
{
  "$schema": "https://unpkg.com/typesafe-i18n/schema/typesafe-i18n.json",
  "baseLocale": "en",
  "outputPath": "./app/i18n/"
}
```

注意：原来很多示例会把 schema URL 固定到某个版本，例如 `@5.26.2`。这能保证可重复，但文章里不建议硬编码旧版本。实际项目中可以：

- 固定 schema 版本，追求稳定。
- 或使用不带版本的 schema，跟随当前包版本。

二者都可以，关键是团队要明确版本升级策略。


## 创建翻译文件

英文 base locale：

```ts
// app/i18n/en/index.ts
import type { BaseTranslation } from "../i18n-types";

const en = {
  locale: "English",
  nav: {
    home: "Home",
    about: "About",
    search: "Search",
  },
  home: {
    welcome: "Welcome to my blog",
    noPostsYet: "No posts yet. Stay tuned!",
  },
  common: {
    loading: "Loading...",
  },
} satisfies BaseTranslation;

export default en;
```

中文翻译：

```ts
// app/i18n/zh/index.ts
import type { Translation } from "../i18n-types";

const zh = {
  locale: "中文",
  nav: {
    home: "首页",
    about: "关于",
    search: "搜索",
  },
  home: {
    welcome: "欢迎来到我的博客",
    noPostsYet: "暂无文章，敬请期待！",
  },
  common: {
    loading: "加载中...",
  },
} satisfies Translation;

export default zh;
```

然后运行生成器：

```bash
pnpm exec typesafe-i18n --no-watch
```

常见生成文件包括：

```text
app/i18n/
├── en/index.ts
├── zh/index.ts
├── formatters.ts
├── i18n-types.ts
├── i18n-util.ts
└── i18n-util.async.ts
```

如果你新增了翻译 key，却发现 `LL.xxx` 类型不存在，通常是忘了重新运行 generator。


## 建议加到 package scripts

为了避免忘记生成类型，建议加入脚本：

```json
{
  "scripts": {
    "i18n": "typesafe-i18n --no-watch",
    "i18n:watch": "typesafe-i18n",
    "typecheck": "pnpm i18n && react-router typegen && tsc"
  }
}
```

在 CI 中也应该跑一次生成和 typecheck。否则本地新增翻译后没有提交生成文件，线上构建可能才失败。


## 创建 I18n Provider

在 React 中使用时，可以自己封装一个 Provider。

```tsx
// app/components/i18n-provider.tsx
import {
  createContext,
  type PropsWithChildren,
  useContext,
  useMemo,
} from "react";
import type { Locales, TranslationFunctions } from "~/i18n/i18n-types";
import { i18nObject } from "~/i18n/i18n-util";

interface I18nContextValue {
  locale: Locales;
  LL: TranslationFunctions;
}

const I18nContext = createContext<I18nContextValue | null>(null);

export function I18nProvider({
  locale,
  children,
}: PropsWithChildren<{ locale: Locales }>) {
  const value = useMemo<I18nContextValue>(
    () => ({
      locale,
      LL: i18nObject(locale),
    }),
    [locale],
  );

  return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}

export function useI18n() {
  const context = useContext(I18nContext);
  if (!context) {
    throw new Error("useI18n must be used within an I18nProvider");
  }
  return context;
}

export function useTranslation() {
  return useI18n().LL;
}

export function useLocale() {
  return useI18n().locale;
}
```

这里用 `i18nObject(locale)` 从已加载的 locale 创建翻译函数。

关键点是：**在调用 `LL.xxx()` 之前，必须确保对应 locale 已经被 `loadLocaleAsync(locale)` 或 `loadAllLocales()` 加载。** typesafe-i18n README 也明确提到，如果 `LL.key()` 渲染为空字符串，常见原因就是忘了先加载 locale。


## 配置语言路由

React Router v7 Framework Mode 推荐用 `routes.ts` 定义路由。

如果你的应用希望 URL 带语言前缀，例如：

```text
/en
/zh
/en/about
/zh/about
```

可以用 `prefix` 包一层：

```ts
// app/routes.ts
import type { RouteConfig } from "@react-router/dev/routes";
import { prefix } from "@react-router/dev/routes";
import { flatRoutes } from "@react-router/fs-routes";

export default [
  ...prefix(":lang", await flatRoutes()),
] satisfies RouteConfig;
```

注意两点：

1. `flatRoutes()` 是异步的，需要 `await`。
2. `prefix()` 只是给 path 加前缀，不会自动创建一个 layout route。

如果你需要一个统一的 locale layout，更推荐显式定义：

```ts
// app/routes.ts
import type { RouteConfig } from "@react-router/dev/routes";
import { route } from "@react-router/dev/routes";

export default [
  route(":lang", "routes/$lang.tsx", [
    route("", "routes/$lang._index.tsx", { index: true }),
    route("about", "routes/$lang.about.tsx"),
  ]),
] satisfies RouteConfig;
```

实际项目中可以按文件路由和手写路由的偏好选择。重点是要有一个稳定的 layout route 负责加载 locale 和挂载 `I18nProvider`。


## Locale 辅助函数

```ts
// app/lib/locale.ts
import type { Locales } from "~/i18n/i18n-types";
import { baseLocale, isLocale } from "~/i18n/i18n-util";

export function getLocaleFromParams(
  params: Record<string, string | undefined>,
): Locales {
  const lang = params.lang;
  if (lang && isLocale(lang)) {
    return lang;
  }
  return baseLocale;
}

export function localePath(locale: Locales, path: string): string {
  const normalizedPath = path.startsWith("/") ? path : `/${path}`;
  return `/${locale}${normalizedPath}`;
}
```

真实项目还可以继续扩展：

- 从 cookie 读取用户偏好。
- 从 `Accept-Language` 选择默认语言。
- 对非法 locale 做 redirect。
- 对 canonical URL 做 SEO 处理。
- 输出 `hreflang`。


## 在 layout loader 中加载 locale

React Router 文档说明：`loader` 在服务端渲染时用于初始页面加载，`clientLoader` 可用于客户端数据加载；`loader` 和 `clientLoader` 也可以同时使用，`clientLoader.hydrate = true` 可以强制 client loader 在 hydration 期间、页面渲染前运行。

对于 i18n，最重要的是：服务端和客户端 hydration 前都要能拿到同一个 locale 的翻译函数。

```tsx
// app/routes/$lang.tsx
import { Outlet } from "react-router";
import { I18nProvider } from "~/components/i18n-provider";
import { loadLocaleAsync } from "~/i18n/i18n-util.async";
import { getLocaleFromParams } from "~/lib/locale";
import type { Route } from "./+types/$lang";

export const loader = async ({ params }: Route.LoaderArgs) => {
  const locale = getLocaleFromParams(params);
  await loadLocaleAsync(locale);
  return { locale };
};

export const clientLoader = async ({
  params,
  serverLoader,
}: Route.ClientLoaderArgs) => {
  const serverData = await serverLoader<typeof loader>();
  const locale = getLocaleFromParams(params);
  await loadLocaleAsync(locale);
  return serverData;
};

clientLoader.hydrate = true as const;

export function HydrateFallback() {
  return null;
}

export default function LocaleLayout({ loaderData }: Route.ComponentProps) {
  const { locale } = loaderData;

  return (
    <I18nProvider locale={locale}>
      <Outlet />
    </I18nProvider>
  );
}
```

这里有几个关键点：

1. `loader` 负责 SSR 时加载 locale。
2. `clientLoader` 负责 hydration 前在客户端也加载 locale。
3. `serverLoader()` 复用服务端 loader 数据，避免重新实现一套数据逻辑。
4. `clientLoader.hydrate = true` 让 client loader 在 hydration 阶段也执行。
5. `HydrateFallback` 可以返回 `null`，避免 hydration 前显示一段和服务端 HTML 不一致的 loading UI。

React Router 官方文档也提醒：当你强制 client loader 在 hydration 期间运行时，应该提供 `HydrateFallback` 用来显示 fallback UI。对于 i18n 场景，如果服务端 HTML 已经是完整翻译内容，返回 `null` 往往比显示 loading 更不容易造成闪烁。


## 为什么不能只在 loader 里加载

很多人会问：既然 SSR 时 `loader` 已经 `loadLocaleAsync(locale)` 了，为什么客户端还要再加载？

原因是服务端和客户端是两个运行环境。

服务端加载了 locale，只能保证服务端渲染 HTML 时可用；浏览器 hydration 时也需要自己的 JS 模块和翻译函数。如果客户端没有加载对应 locale，调用 `LL.home.welcome()` 可能得到空字符串或不完整结果。

所以 SSR i18n 的基本原则是：

```text
server render 前加载 locale
+
client hydrate 前加载同一个 locale
```

否则就容易出现：

- 服务端 HTML 是中文，客户端 hydration 后变成空字符串。
- 服务端和客户端文本不一致，引发 hydration mismatch。
- 首屏闪一下默认语言再切换到目标语言。


## 在组件中使用翻译

```tsx
// app/routes/$lang._index.tsx
import { useI18n } from "~/components/i18n-provider";

export default function HomePage() {
  const { LL } = useI18n();

  return (
    <main>
      <h1>{LL.home.welcome()}</h1>
      <p>{LL.home.noPostsYet()}</p>
    </main>
  );
}
```

自动补全会类似：

```text
LL.
  ├── locale()
  ├── nav.
  │   ├── home()
  │   ├── about()
  │   └── search()
  ├── home.
  │   ├── welcome()
  │   └── noPostsYet()
  └── common.
      └── loading()
```


## 语言切换器

```tsx
// app/components/locale-switcher.tsx
import { useLocation, useNavigate } from "react-router";
import type { Locales } from "~/i18n/i18n-types";
import { locales } from "~/i18n/i18n-util";
import { useLocale } from "./i18n-provider";

const LABELS: Record<Locales, string> = {
  en: "English",
  zh: "中文",
};

export function LocaleSwitcher() {
  const currentLocale = useLocale();
  const navigate = useNavigate();
  const location = useLocation();

  const switchLocale = (newLocale: Locales) => {
    const newPath = location.pathname.replace(
      new RegExp(`^/${currentLocale}(/|$)`),
      `/${newLocale}$1`,
    );

    navigate(newPath + location.search + location.hash);
  };

  return (
    <select
      value={currentLocale}
      onChange={(event) => switchLocale(event.target.value as Locales)}
    >
      {locales.map((locale) => (
        <option key={locale} value={locale}>
          {LABELS[locale]}
        </option>
      ))}
    </select>
  );
}
```

实际项目里要注意：

- 当前路径不一定有 locale 前缀。
- 切换语言时可能需要保留 query 和 hash。
- 不同语言可能不是同一套 slug。
- 文章、商品、文档等内容型页面可能需要 locale-aware slug mapping。


## SEO 和 HTML lang

多语言 SSR 项目还应该设置 HTML lang。

在 React Router 的 `root.tsx` 或 locale layout 中，可以把 locale 放进 loader data，然后用于 `<html lang={locale}>`。

如果项目需要搜索引擎优化，还应该输出：

- canonical URL。
- alternate hreflang。
- 当前页面的 localized title / description。
- fallback locale 策略。

这部分和 typesafe-i18n 本身无关，但和 SSR i18n 密切相关。


## 常见问题


### 1. `LL.xxx()` 渲染为空字符串

通常是忘了先加载 locale。

检查：

```ts
await loadLocaleAsync(locale);
```

是否在服务端 loader 和客户端 hydration 前都执行了。


### 2. 新增翻译 key 后类型不存在

通常是忘了重新运行 generator。

```bash
pnpm exec typesafe-i18n --no-watch
```


### 3. hydration mismatch

常见原因：

- 服务端和客户端 locale 不一致。
- 客户端 hydration 前没有加载 locale。
- `HydrateFallback` 显示了和 SSR HTML 不同的内容。
- locale 来自 cookie / header / URL，但客户端计算方式不同。


### 4. `prefix(':lang', await flatRoutes())` 没有 layout

这是正常的。`prefix` 只改 path，不创建新的 layout route。

如果需要统一加载 locale，显式创建 `:lang` layout route 更清晰。


## 小结

在 React Router v7 SSR 应用中集成 `typesafe-i18n`，关键不是 Provider 写法，而是加载时机。

你需要保证：

1. base locale 定义完整，其他 locale 使用 `Translation` 检查结构。
2. 每次修改翻译后运行 generator。
3. SSR loader 中加载当前 locale。
4. client hydration 前也加载同一个 locale。
5. `clientLoader.hydrate = true` 时提供合适的 `HydrateFallback`。
6. 语言前缀路由最好有一个明确的 locale layout。
7. 语言切换、SEO、HTML lang、hreflang 需要单独设计。

`typesafe-i18n` 的价值在于把翻译 key、参数和 locale 结构错误提前暴露到编译期。对于 TypeScript 项目来说，这能显著降低多语言长期维护成本。


## 参考资料

- [typesafe-i18n GitHub](https://github.com/codingcommons/typesafe-i18n)
- [React Router Data Loading](https://reactrouter.com/start/framework/data-loading)
- [React Router Routing](https://reactrouter.com/start/framework/routing)
