<!-- source: openspec/current/i18n.md -->

---
title: i18n
status: stable
owner: pavel
audience: [pavel, ai, engineer]
tldr: Thin wrapper around next-intl — routing config, navigation helpers, and per-locale message catalogs (en, ru, ro). Consumed by apps/web.
---
# i18n — capability spec

> Thin wrapper around `next-intl` providing routing config, navigation helpers, and per-locale message catalogs (`en`, `ru`, `ro`). Leaf in the package graph. Consumed by `apps/web`; `apps/studio` does not consume it today.

---

## Where i18n lives

- Source: `packages/i18n/`
- Package name: `@rapoport-studio/i18n`
- Version: `0.0.0`, `private: true`
- Entry surface: five subpath exports — `.` → `src/index.ts` (barrel), `./routing` → `src/routing.ts`, `./navigation` → `src/navigation.ts`, `./config` → `src/config.ts`, `./messages/*` → `messages/*` (raw JSON catalogs at the package root, not under `src/`)
- Build: none. Consumers compile `.ts` directly via Next.js / vitest / app build.
- Scripts: `lint` (eslint src), `typecheck` (tsc --noEmit), `test` (vitest run).
- Test runtime: vitest with `environment: "node"`.

---

## Architecture

```
packages/i18n/
├── src/
│   ├── index.ts          Barrel — re-exports locales, defaultLocale, Locale, routing
│   ├── config.ts         Locale list, default locale, Locale type
│   ├── routing.ts        next-intl routing config (locales, defaultLocale, localePrefix: "always")
│   ├── navigation.ts     Locale-aware Link, redirect, useRouter, usePathname (createNavigation(routing))
│   └── __tests__/
│       └── smoke.test.ts (10 unit tests: config shape + per-locale catalog parity)
└── messages/
    ├── en/
    │   ├── common.json   Chrome strings (nav, footer, locale switcher, status)
    │   └── web.json      Marketing-site copy (home blocks, services, work, lab, contact)
    ├── ru/
    │   ├── common.json
    │   └── web.json
    └── ro/
        ├── common.json
        └── web.json
```

The package is a thin layer over `next-intl`: it wraps the framework's primitives so apps can import locale-aware components and hooks from a single workspace package without re-stating routing config in every consumer.

Three surfaces:

- **Configuration** — `config.ts` exports `locales`, `defaultLocale`, `Locale` type. `routing.ts` builds a `next-intl` `defineRouting(...)` instance (`localePrefix: "always"`) consumed by middleware and request config in apps.
- **Navigation** — `navigation.ts` invokes `createNavigation(routing)` from `next-intl/navigation` and re-exports the resulting `Link`, `redirect`, `useRouter`, `usePathname` pre-bound to this package's routing. Consumers import these from `@rapoport-studio/i18n/navigation` instead of `next-intl/navigation` directly.
- **Message catalogs** — JSON files at `messages/<locale>/<namespace>.json` (package root, not under `src/`); the live set is `{en,ru,ro} × {common,entities,intake,primer,ui,web}`. The `./messages/*` subpath export lets the consumer (`apps/web/src/i18n/request.ts`) `import("@rapoport-studio/i18n/messages/<locale>/<namespace>.json")` directly.

There is no `request.ts` inside this package. Per-request `next-intl` config (which catalog to load for which locale) lives in the consumer at `apps/web/src/i18n/request.ts`. The package ships routing config + navigation helpers + raw catalogs; the consumer wires those into `getRequestConfig`.

---

## Locales

| Locale | Code | Default | Direction | Notes |
|---|---|---|---|---|
| English | `en` | yes | LTR | Primary |
| Romanian | `ro` | no | LTR | |
| Russian | `ru` | no | LTR | |

`localePrefix: "always"` (set in `src/routing.ts`) — every locale prefixes its paths, including the default. `en` is served at `/en/services`, `/en/work`; the unprefixed forms `/services`, `/work` redirect to `/en/...`. `ro` and `ru` use `/ro/...`, `/ru/...`. The "always" mode keeps the URL unambiguous about which locale is being rendered, at the cost of one extra path segment for the primary locale.

