Skip to content

Commit 6a18c16

Browse files
justin808claude
andauthored
docs: improve react-intl documentation for React Server Components (#3085)
## Summary - Document `createIntl` from `@formatjs/intl` as the recommended approach for i18n in Server Components — a context-free API with full interpolation, pluralization, and date/number formatting - Add limitations section for the plain `messages['key']` lookup pattern (only works for pre-formatted strings, renders ICU `{variable}` placeholders as literal text) - Document Rails pre-formatting (`I18n.t('key', name: value)`) as a valid alternative for Server Components - Add comparison table reconciling build-time (`config.i18n_dir`) vs controller-props i18n approaches - Fix vague i18next claim in `rsc-third-party-libs.md` with concrete per-request `createInstance()` guidance - Add RSC cross-reference section to the build-time i18n docs - Update compatibility decision matrix with `createIntl` recommendation Fixes #3081 ## Test plan - [ ] Verify all internal markdown links resolve correctly - [ ] Review `createIntl` code examples for accuracy against FormatJS docs - [ ] Confirm build-time vs controller-props comparison table is accurate 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Low risk: documentation-only updates that don’t change runtime code, limited to guidance/examples for i18n usage in RSC. > > **Overview** > Adds **React Server Components i18n guidance** across the docs, recommending `react-intl`’s context-free `createIntl` API for Server Components and documenting Rails-side pre-formatting as an alternative. > > Clarifies the limitations of direct `messages['key']` lookups with ICU placeholders, adds a build-time vs controller-props comparison table, and updates the RSC third-party compatibility docs/matrix to reflect the `createIntl` recommendation and more concrete `i18next` per-request setup notes. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit eb62286. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Documentation** * Added a comprehensive guide for internationalization with React Server Components, recommending server-side formatting patterns and a controller pre-formatting alternative. * Reintroduced explicit client-side provider pattern for Client Components. * Added a build-time vs request-time comparison table and updated third-party library compatibility guidance (including per-request i18next considerations). * Expanded common mistakes with placeholder and formatting guidance. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6a399b0 commit 6a18c16

3 files changed

Lines changed: 111 additions & 21 deletions

File tree

docs/oss/building-features/i18n.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,17 @@ By default, the locales are generated as JSON, but you can also generate them as
183183
)
184184
```
185185

186+
## Using i18n with React Server Components
187+
188+
The build-time locale system described above generates flat dot-separated keys (e.g., `"product.title"`) and converts Rails `%{variable}` placeholders to ICU `{variable}` syntax. This works well with `react-intl`'s `defineMessages` in Client Components.
189+
190+
For **Server Components**, `react-intl`'s hooks (`useIntl`) and Context (`IntlProvider`) are unavailable. Use one of these approaches:
191+
192+
- **`createIntl` from `react-intl`** (recommended) — a context-free API that provides full interpolation, pluralization, and formatting in Server Components
193+
- **Rails pre-formatting** — compute `I18n.t('key', name: value)` in the controller and pass ready-to-render strings as props
194+
195+
See the [RSC i18n Provider guide](../migrating/rsc-context-and-state.md#i18n-provider) for detailed patterns and code examples.
196+
186197
## Notes
187198

188199
- See why using JSON can perform better compared to JS for large amounts of data [https://v8.dev/blog/cost-of-javascript-2019#json](https://v8.dev/blog/cost-of-javascript-2019#json).

docs/oss/migrating/rsc-context-and-state.md

Lines changed: 95 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,11 @@ If your React components also need the theme value, pass it as a prop:
353353

354354
### i18n Provider
355355

356-
Internationalization in React on Rails typically uses Rails I18n on the server side and a client-side library (like `react-intl` or `i18next`) for Client Components. Pass translations from Rails as props:
356+
Internationalization in React on Rails typically uses Rails I18n on the server side and `react-intl` for Client Components. The challenge with RSC is that `react-intl`'s `useIntl()` hook and `<FormattedMessage>` component require React Context, which is unavailable in Server Components.
357+
358+
> **Two i18n systems:** React on Rails has a [build-time locale system](../building-features/i18n.md) (`config.i18n_dir`) that compiles Rails YAML translations into JSON/JS files with flat dot-separated keys (e.g., `"product.title"`). The controller-props approach below passes translations at request time with whatever key structure you choose. Both are valid — see the comparison below.
359+
360+
#### Passing translations from Rails
357361

358362
```ruby
359363
# app/controllers/application_controller.rb
@@ -369,32 +373,43 @@ def i18n_props
369373
end
370374
```
371375

372-
```jsx
373-
// I18nProvider.jsx
374-
'use client';
376+
#### Server Components: plain string lookup (limited)
375377

