@serenis/i18n
v0.22.0
Published
Internationalization for Serenis apps. The library owns two concerns:
Keywords
Readme
@serenis/i18n
Internationalization for Serenis apps. The library owns two concerns:
- Translations — a hierarchical system for global and scoped copy.
- Language management — the user's language preference, persisted and shared across every Serenis app.
Translations
This library creates a hierarchical translation system that lets you maintain both global translations that are available throughout your application and independent scoped translations (e.g. for the domains) that can override or extend the global ones. This allows to split translations into smaller files, making it easier to manage and maintain them in addition to optimizing code splitting. It works by nesting react contexts: translations are first looked up in the closest context, then fall back to parent contexts if not found.
Usage
import { I18nProvider, Translation } from '@serenis/i18n'
export { default as globalTranslations } from './global/en.json'
export { default as domainTranslations } from './domains/foo/i18n/en.json'
const App = () => {
return (
<I18nProvider translations={globalTranslations}>
<GlobalComponent />
<Domain />
</I18nProvider>
)
}
const GlobalComponent = () => {
const translate = useTranslate()
return (
<div>
<h1>
<Tranlation id="serenis" />
</h1>
</div>
)
}
const Domain = () => {
return (
<I18nProvider translations={domainTranslations}>
<DomainComponent />
</I18nProvider>
)
}
const DomainComponent = () => {
const translate = useTranslate()
return (
<div>
<h1>
<Tranlation id="nutritionPlan.title" />
</h1>
<button type="button">
<Tranlation id="global.save" />
</button>
</div>
)
}Typing the translation keys
The library uses TypeScript type augmentation to type the translation keys.
You can provide the keys you need by extending the Translation interface:
import { type default as en } from '../src/i18n/en.json'
declare global {
namespace I18n {
interface Translation {
// Extend the keys with the ones from the json translation file
id: keyof typeof en
}
}
}Language management
The library also owns the user's language preference, shared across every Serenis app through a cookie (srns_lang) scoped to serenis.it.
The cookie is the single source of truth: apps mount LanguageProvider and read or change the language through useLanguage — persistence and cross-app sharing come for free.
import { LanguageProvider, useLanguage } from '@serenis/i18n'
const App = () => {
return (
<LanguageProvider>
<LanguageSwitcher />
</LanguageProvider>
)
}
const LanguageSwitcher = () => {
const { language, setLanguage } = useLanguage()
return (
<button type="button" onClick={() => setLanguage(language === 'it' ? 'en' : 'it')}>
{language}
</button>
)
}Seeding the first render
initialLanguage seeds the first render with values the provider cannot read on its own: a query parameter at the entry point, or the cookie forwarded by an SSR framework.
Unsupported values are ignored, and the resolved language is persisted back to the cookie after mount.
<LanguageProvider initialLanguage={new URLSearchParams(window.location.search).get('lng')}>
<App />
</LanguageProvider>Reacting to language changes
onLanguageChange is called with the resolved language after mount and again on every change.
Use it for side effects that must follow the language, like a date library locale. Keep its reference stable: an inline arrow re-triggers it on every render.
const syncDateLocale = (language: Language) => {
setDefaultOptions({ locale: locales[language] })
}
const App = () => {
return (
<LanguageProvider onLanguageChange={syncDateLocale}>
<Content />
</LanguageProvider>
)
}Reading and writing outside React
readLanguage and writeLanguage expose the language to non-React environments — a server component, a proxy — through transport functions the consumer supplies.
The library owns the cookie name, the value validation, the default, and the scope; the consumer only provides how to read or write a cookie in its environment.
They are published from the React-free @serenis/i18n/server entry point, so importing them into a server component or proxy does not pull React in.
readLanguage returns null when no language cookie is set; pass { fallback: true } to get the default language instead — useful when a concrete value is required, e.g. an <html lang> attribute.
import { readLanguage, writeLanguage } from '@serenis/i18n/server'
// Next.js server component: read the language, defaulting when none is set
const cookieStore = await cookies()
const language = readLanguage((name) => cookieStore.get(name)?.value, { fallback: true })
// Next.js proxy: normalize a query parameter into the shared cookie
writeLanguage(
request.nextUrl.searchParams.get('lng'),
(cookie) => response.cookies.set(cookie),
request.nextUrl.hostname,
)