All current locales are LTR. Hebrew (`he`) was shipped 2026-05-16 (`harden-cookie-compliance-followup`) as an RTL locale and removed 2026-05-16 — the IL ICP trust-signal argument did not justify maintaining AI-drafted chrome ahead of a native-speaker review pass. The `[locale]` root layout resolves `<html dir="ltr">`; RTL support can be reintroduced behind a future change proposal.

---

## Public surface

The package exposes four code subpaths plus a raw-asset subpath. Surface is partitioned across them by import-time concerns: the root barrel is config-only (safe to import anywhere); navigation helpers live behind `./navigation` because `createNavigation` produces React/`next-intl` runtime values; routing config is also reachable directly via `./routing` for `next-intl` middleware/server wiring; raw JSON catalogs are reachable at `./messages/*`.

### `.` — root barrel (`src/index.ts`)

| Export | Source | Type |
|---|---|---|
| `locales` | `config.ts` | `readonly ["en", "ru", "ro"]` |
| `defaultLocale` | `config.ts` | `"en"` |
| `Locale` | `config.ts` | `"en" \| "ru" \| "ro"` (type) |
| `routing` | `routing.ts` | `next-intl` routing instance (`defineRouting`) |

### `./routing` (`src/routing.ts`)

Direct access to the same `routing` instance the barrel re-exports. Apps wiring `next-intl` middleware import from this subpath: `import { routing } from "@rapoport-studio/i18n/routing";`. Equivalent to `@rapoport-studio/i18n` for `routing`; provided as a separate subpath to match how `next-intl`'s middleware is conventionally wired.

### `./navigation` (`src/navigation.ts`)

| Export | Type |
|---|---|
| `Link` | locale-aware `<Link>` component |
| `redirect` | locale-aware `redirect()` |
| `useRouter` | locale-aware `useRouter()` hook |
| `usePathname` | locale-aware `usePathname()` hook |

Generated by `createNavigation(routing)`. **Not re-exported from the root barrel.** Consumers must import from this subpath: `import { Link } from "@rapoport-studio/i18n/navigation";`.

### `./config` (`src/config.ts`)

Direct access to `locales`, `defaultLocale`, `Locale`. Equivalent to importing the same names from `.`; provided as a separate subpath for consumers who want config without pulling in the routing instance.

### `./messages/*`

Raw JSON catalogs at the package root. Consumers reach a specific file via `import("@rapoport-studio/i18n/messages/en/web.json")`. Used by the consumer's `getRequestConfig` to load per-locale catalogs at request time (see `apps/web/src/i18n/request.ts`).

---

## Message catalogs

Catalogs live at `packages/i18n/messages/<locale>/<namespace>.json` — 18 files: `{en,ru,ro} × {common,entities,intake,primer,ui,web}` (three locales × six namespaces). Line counts vary per namespace and grow as features land; the cross-locale key-parity contract below is the durable invariant, not any line total. `common.json` holds chrome strings (nav, footer, locale switcher, status copy); `web.json` holds marketing-site copy (home blocks, services, work, lab, contact); `entities`, `intake`, `primer`, and `ui` carry their respective feature-scoped strings. The namespace split is deliberate: chrome is loaded on every locale-aware route, feature copy can stay route- or feature-specific.

Catalog loading is the **consumer's** responsibility. The package does not ship a `request.ts`. `apps/web/src/i18n/request.ts` is the canonical example — it picks the catalog set per locale and merges the two namespaces:

```ts
// apps/web/src/i18n/request.ts (consumer, not packaged)
const messageImports = {
  en: () => Promise.all([
    import("@rapoport-studio/i18n/messages/en/common.json"),
    import("@rapoport-studio/i18n/messages/en/web.json"),
  ]),
  ru: () => Promise.all([
    import("@rapoport-studio/i18n/messages/ru/common.json"),
    import("@rapoport-studio/i18n/messages/ru/web.json"),
  ]),
  ro: () => Promise.all([
    import("@rapoport-studio/i18n/messages/ro/common.json"),
    import("@rapoport-studio/i18n/messages/ro/web.json"),
  ]),
} as const;

export default getRequestConfig(async ({ requestLocale }) => {
  const requested = await requestLocale;
  const locale = /* validate against routing.locales, fall back to defaultLocale */;
  const [common, web] = await messageImports[locale]();
  return { locale, messages: { common: common.default, web: web.default } };
});
```

