Internationalization is baked into the foundation of Astryx. The library ships a complete i18n system inside @astryxdesign/core — there is nothing extra to install. You wrap your app in an InternationalizationProvider, point it at a locale, and Astryx components pick up localized strings from that provider.
Files this lesson: src/app/messages.ts (new), src/app/providers.tsx (extended), src/app/components/KanbanBoard.tsx (rewritten). page.tsx stays untouched.
Message Catalogs & the Translator
A catalog is a plain object mapping message keys to messages. Each entry uses the shape { defaultMessage, description? }. Keys should be namespaced — keep your app keys in your own namespace (e.g. @app.*) so they never collide with Astryx’s built-in @astryx.* strings.
Create src/app/messages.ts:
import type { Catalog, MessagesByLocale } from '@astryxdesign/core/i18n';
export const en: Catalog = { '@app.board.title': { defaultMessage: 'Sprint Board' }, '@app.switchLanguage': { defaultMessage: 'En français' }, '@app.tasks': { defaultMessage: 'tasks' },};
export const fr: Catalog = { '@app.board.title': { defaultMessage: 'Tableau de sprint' }, '@app.switchLanguage': { defaultMessage: 'In English' }, '@app.tasks': { defaultMessage: 'tâches' },};
// MessagesByLocale types the map you pass to the provider's `messages` prop.export const messages: MessagesByLocale = { en, fr };Include your own en catalog even though English is the built-in fallback: that fallback only contains Astryx’s own component strings, not your app’s. If a key is missing from the active locale, Astryx walks the locale chain (for example pt-BR → pt → built-in en) before giving up.
Reading Strings with useTranslator
Component authors resolve strings with the useTranslator() hook rather than hardcoding user-facing text:
import { useTranslator } from '@astryxdesign/core/i18n';
function BoardHeader() { const t = useTranslator(); return <h1>{t('@app.board.title')}</h1>;}t() is just a function — call it anywhere in a component rendered under the provider.
The Preferences Context: State That Lives Above the Board
The language toggle button has to live inside the board, but the locale state has to live above the board — inside the providers, because that is where InternationalizationProvider sits. Rather than drilling callbacks through every layer, we add a small context. This same context will carry the light/dark mode in lesson 5, so we build it fully now.
Extend src/app/providers.tsx:
'use client';
import type { ReactNode } from 'react';import { createContext, useContext, useState, type ReactNode,} from 'react';import { Theme } from '@astryxdesign/core';import { neutralTheme } from '@astryxdesign/theme-neutral/built';import { InternationalizationProvider } from '@astryxdesign/core/i18n';import { messages } from './messages';
export type ThemeMode = 'system' | 'light' | 'dark';export type AppLocale = 'en' | 'fr';
interface AppPreferences { mode: ThemeMode; setMode: (mode: ThemeMode) => void; locale: AppLocale; setLocale: (locale: AppLocale) => void;}
const AppPreferencesContext = createContext<AppPreferences | null>(null);
export function usePreferences() { const ctx = useContext(AppPreferencesContext); if (!ctx) { throw new Error('usePreferences must be used inside <Providers>'); } return ctx;}
export function Providers({ children }: { children: ReactNode }) { const [mode, setMode] = useState<ThemeMode>('system'); const [locale, setLocale] = useState<AppLocale>('en');
return ( <Theme theme={neutralTheme} mode="system"> <Theme theme={neutralTheme} mode={mode}> <InternationalizationProvider locale={locale} messages={messages}> <AppPreferencesContext.Provider value={{ mode, setMode, locale, setLocale }} > {children} </AppPreferencesContext.Provider> </InternationalizationProvider> </Theme> );}modeis already in state, wired to<Theme mode={mode}>— it defaults to'system'and is unused for now. Lesson 5 adds the toggle button that reads it.localefeedsInternationalizationProvider; re-rendering with a new locale updates every string live — no reload.usePreferences()lets any component in the tree read and change both.
Using It: The Board Header
Rewrite src/app/components/KanbanBoard.tsx to read the localized title, show a live task counter with tabular numbers, and carry a language toggle button:
'use client';
import { Section } from '@astryxdesign/core/Section';import { Layout, LayoutHeader, LayoutContent, HStack,} from '@astryxdesign/core/Layout';import { Heading, Text } from '@astryxdesign/core/Text';import { Card } from '@astryxdesign/core/Card';import { Button } from '@astryxdesign/core/Button';import { useTranslator } from '@astryxdesign/core/i18n';import { usePreferences } from '../providers';
export default function KanbanBoard() { const t = useTranslator(); const { locale, setLocale } = usePreferences();
return ( <Section height="100dvh"> <Layout height="fill" header={ <LayoutHeader hasDivider padding={4}> <HStack hAlign="between" vAlign="center"> <Heading level={3}>Sprint Board</Heading> <Heading level={3}>{t('@app.board.title')}</Heading> <Text type="supporting" color="secondary"> Layout shell — real columns arrive in lesson 6 </Text> <HStack gap={2} vAlign="center"> {/* Tabular numbers keep digits from jittering as counts change */} <Text type="supporting" color="secondary" hasTabularNumbers> 4 {t('@app.tasks')} </Text> <Button label={t('@app.switchLanguage')} variant="secondary" onClick={() => setLocale(locale === 'en' ? 'fr' : 'en')} /> </HStack> </HStack> </LayoutHeader> } content={ <LayoutContent padding={4}> <HStack gap={4}> {[0, 1, 2, 3].map(i => ( <Card key={i} variant="muted" padding={3} width={300} style={{ flexShrink: 0 }} > <Heading level={4}>Column {i + 1}</Heading> <Text type="supporting" color="secondary"> Placeholder — data arrives in lesson 6. </Text> </Card> ))} </HStack> </LayoutContent> } /> </Section> );}Typography That Holds Up Across Languages
- Tabular numbers (
hasTabularNumbers) —Text hasTabularNumbersenables monospace figures (font-variant-numeric: tabular-nums) so counters stay perfectly aligned as values change. Essential when the same counter renders under differently worded labels in each locale. - Line clamping (
maxLines) — localized strings vary significantly in length.Text maxLines={n}truncates cleanly so translated descriptions never break card heights. We will use it on task descriptions in lesson 6.
See It in Action
Run npm run dev and open http://localhost:3000. The header reads Sprint Board with a task counter. Click En français — the title flips to Tableau de sprint, the counter label to tâches, and the button label to In English, all without a reload. Because locale lives in the providers above the board, every component under the tree would re-localize together.
What You Built
messages.tswith namespaceden/frcatalogs.- A
usePreferences()context exposinglocale(andmode, for lesson 5) to the whole tree. - A language toggle button living in the board header — the first of the app-level controls we will consolidate into the toolbar in lesson 10.
In the next lesson, we will master styling components with StyleX and the xstyle prop.