376-
import { IntlProvider } from 'react-intl';
378+
The simplest approach is to read translation values directly from the messages object:
377379

378-
export default function I18nProvider({ locale, messages, children }) {
379-
return (
380-
<IntlProvider locale={locale} messages={messages}>
381-
{children}
382-
</IntlProvider>
383-
);
380+
```jsx
381+
// ProductPage.jsx -- Server Component
382+
export default function ProductPage({ locale, messages, ...props }) {
383+
const title = messages['title'];
384+
385+
return <h1>{title}</h1>;
384386
}
385387
```
386388

389+
> **Limitation:** This only works for **pre-formatted strings** — plain text with no interpolation, pluralization, or number/date formatting. React on Rails' build-time locale system converts Rails `%{variable}` placeholders to ICU `{variable}` syntax, so `messages['greeting']` would render the literal text `{name}` instead of a substituted value. For anything beyond plain strings, use `createIntl` or Rails pre-formatting (both described below).
390+
391+
#### Server Components: `createIntl` from `react-intl` (recommended)
392+
393+
Import `createIntl` from `react-intl` for a **context-free API** that provides full interpolation, pluralization, and date/number formatting without React Context. This is the recommended approach for i18n in Server Components:
394+
387395
```jsx
388396
// ProductPage.jsx -- Server Component
397+
import { createIntl, createIntlCache } from 'react-intl';
389398
import I18nProvider from './I18nProvider';
390399

400+
// Module-level cache — safe because it only caches Intl constructors, not request data
401+
const cache = createIntlCache();
402+
391403
export default function ProductPage({ locale, messages, ...props }) {
392-
// Server Components can use the translations object directly
393-
const title = messages['title'];
404+
const intl = createIntl({ locale, messages }, cache);
394405

395406
return (
396407
<div>
397-
<h1>{title}</h1>
408+
{/* Full formatting works in Server Components */}
409+
<h1>{intl.formatMessage({ id: 'greeting' }, { name: props.userName })}</h1>
410+
<p>{intl.formatNumber(props.price, { style: 'currency', currency: 'USD' })}</p>
411+
<p>{intl.formatMessage({ id: 'items_count' }, { count: props.itemCount })}</p>
412+
398413
<I18nProvider locale={locale} messages={messages}>
399414
<InteractiveFilters /> {/* Client Component can use useIntl() */}
400415
</I18nProvider>
@@ -403,6 +418,44 @@ export default function ProductPage({ locale, messages, ...props }) {
403418
}
404419
```
405420

421+
> **Note:** `createIntl` is a plain function call — no hooks, no Context, no `'use client'` needed. The `createIntlCache()` call avoids recreating expensive `Intl.NumberFormat` / `Intl.DateTimeFormat` instances on every request. The cache stores only `Intl` constructor instances keyed by format options — no locale data, messages, or user-specific information — so it is safe to share at module scope across all concurrent requests for the lifetime of the Node.js process.
422+
423+
#### Alternative: Rails pre-formatting
424+
425+
Instead of formatting on the client, let Rails compute interpolation and pluralization before passing translations as props. This keeps Server Components simple at the cost of less flexible client-side formatting:
426+
427+
```ruby
428+
def i18n_props
429+
{
430+
locale: I18n.locale.to_s,
431+
# Pre-format with variables — Server Components receive ready-to-render strings
432+
greeting: I18n.t('greeting', name: current_user.name),
433+
items_count: I18n.t('items_count', count: @cart.item_count),
434+
# Pass raw messages for Client Components that need dynamic formatting
435+
messages: I18n.t('product_page').deep_stringify_keys,
436+
}
437+
end
438+
```
439+
440+
#### Client Components: `IntlProvider` + `useIntl()`
441+
442+
Client Components use the standard `react-intl` Context pattern:
443+
444+
```jsx
445+
// I18nProvider.jsx
446+
'use client';
447+
448+
import { IntlProvider } from 'react-intl';
449+
450+
export default function I18nProvider({ locale, messages, children }) {
451+
return (
452+
<IntlProvider locale={locale} messages={messages}>
453+
{children}
454+
</IntlProvider>
455+
);
456+
}
457+
```
458+
406459
```jsx
407460
// InteractiveFilters.jsx -- Client Component
408461
'use client';
@@ -415,6 +468,15 @@ export default function InteractiveFilters() {
415468
}
416469
```
417470

471+
#### Build-time vs controller-props: when to use each
472+
473+
| Approach | Source | Key format | Best for |
474+
| ------------------------------ | -------------------------- | ------------------------------- | --------------------------------------------------------------------------------------- |
475+
| Build-time (`config.i18n_dir`) | YAML → compiled JSON/JS | Flat: `"product.title"` | Static translations shared across pages; client-side `react-intl` with `defineMessages` |
476+
| Controller-props (`I18n.t`) | Rails I18n at request time | Flat (required by `createIntl`) | Page-specific translations; pre-formatted strings with variables; RSC `createIntl` |
477+
478+
Both can be used together — for example, build-time translations for the client bundle and controller-props for Server Component content. See the [Internationalization guide](../building-features/i18n.md) for build-time setup details.
479+
418480
## Common Mistakes
419481

420482
### Mistake 1: Wrapping the entire tree in providers unnecessarily
@@ -468,7 +530,24 @@ messages: I18n.t('.').deep_stringify_keys
468530
messages: I18n.t('product_page').deep_stringify_keys
469531
```
470532

471-
### Mistake 3: Reading Redux store in Server Components
533+
### Mistake 3: Using `messages['key']` for translations with placeholders
534+
535+
The build-time locale system converts Rails `%{variable}` placeholders to ICU `{variable}` syntax. Reading these directly from the messages object renders the literal placeholder text:
536+
537+
```jsx
538+
// BAD: Renders "Hello, {name}" as literal text
539+
const greeting = messages['greeting'];
540+
```
541+
542+
```jsx
543+
// GOOD: Use createIntl to format with variable substitution
544+
import { createIntl, createIntlCache } from 'react-intl';
545+
const cache = createIntlCache();
546+
const intl = createIntl({ locale, messages }, cache);
547+
const greeting = intl.formatMessage({ id: 'greeting' }, { name: 'John' });
548+
```
549+
550+
### Mistake 4: Reading Redux store in Server Components
472551

473552
Server Components render once on the server and never re-render. They cannot subscribe to store changes:
474553

@@ -482,7 +561,7 @@ export default function Dashboard({ user }) {
482561

483562
**Fix:** Keep the component as a Client Component (add `'use client'`), or pass the value from Rails as a prop to a Server Component that doesn't need the Redux store.
484563

485-
### Mistake 4: Creating new QueryClient on every render
564+
### Mistake 5: Creating new QueryClient on every render
486565

487566
If the `QueryClient` is created without `useState`, React creates a new instance on every render, losing the cache:
488567

docs/oss/migrating/rsc-third-party-libs.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -251,10 +251,10 @@ All major date libraries work in Server Components since they are pure utility f
251251

252252
## Internationalization
253253

254-
| Library | RSC Pattern | Notes |
255-
| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
256-
| **Rails I18n + react-intl** | Pass translations from Rails controller as props. Server Components use the translations object directly; Client Components use `<IntlProvider>` + `useIntl()`. | Recommended for React on Rails. See [Context guide](rsc-context-and-state.md#i18n-provider). |
257-
| **i18next / react-i18next** | Requires `'use client'` for hook-based usage. Server Components can use `i18next` directly (no hooks). | Framework-agnostic alternative. |
254+
| Library | RSC Pattern | Notes |
255+
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- |
256+
| **Rails I18n + react-intl** | Pass translations from Rails controller as props. Server Components use `createIntl` from `react-intl` for full formatting (interpolation, pluralization, dates); Client Components use `<IntlProvider>` + `useIntl()`. | Recommended for React on Rails. See [Context guide](rsc-context-and-state.md#i18n-provider). |
257+
| **i18next / react-i18next** | `react-i18next` hooks (`useTranslation`) require `'use client'`. For Server Components, `i18next` can be initialized per-request via `i18next.createInstance()` and used without hooks — but you must manage locale/namespace initialization yourself. | Requires per-request setup; not used in this codebase. |
258258

259259
## Authentication
260260

@@ -364,7 +364,7 @@ Use `client-only` for:
364364
| **Charts** | Nivo (SSR support) | Recharts, Tremor, Chart.js | -- |
365365
| **Data Fetching** | React on Rails Pro streaming, native `fetch` in Server Components | TanStack Query (with hydration), Apollo, SWR | -- |
366366
| **State** | Server Component props, `React.cache` | Zustand, Jotai (v2.6+), Redux Toolkit | Recoil (discontinued) |
367-
| **i18n** | Rails I18n + react-intl | react-i18next, i18next | -- |
367+
| **i18n** | Rails I18n + `createIntl` (Server), `IntlProvider` (Client) | react-i18next (hooks require `'use client'`) | -- |
368368
| **Auth** | Rails auth (Devise, etc.) via controller props | -- | -- |
369369
| **Date Utils** | date-fns, dayjs (pure functions) | -- | Moment.js (not tree-shakable) |
370370

0 commit comments

Comments
 (0)