Why namespacing? `next-intl` routes `t("home.hero.eyebrow")` to whichever namespace key resolves it. Separating `common` from `web` keeps chrome strings reusable across consumers (today `apps/studio` doesn't consume i18n, but a future consumer could load `common` only). The dynamic import means each catalog is only fetched at request time for the locale the user is browsing.

### Convention for filling catalogs

Per consuming feature: when `apps/web/[locale]/services/page.tsx` needs `t("services.heading")`, the implementer adds `services.heading` to the relevant namespace (`web.json` for marketing copy, `common.json` for chrome) under all three locales (`en`, `ru`, `ro`) at the same time. The package itself does **not** maintain a master key list; cross-locale parity is enforced by the smoke test (`__tests__/smoke.test.ts` — every key in `en/<file>.json` must exist in `ru/` and `ro/`).

---

## Categories — deliberate divergence

The package intentionally has no `categories` export and no `Category` type. Earlier iterations of the package shipped a fixed category list specific to a personal-portfolio use case (`engineering`, `writing`, `notes`); the studio brand has different content needs that haven't been modeled yet, so the surface was removed rather than carried forward as misleading scaffolding.

If the studio site grows a category-based content system later, it lands as a new export with a fresh shape — not as a re-introduction of the old list.

---

## Test coverage

`packages/i18n/src/__tests__/smoke.test.ts` — 10 tests, vitest, `environment: "node"`:

| Area | Coverage |
|---|---|
| `config.ts` shape | `locales` equals `["en", "ru", "ro"]`; `defaultLocale` is `"en"` |
| Catalog files | Each `messages/<locale>/<namespace>.json` exists and parses as valid JSON, for every locale × namespace in `{en,ru,ro} × {common,entities,intake,primer,ui,web}` |
| Cross-locale parity | Every key in each `en/<namespace>.json` exists in the matching `ru/` and `ro/` file |

No integration tests (no actual rendering inside `next-intl`'s middleware). No tests of message-key resolution against rendered components. The cross-locale parity check is the safety net against drift: when a key is added to `en/<file>.json` it must be added to `ru/` and `ro/` at the same time, or the test fails.

---

## Conventions

### Locale codes

ISO 639-1 two-letter codes (`en`, `ru`, `ro`). No region suffix (e.g., not `en-US`). When a region distinction matters (Russian-Romania vs Russian-Russia, say), the convention is to introduce a new locale code (`ru-md`) at that point — not to retrofit region suffixes everywhere.

### Locale list is a `readonly` const tuple

`config.ts` declares `locales` as `["en", "ru", "ro"] as const`. The `Locale` union type is derived from this tuple. Adding a locale: append to the tuple, add the full `messages/<locale>/<namespace>.json` catalog set (one file per namespace). The `as const` ensures TypeScript narrows `Locale` to the literal members, not `string`.

### Navigation helpers are wrappers, not re-implementations

`navigation.ts` calls `createNavigation(routing)` from `next-intl/navigation`, pre-bound to this package's `routing` instance. Consumers should never import directly from `next-intl/navigation` — always go through `@rapoport-studio/i18n/navigation`. This keeps the locale + routing config a single source of truth.

### Catalogs ship populated, with parity enforced

Catalogs at `messages/<locale>/<namespace>.json` are populated as features land. Cross-locale key parity is enforced by `__tests__/smoke.test.ts`: every key in `en/<file>.json` must exist in `ru/<file>.json` and `ro/<file>.json`. The test fails on drift; the package itself does not pre-populate keys, synthesize defaults, or warn on missing keys at build time. If a feature lands a key in only one locale, the smoke test catches it.

### Subpath exports are intentional, not a barrel-only package

`packages/i18n/src/index.ts` re-exports config + the routing instance. **Navigation helpers are not in the root barrel** — `Link`, `redirect`, `useRouter`, `usePathname` are reachable only at `@rapoport-studio/i18n/navigation`. This is deliberate: the navigation helpers carry React/`next-intl` runtime weight (created via `createNavigation`); keeping them on a dedicated subpath signals that import (and bundles them only when actually used). Routing config and the raw catalogs each have their own subpath as well (`./routing`, `./config`, `./messages/*`).

---

## Dependencies

### Runtime

| Package | Version | Used by |
|---|---|---|
| `next-intl` | `^4.9.0` | `routing.ts` (`defineRouting`), `navigation.ts` (`createNavigation`); the consumer's `request.ts` (in `apps/web`) imports `getRequestConfig` from `next-intl/server` |

### Dev

| Package | Version | Used by |
|---|---|---|
| `@types/node` | `^20` | type checking |
| `typescript` | `^5` | `tsc --noEmit` |
| `vitest` | `^4` | `__tests__/smoke.test.ts` |

`next` and `react` are not declared in this package's `devDependencies` today (only consumed transitively through `next-intl`'s peer deps, satisfied by the consumer apps). If the package gains direct usage of Next.js or React types, add them as devDependencies.

### Internal

None. Leaf in the package graph.

---

## Boundaries

Enforced by `eslint.config.mjs`. `i18n` is a leaf — may not import any `@rapoport-studio/*` package.

- i18n **MAY NOT** import from `@rapoport-studio/{ui,db,config,auth,forge,spec,muse}`
- i18n **MAY** import from `next-intl`, `next`, `react`
- i18n **MAY NOT** contain business logic, route handlers, or app-specific UI

---

## Consumers

| Consumer | What it imports | Notes |
|---|---|---|
| `apps/web` | `routing` (from `./routing`); `Link`, `redirect`, `useRouter`, `usePathname` (from `./navigation`); `locales`, `defaultLocale`, `Locale` (from `.`); raw catalogs (from `./messages/*`) | Marketing site is the only locale-aware app today; `src/i18n/request.ts` is the consumer-side `getRequestConfig` wiring |
| `apps/studio` | (none) | Internal workspace, single-locale by design (Pavel's tool) |

Single-app consumption matches `app.rapoport.studio`'s positioning as a single-user (Pavel) internal workspace.

---

## Extension

### Adding a new locale

1. Append the new code to the `locales` tuple in `src/config.ts`.
2. Add `messages/<locale>/common.json` and `messages/<locale>/web.json` at the package root. Start by copying the `en/` files; the parity smoke test will fail if you ship the new locale with missing keys.
3. Extend the consumer's `messageImports` map (e.g., `apps/web/src/i18n/request.ts`) to load the new locale's two catalogs.
4. Update this spec's Locales table.
5. The `Locale` union type updates automatically via `as const` inference.
6. Verify `apps/web` picks up the new locale (the consumer's middleware/`getRequestConfig` reads `routing` from this package).

### Adding a translation

1. Decide the key path (e.g., `services.heading`) and the namespace it belongs in (`web` for marketing copy, `common` for chrome).
2. Add the key to all three locale variants of that namespace (`en/<ns>.json`, `ru/<ns>.json`, `ro/<ns>.json`) at the same time — even if the value is identical or you only have one translation ready (use the English text as a placeholder). The smoke test rejects partial additions.
3. Use `t("services.heading")` in the consuming feature, scoped via `useTranslations("web")` (or whichever namespace).

### Adding a new navigation helper

Out of band — would require deciding whether to add framework-specific code to a leaf package. Default: keep the surface limited to what `next-intl` exposes. If a project needs locale-aware data fetching helpers, those probably belong in the consuming app, not in `i18n`.

---

## Open questions

- **Master key list / build-time missing-key detection.** The smoke test enforces cross-locale parity but does not enforce that catalog keys cover every key referenced by `t(...)` calls in the consuming code. A `t("home.hero.eyebrow")` typo in a component would silently fall back to the key string at runtime. Worth wiring `next-intl`'s typed messages or a key-extraction step once content stabilizes.
- **Workspace app and locales.** `apps/studio` is single-locale (Pavel's tool). If a future expansion brings collaborators in different languages, does the workspace app start consuming `i18n`, or does it stay single-locale and switch only the public-facing parts?
- **`zh`, `de`, etc.** No plan to add locales beyond `en`/`ru`/`ro`. Adding more is a content-strategy decision, not a technical one — flagged here so future readers know the bottleneck is brand strategy, not infrastructure.
- **Server vs client locale resolution.** `next-intl` resolves the locale per request via middleware (server side) and via `useLocale()` hook (client side). Both paths read from the same `routing` config (`localePrefix: "always"`), so they agree by construction — but no explicit test asserts agreement. Could be added if a divergence ever shows up.
- **`localePrefix: "always"` vs `"as-needed"`.** Current setting prefixes the default locale (`/en/...`). Switching to `"as-needed"` would shorten URLs for the primary locale but introduces ambiguity in middleware about whether a request is unprefixed-default or unrecognized. The current decision is to keep "always" until URL ergonomics for `en` becomes a real complaint.
- **i18n in WhatsApp/Telegram bot replies.** Bot messages today are not locale-aware (sent in whatever language the agent prompt uses). If clients in different language regions adopt the bot, message templating may need to consume `i18n` — but the bot is outside `apps/*` (lives in n8n/agent code), so the integration shape is undefined.

---

## Related capabilities

- `web.md` — primary consumer (marketing locale routing)
- `studio-workspace/README.md` — potential future consumer; today single-locale
- `platform.md` — overall context; locale list defined; multi-language strategy noted as English first, Romanian and Russian later

---

## /discovery locale stubs (applied 2026-05-04 from change `add-client-onboarding`)

The new `/discovery` page uses `generateStaticParams()` returning `routing.locales.map(locale => ({ locale }))` — matching the pattern already used by `/contact` and `/services`. At launch, all three locales (`en`, `ru`, `ro`) render identical English copy (placeholder for non-English). `generateMetadata()` emits `alternates.canonical` using the locale prefix. Root layout already handles `alternates.languages` hreflang for all routes; no per-page override needed.

The `IntakeForm` client component uses `useLocale()` to include `locale` in the intake payload, ensuring the studio inbox can surface which locale the lead submitted from — useful for routing decisions if non-English leads start arriving.


## `intake.json` namespace (applied 2026-05-10 from change `redesign-intake-form`)

New i18n namespace `intake.json` shipped under all three locales (`en`, `ru`, `ro`). Covers `<IntakeForm />` primitive copy: block legends, field labels, helper text, error messages, restore-prompt strings. Schema matches `packages/ui/src/components/intake-form/schema.ts` field-for-field; consumed via `useTranslations("intake")` (or via `i18nNamespace` prop on the primitive).

**Locale parity:** extended `packages/i18n/__tests__/` to assert every leaf key in `en/intake.json` exists in `ru/intake.json` and `ro/intake.json` (recursive).

**Plural forms:** RU + RO use ICU `{count, plural, ...}` for character-count helpers (e.g., brief minimum-length feedback).

**Page chrome stays in `web.json`:** `intake.json` is form-only — page eyebrows, headings, ledes for `/start-project` and `/discovery` continue to live in `web.json` per existing convention. Strings authored in EN, professionally translated to RU + RO. The `/start-project` page chrome plus its narrative intro section (paths + "what happens next" blocks) live under the `web.json` `startProject.*` key.

## `common.marketing.philosophy` key group (applied 2026-05-24, RAP-1211)

New translation-key group added to `packages/i18n/messages/{en,ru,ro}/common.json` under `marketing.philosophy.{principles,method,openspec}`.

| Key | Purpose |
|---|---|
| `marketing.philosophy.{page}.nav` | Nav link label in `<Nav />` philosophy group |
| `marketing.philosophy.{page}.title` | Page `<h1>` title in `<PhilosophyTemplate />` |
| `marketing.philosophy.{page}.blurb` | Blurb under the title (≤ 200 chars) |
| `marketing.philosophy.{page}.sidebar` | Sidebar TOC label in `<PhilosophyTemplate />` |

Long-form body copy (principle text, engagement-shape narratives, OpenSpec corpus description) stays English-only per locale strategy — only chrome strings above are translated.

`web.meta.{principles,method,openspec}.title` added to `packages/i18n/messages/{en,ru,ro}/web.json` for `generateMetadata()`.

Smoke test parity: the `Object.keys()` top-level check for `common.json` covers the new `marketing` key across all three locales.

---

## Conversion surface i18n keys (applied 2026-05-24 from add-per-service-pages-and-conversion)

### `common.json` — `marketing.*` key group

New key group added to `packages/i18n/messages/{en,ru,ro}/common.json` under `marketing`:

```
marketing.services.<slug>.{title, blurb, indicativeRange, ctaLabel}
  Slugs: discovery | migration | modular | platform | subscription
  Long-form content (blurb) is English-only across all locales (long-form locale strategy).

marketing.decisionRouter.{headline, prompts.{unsure, migrate, build}}
  Used by <DecisionRouter /> on /services.

marketing.intakeForm.steps.{step1, step2, step3}
  Used by <FormProgress /> labels in the multi-step <IntakeForm />.
```

All three locales (en, ru, ro) carry the full key set. DecisionRouter prompts and IntakeForm step labels are translated; service blurbs are English-only.

### `web.json` — `home.heroC.*` key group

New key group added to `packages/i18n/messages/{en,ru,ro}/web.json` under `home.heroC`:

```
home.heroC.{line1, line2, line3, subhead, ctaPrimary, ctaSecondary}
  Used by <HeroC /> on the homepage.
```

All three locales carry translated values. The subhead is ≤160 characters in all locales.

Cross-ref: `openspec/archive/2026-05-24-add-per-service-pages-and-conversion/`. Linear: [RAP-1212](https://linear.app/rapoport-studio-slr/issue/RAP-1212).

### `common.json` — `nav.*` key additions (applied 2026-05-24 from change refine-nav-locale-and-auth)

Three new keys added to `packages/i18n/messages/{en,ru,ro}/common.json` under `nav`:

- `nav.openApp` — authenticated-state CTA label rendered by `<NavAuthLink/>` when the Supabase auth cookie is present. EN `Open app →` / RU `Открыть приложение →` / RO `Deschide aplicația →`.
- `nav.localeSwitcherLabel` — a11y label for the `<LocaleSwitcher/>` container (`<nav aria-label=…>`). EN `Language` / RU `Язык` / RO `Limba`.
- `nav.switchToLocale` — ICU template producing each glyph's `aria-label`. EN `Switch to {language}` / RU `Переключить на {language}` / RO `Comută la {language}`.

Native-language tooltip strings (`English`, `Română`, `Русский`) live as an in-component `NATIVE_NAMES` constant in `apps/web/src/components/locale-switcher.tsx`, not in `common.json` — by definition the native name of each language is identical across all locale catalogues.

Cross-ref: `openspec/archive/2026-05-24-refine-nav-locale-and-auth/`. Linear: [RAP-1215](https://linear.app/rapoport-studio-slr/issue/RAP-1215).

### `common.json` — `nav.*` IA additions (applied 2026-05-28 from change redesign-primary-nav)

The primary-nav IA flip (Services ▾ · Work · Lab · About ▾) introduced new `nav.*` keys in `packages/i18n/messages/{en,ru,ro}/common.json`:

- **Trigger labels.** `nav.services`, `nav.about` — labels for the two dropdown triggers.
- **Direct-link labels.** `nav.work`, `nav.lab` — pre-existing keys, retained.
- **Panel item labels.** 12 keys for the Services panel (`servicesOverview`, `discovery`, `migration`, `modular`, `platform`, `subscription` under `/engagements`; `founders`, `specialists` under `/for`) and the About panel (`principles`, `method`, `openspec`, `network`).
- **Panel item descriptors.** A `*Sub` companion key for each panel item (e.g. `discoverySub`, `principlesSub`) carrying the one-line eyebrow descriptor that renders under the item title.
- **Status pills.** `nav.status.live` / `nav.status.progressing` / `nav.status.idle` / `nav.status.archived` — labels for the lifecycle pill rendered next to items whose nav-config entry carries `status`.
- **Featured-block bodies.** `nav.featured.services` and `nav.featured.about` — the eyebrow + body content for the featured slot in each dropdown panel.

The retired `nav.network` and `nav.philosophy` top-level keys (`<Nav />` no longer renders flat-row links to those pages — `/network` is now reachable from the About panel; the `Philosophy` row has been retired entirely in favour of `Principles` + `Method` + `OpenSpec` under About) MAY remain in the catalogues until the next i18n sweep cleans them up; they're unused by current `nav.tsx`.

Source-of-truth mapping between `nav.*` keys and `NAV_ENTRIES` lives in `apps/web/src/components/nav-config.ts` and `apps/web/src/components/nav.tsx`. The i18n contract is parity across all three locales (the `tests/locale-parity.test.ts` suite enforces this).

Cross-ref: `openspec/archive/2026-05-28-redesign-primary-nav/`. Linear: [RAP-1216](https://linear.app/rapoport-studio-slr/issue/RAP-1216).
