i18n for Astro + React islands — MessageFormat 2, automatic tree-shaking, parallel loading, any translation source
npm install @astroscope/i18n> Note: This package is in active development. APIs may change between versions.
i18n for Astro + React islands — automatic tree-shaking, parallel loading, any translation source.
SSR-first — The only i18n solution built specifically for SSR + islands architecture. Works seamlessly with Astro's partial hydration model where most i18n libraries fail.
Automatic tree-shaking — Only translations actually used by each component are delivered to the browser. No manual chunk splitting, no configuration. It just works.
Parallel loading — Translations load alongside component hydration via custom client:*-x directives. No waiting for translations before rendering.
Unified API — Same t() function works identically in Astro templates and React islands.
Any translation source — Fetch translations from any provider: JSON files, database, headless CMS, TMS, or custom API. All of them will be optimized and chunked automatically.
Production optimized — Fallback strings are stripped from production bundles via Babel, reducing bundle size while keeping fallbacks available via the manifest.
- Per-chunk translation loading — each island gets only its translations
- Unicode MessageFormat 2 (MF2) support via messageformat v4
- Built-in formatters — :number, :integer, :percent, :currency, :date, :time, :datetime, :unit
- Babel-based extraction — robust AST parsing, source maps, production stripping
- Manifest fallbacks — missing translations automatically use extracted fallbacks
- Full TypeScript support
- Tiny client runtime — ~8KB gzipped for translations
``bash`
npm install @astroscope/i18n @astroscope/boot @astroscope/excludes
`ts
// astro.config.ts
import { defineConfig } from 'astro/config';
import boot from '@astroscope/boot';
import i18n from '@astroscope/i18n';
export default defineConfig({
integrations: [
boot(),
i18n(),
],
});
`
#### Consistency checking
The integration checks for translation key consistency — when the same key is used in multiple files with different fallbacks, variables, or descriptions.
`ts`
i18n({
consistency: 'warn', // 'warn' (default) | 'error' | 'off'
})
- 'warn' — Log a warning but continue (default)
- 'error' — Fail the build
- 'off' — Disable consistency checking
VERY IMPORTANT: i18n.configure must be awaited during boot before handling any requests!
`ts
// src/boot.ts
import { i18n, type RawTranslations } from '@astroscope/i18n';
async function fetchTranslations(locale: string): Promise
// fetch from your CMS, API, or local files
const response = await fetch(https://api.example.com/translations/${locale});
return response.json();
}
export async function onStartup() {
await i18n.configure({
locales: ['en', 'de'],
defaultLocale: 'en', // optional, defaults to first locale
});
// load translations for all locales
const [en, de] = await Promise.all([
fetchTranslations('en'),
fetchTranslations('de'),
]);
i18n.setTranslations('en', en);
i18n.setTranslations('de', de);
}
`
`ts
// src/middleware.ts
import { sequence } from 'astro:middleware';
import { createI18nChunkMiddleware, createI18nMiddleware, i18n } from '@astroscope/i18n';
export const onRequest = sequence(
createI18nChunkMiddleware(), // serves /_i18n/ translation chunks
createI18nMiddleware({
locale: (ctx) =>
ctx.cookies.get('locale')?.value ??
i18n.getConfig().defaultLocale,
}),
);
`
By default, RECOMMENDED_EXCLUDES (static assets like /_astro/) are excluded from locale context setup. To customize:
`ts
import { sequence } from 'astro:middleware';
import { createI18nChunkMiddleware, createI18nMiddleware, i18n } from '@astroscope/i18n';
import { RECOMMENDED_EXCLUDES } from '@astroscope/excludes';
export const onRequest = sequence(
createI18nChunkMiddleware(),
createI18nMiddleware({
locale: (ctx) =>
ctx.cookies.get('locale')?.value ??
i18n.getConfig().defaultLocale,
exclude: [...RECOMMENDED_EXCLUDES, { exact: '/health' }],
}),
);
`
Inject translations into the page for hydrated components:
`astro`
---
import { I18nScript } from '@astroscope/i18n/astro';
---
`astro`
---
// In .astro files
import { t } from '@astroscope/i18n/translate';
---{t('checkout.title', 'Order Summary')}
`tsx
// In React components
import { t } from '@astroscope/i18n/translate';
export function CheckoutSummary() {
return (
{t('checkout.tax', 'Includes {$tax} VAT', { tax: '19%' })}
> Note: Variables use
{$name} syntax (with $ prefix) per MessageFormat 2 specification.$3
The problem: With standard
client: directives, the translation chunk loads after* the component module. This delays hydration while translations are fetched sequentially.The solution: Use client:*-x directives to load translations in parallel with the component code:
`astro`
---
import Cart from '../components/Cart';
---
Translate a key with optional interpolation values. The fallback is used when a translation is missing and also serves as an example for translators. Uses MessageFormat 2 syntax.
`ts
// simple text
t('checkout.title', 'Order Summary')
// with variables (note the $ prefix)
t('checkout.tax', 'Includes {$tax} VAT', { tax: '19%' })
// with pluralization (MF2 syntax)
t('cart.items', .input {$count :number}
.match $count
one {{{$count} item}}
* {{{$count} items}}, { count: 5 })
// with number formatting
t('stats.value', '{$value :number minimumFractionDigits=2}', { value: 1234.5 })
// with percentage
t('stats.ratio', '{$value :percent}', { value: 0.856 })
// with date/time formatting
t('event.date', '{$date :date style=long}', { date: new Date() })
t('event.time', '{$time :time style=short}', { time: new Date() })
// with currency (translator controls currency)
t('product.price', '{$price :currency currency=EUR}', { price: 99.99 })
// with currency (code controls currency via wrapped value)
t('product.price', '{$price :currency}', {
price: { valueOf: () => 99.99, options: { currency: 'EUR' } }
})
// with units
t('distance', '{$value :unit unit=kilometer}', { value: 42 })
// with metadata object (for extraction tooling)
t('cart.total', {
example: 'Total: {$amount}',
description: 'Cart total price',
variables: {
amount: { example: '$0.00', description: 'Formatted price' }
}
}, { amount: '$49.99' })
`
Translate with embedded components using MF2 markup syntax. Returns an array of strings and JSX elements that can be rendered directly.
`tsx
import { rich } from '@astroscope/i18n/translate';
// basic link
rich('tos', 'Read our {#link}Terms of Service{/link}', {
link: (children) => {children}
})
// Returns: ['Read our ', Terms of Service]
// multiple tags
rich('legal', 'Read our {#tos}Terms{/tos} and {#privacy}Privacy Policy{/privacy}', {
tos: (children) => {children},
privacy: (children) => {children},
})
// with variables
rich('greeting', 'Hello {$name}, check your {#inbox}messages{/inbox}', {
inbox: (children) => {children},
}, { name: 'Alice' })
// nested tags
rich('highlight', 'This is {#bold}very {#em}important{/em}{/bold}', {
bold: (children) => {children},
em: (children) => {children},
})
// standalone (self-closing) tags
rich('install', 'Click {#icon/} to install', {
icon: () =>
})
`
The same code works in both Astro templates and React islands — rich() is JSX-runtime agnostic.
`ts
import { i18n } from '@astroscope/i18n';
// configure (call once at startup)
await i18n.configure({
locales: ['en', 'de'],
defaultLocale: 'en',
});
// set translations for a locale
i18n.setTranslations('en', { 'key': 'value' });
// get raw translations (includes manifest fallbacks)
i18n.getTranslations('en');
// get compiled translations (ICU MessageFormat functions)
i18n.getCompiledTranslations('en');
// get extraction manifest
// has all extracted keys with their metadata
// you can use this to generate translation files or upload to a TMS / CMS
i18n.getManifest();
// clear cached translations
i18n.clear(); // all locales
i18n.clear('en'); // specific locale
`
Translations load automatically for lazy-loaded components.
`tsx
import { Suspense, lazy } from 'react';
const StatsModal = lazy(() => import('./StatsModal'));
export function App() {
return (
How it works
1. Build time — Babel plugin extracts all
t() calls, maps them to chunks, strips fallbacks from production bundles
2. Manifest — Extracted keys with fallbacks are written to i18n-manifest.json
3. SSR — Middleware provides translations to t(), merging manifest fallbacks for missing keys
4. Client — Custom directives load only the translations needed by each chunkThe same
import { t } from '@astroscope/i18n/translate' works everywhere — bundler picks the correct implementation via conditional exports (browser vs default).$3
Translation chunks are served as raw MessageFormat 2 strings and compiled on the browser on first use. This keeps chunk sizes minimal — the
messageformat runtime is ~8KB gzipped. Compiled messages are cached for subsequent renders.> Future: Once browsers ship native
Intl.MessageFormat, this 8KB runtime will be replaced by the built-in API with zero bundle cost.MessageFormat 2 Syntax
This library uses Unicode MessageFormat 2 (MF2), the modern standard for internationalization.
$3
`
Simple text
Hello {$name}
`$3
`
.input {$count :number}
.match $count
one {{{$count} item}}
* {{{$count} items}}
`$3
`
.input {$gender :string}
.match $gender
male {{He liked it}}
female {{She liked it}}
* {{They liked it}}
`$3
| Formatter | Description | Example |
|-----------|-------------|---------|
|
:number | Locale-aware number | {$n :number} → "1,234.56" |
| :integer | Integer (no decimals) | {$n :integer} → "1,235" |
| :percent | Percentage | {$n :percent} → "85.6%" |
| :currency | Currency | {$n :currency currency=EUR} → "€99.99" |
| :date | Date | {$d :date style=long} → "January 26, 2026" |
| :time | Time | {$d :time style=short} → "3:45 PM" |
| :datetime | Date + time | {$d :datetime dateStyle=medium timeStyle=short} |
| :unit | Units | {$n :unit unit=kilometer} → "42 km" |$3
For
:currency and :unit, the required option (currency or unit) can be:1. Hardcoded in translation (translator controls):
`
{$price :currency currency=EUR}
`
Code passes plain number: { price: 99.99 }2. Provided by code (for dynamic currency/unit):
`
{$price :currency}
`
Code passes wrapped value: { price: { valueOf: () => 99.99, options: { currency: 'EUR' } } }> Note: If both translation and code specify the option, translation wins.
Configuration
$3
| Option | Type | Default | Description |
|--------|------|---------|-------------|
|
locales | string[] | required | Supported locales |
| defaultLocale | string | first locale | Default/fallback locale |
| fallback | FallbackBehavior | 'fallback' | Behavior when translation missing |$3
-
'fallback' — Use the fallback string from manifest (default)
- 'key' — Return the translation key
- 'throw' — Throw an error
- (key, meta) => string` — Custom functionMIT