Astro Starlight is a useful reference implementation for internationalization because it does not treat i18next as the entire i18n system.
Instead, Starlight uses several layers:
- the application/framework determines which locale is active;
- translation files are treated as validated application data;
- multiple translation sources are merged according to an explicit precedence;
-
i18nextprovides the actual translation engine; - consumers receive a small, locale-bound
t()API; - TypeScript and schemas make invalid translation usage harder;
- plugins and third-party UI systems participate in the same translation pipeline.
This separation is the main lesson worth carrying into Astro, React, Next.js, server-rendered applications, component libraries, and other projects.
Starlight currently depends on i18next directly and creates its own i18next instance rather than using the package-global singleton.
1. Separate locale resolution from translation lookup
A common i18next implementation starts by asking i18next to detect the user's language.
Starlight takes a different approach.
Astro/Starlight first resolves the locale from the application's routing configuration. That locale is then passed into the translation system. Middleware creates a translation function for context.currentLocale and exposes it to the rest of the request as Astro.locals.t.
Conceptually:
request
↓
router / application locale resolution
↓
locale = "fr"
↓
translation service
↓
t = getTranslator("fr")
↓
component
↓
t("navigation.home")
This is preferable to making i18next simultaneously responsible for:
URL routing
cookie handling
browser detection
locale selection
translation loading
translation lookup
Those are separate responsibilities.
General lesson
Design an application-level locale resolver first.
For example:
type Locale = 'en' | 'fr' | 'es';
function resolveLocale(request: Request): Locale {
// Depending on your application:
// 1. URL
// 2. authenticated user preference
// 3. cookie
// 4. Accept-Language
// 5. default locale
}
Then make translation lookup explicit:
const t = getTranslator(locale);
For a React SPA, browser-language detection may still be appropriate. The important part is that locale resolution should have a clearly defined owner rather than emerging accidentally from several libraries.
For SSR applications, this separation is particularly useful because the server already knows which locale is rendering the request.
2. Treat translation dictionaries as application data
Starlight does not simply import arbitrary JSON and hope that keys are correct.
Its i18n collection has a Zod schema. The schema describes recognized translation keys and even attaches descriptions explaining what each string represents.
Examples include concepts such as:
search.label
themeSelect.dark
languageSelect.accessibleLabel
tableOfContents.onThisPage
page.previousLink
aside.warning
heading.anchorLabel
The important idea is:
translations are data with a contract
rather than:
translations are arbitrary Record<string, string>
Why this matters
A schema can detect:
- invalid value types;
- misspelled keys;
- unsupported structures;
- malformed generated catalogs;
- accidental translation metadata;
- plugin-specific extensions.
It can also become a source for:
- editor autocomplete;
- translation documentation;
- automated catalog validation;
- translation-management tooling;
- generated TypeScript declarations.
Recommended pattern
Maintain a canonical schema beside your i18n infrastructure:
import { z } from 'zod';
export const uiMessagesSchema = z.object({
'navigation.home': z.string(),
'navigation.settings': z.string(),
'search.label': z.string(),
'search.results': z.string(),
'account.signOut': z.string(),
});
A more mature system can store translator context:
const messages = {
'navigation.home': {
description: 'Primary navigation link to the application home page',
},
'account.signOut': {
description: 'Button used to terminate the current authenticated session',
},
};
Translator context is not cosmetic. A translator seeing only:
"open": "Open"
does not know whether "open" is a verb, adjective, menu state, button, or accessibility label.
3. Use different validation strictness for canonical and override catalogs
Starlight makes an interesting distinction.
Its bundled Starlight strings are validated against a stricter schema, while the schema exposed to application users is partial and extensible. This lets a project provide only the translations it wants to override rather than having to reproduce the complete Starlight catalog.
This suggests a strong general pattern.
Canonical/default locale
Require everything:
const defaultCatalogSchema = uiMessagesSchema.required();
A missing English source string should fail CI or the build.
Secondary locales
Depending on your translation workflow, either require completeness:
const localeCatalogSchema = uiMessagesSchema.required();
or deliberately allow incremental translation:
const localeCatalogSchema = uiMessagesSchema.partial();
with runtime fallback to the default locale.
The decision should be explicit.
Do not accidentally allow partial translation simply because JSON files are unchecked.
4. Establish an explicit translation precedence
Starlight combines three categories of translations:
Starlight built-ins
↓
plugin translations
↓
project/user translations
The resource builder processes them in that order, so later sources can override earlier ones. Undefined or missing entries do not erase an earlier translation.
This produces a clear policy:
framework defaults < extension defaults < application overrides
That is a highly reusable pattern.
For a component platform, the equivalent might be:
design-system defaults
↓
feature-package translations
↓
application translations
↓
customer/tenant overrides
Implement the policy explicitly:
function buildCatalog(
core: Messages,
extensions: Messages,
app: Messages,
): Messages {
return {
...core,
...extensions,
...app,
};
}
For complex systems, prefer a merge function that can distinguish:
missing
undefined
empty string
null
intentional deletion
Starlight itself ignores falsy translation values while merging. That behavior is worth understanding rather than copying blindly.
5. Give extensions a translation registration API
Starlight plugins do not need access to the internals of the i18next resource store.
Instead, plugins participate in an i18n:setup phase and call:
injectTranslations({
en: {
'myPlugin.doThing': 'Do the thing',
},
fr: {
'myPlugin.doThing': 'Faire le truc',
},
});
Those strings become available through the same translation API as Starlight's built-in strings.
This is a useful architecture for:
- plugins;
- feature modules;
- packages in a monorepo;
- design systems;
- microfrontends;
- third-party integrations.
Instead of allowing every feature to initialize i18next independently, define an extension contract.
For example:
interface TranslationExtension {
namespace: string;
resources: Record<string, Record<string, string>>;
}
registerTranslations({
namespace: 'billing',
resources: {
en: {
'billing.invoice.download': 'Download invoice',
},
fr: {
'billing.invoice.download': 'Télécharger la facture',
},
},
});
Starlight's plugin examples use keys prefixed with the plugin name, such as myPlugin.doThing, which is a practical convention for avoiding collisions.
6. Hide the raw i18next instance behind an application API
Starlight creates a dedicated i18next instance:
const i18n = i18next.createInstance();
await i18n.init({
resources,
fallbackLng,
});
It then returns locale-bound translators using getFixedT().
i18next itself documents createInstance() for separate i18next instances and getFixedT() for generating a translation function bound to a language or namespace.
This means most Starlight components do not care about initialization.
They receive something conceptually equivalent to:
const t = getTranslator('fr');
t('search.label');
General lesson
Your application code should depend primarily on:
t(...)
not:
i18next.changeLanguage(...)
i18next.addResourceBundle(...)
i18next.init(...)
i18next.services.resourceStore...
Keep those operations in the infrastructure layer.
A useful API might be:
export interface Translator {
(key: TranslationKey, options?: TranslationOptions): string;
exists(key: TranslationKey): boolean;
dir(locale?: string): 'ltr' | 'rtl';
all(): Readonly<Record<string, string>>;
}
Starlight adds exactly this kind of convenience around i18next: its translator exposes t(), t.exists(), t.dir(), and t.all().
7. Bind translators to the request or rendering context
Starlight does not continually mutate one global current language.
Instead, it uses getFixedT(locale, namespace) to obtain a translator for a particular locale. Middleware attaches that translator to the current request.
This is especially relevant for SSR.
A global mutable language can create problems when a server handles concurrent requests:
request A → French
request B → Japanese
If both mutate a shared singleton's active language, isolation becomes important.
A safer server-side model is:
const t = getTranslator(requestLocale);
or an i18next instance scoped appropriately for the request/application architecture.
For React SSR, react-i18next also documents passing an i18next instance through I18nextProvider, including request-specific instances for SSR scenarios.
8. Make fallback behavior part of the product policy
Starlight explicitly sets its default locale as the fallbackLng.
Its documentation also treats fallback as a product feature: untranslated documentation pages can fall back to content in the default language instead of simply disappearing.
The translation equivalent is:
requested locale
↓
less-specific locale if applicable
↓
application default locale
i18next has built-in language-variant fallback behavior. For example, a regional locale can fall back to its broader language before the configured fallback language.
A general application should therefore document fallback rules explicitly:
export const DEFAULT_LOCALE = 'en';
export const supportedLocales = [
'en',
'en-GB',
'fr',
'fr-CA',
'es',
] as const;
Do not let locale fallback be whatever i18next happens to do by default. i18next's default fallback is dev; its documentation recommends explicitly selecting a real fallback language for production applications.
9. Use proper locale identifiers
Starlight uses BCP-47 language tags and relies on platform internationalization APIs such as Intl.Locale and Intl.DisplayNames to derive information about locales. It also exposes language direction through the translation API.
Prefer:
en
en-US
en-GB
pt-BR
zh-CN
zh-TW
rather than application-specific formats such as:
english
en_US
brazilian
zh_chinese
BCP-47-compatible identifiers cooperate better with:
-
Intl; - browsers;
- HTTP language headers;
- i18next;
- translation vendors;
- date/number formatting APIs.
One Starlight implementation detail should not necessarily be copied: its helper for locating a bundled base-language translation uses a relatively simple region-stripping function. A general-purpose application with scripts and complex language variants should rely on proper locale negotiation rather than inventing BCP-47 parsing.
10. Make RTL direction part of the i18n service
Internationalization is not only translated strings.
Starlight exposes:
t.dir()
and uses that result to produce ltr or rtl. Its locale configuration also includes writing direction.
A general application should make direction available globally:
function AppShell({ locale }: { locale: string }) {
const dir = i18n.dir(locale);
return (
<html lang={locale} dir={dir}>
...
</html>
);
}
CSS should then favor logical properties:
margin-inline-start
padding-inline-end
border-inline-start
inset-inline-end
instead of building separate RTL styles around left and right.
11. Keep translation keys stable and semantic
Starlight's catalogs use keys such as:
search.label
themeSelect.dark
tableOfContents.onThisPage
page.previousLink
heading.anchorLabel
rather than using the English text itself as the identifier.
This allows copy to change without changing the application's API.
Prefer:
t('checkout.payment.submit')
over:
t('Pay now')
A practical naming structure is:
domain.component.meaning
Examples:
auth.login.submit
auth.login.invalidPassword
billing.invoice.download
billing.invoice.status.overdue
navigation.account.settings
navigation.account.signOut
For packages or plugins, prefix keys with the package identity:
analytics.export.csv
editor.toolbar.bold
myPlugin.settings.title
12. Use interpolation for dynamic values, not sentence construction
Starlight exposes normal i18next interpolation:
{
"heading.anchorLabel": "Section titled “{{title}}”"
}
and custom application strings can similarly use:
{
"link.astro.custom": "Astro documentation for {{feature}}"
}
The value is supplied through the t() options object.
Use interpolation when a value is genuinely dynamic:
t('welcome.user', { name });
t('files.count', { count });
Avoid constructing sentences from fragments:
t('youHave') + count + t('messages');
i18next's own best-practices documentation warns that excessive interpolation and sentence fragmentation create localization problems because languages do not share the same grammar or word ordering.
Pluralization should similarly remain inside the translation system rather than application conditionals. i18next's plural rules use Intl.PluralRules and the count option.
13. Integrate third-party UI into the same translation pipeline
One particularly useful Starlight pattern appears in search.
Pagefind is a separate library with its own UI translation API. Rather than creating an entirely separate locale system, Starlight extracts pagefind.* entries from its central translation dictionary, removes the prefix, and passes them to Pagefind.
Conceptually:
const searchTranslations = Object.fromEntries(
Object.entries(t.all())
.filter(([key]) => key.startsWith('searchProvider.'))
.map(([key, value]) => [
key.replace('searchProvider.', ''),
value,
]),
);
thirdPartySearch.init({
translations: searchTranslations,
});
This is an excellent general rule:
The application owns localization. Third-party widgets should consume translations from the application's localization pipeline whenever possible.
That prevents:
main application locale = French
date picker locale = English
search UI locale = browser language
editor locale = previously saved setting
from becoming four independent systems.
14. Make translation keys part of the TypeScript API
Starlight augments i18next's CustomTypeOptions so the starlight namespace knows its translation keys. User schema keys and plugin keys are also incorporated into the translation type.
Plugin translations are particularly interesting: Starlight collects injected keys and generates a TypeScript declaration file in the consuming project so those keys become part of the typed translation API.
The goal is:
t('navigation.home'); // valid
t('navigaton.home'); // TypeScript error
instead of discovering the typo in production.
i18next officially supports TypeScript resource typing through CustomTypeOptions.
For a normal application, a simpler approach is often sufficient:
import en from './locales/en/common.json';
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: 'common';
resources: {
common: typeof en;
};
}
}
For a large modular project, generate those declarations from your translation registry or source locale.
15. File structure: what to copy and what to adapt
Starlight itself keeps bundled UI translations as one file per locale and supports project translations from src/content/i18n/*.json, .yaml, or .yml.
A small application can use:
src/
i18n/
index.ts
config.ts
schema.ts
locales/
en.json
fr.json
es.json
For a larger React application, namespaces usually scale better:
src/
i18n/
config.ts
createI18n.ts
resolveLocale.ts
types.ts
schemas/
common.ts
billing.ts
editor.ts
locales/
en/
common.json
billing.json
editor.json
fr/
common.json
billing.json
editor.json
Starlight uses one starlight i18next namespace because its UI catalog is relatively cohesive. i18next explicitly recommends multiple namespaces when catalogs become large, belong to different semantic areas, or should be lazy-loaded separately.
Therefore, the lesson is not "always use one namespace."
The lesson is:
choose namespace boundaries deliberately
16. A generalized architecture
A reusable i18next architecture based on Starlight's strongest ideas looks like this:
┌─────────────────────────┐
│ Locale Resolver │
│ URL / user / cookie / │
│ Accept-Language │
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ Locale Config │
│ supported / default / │
│ BCP-47 / direction │
└────────────┬────────────┘
│
▼
┌─────────────┐ ┌───────────────────┐ ┌─────────────┐
│ Core │ │ Feature / plugin │ │ Application │
│ catalogs │ │ catalogs │ │ overrides │
└──────┬──────┘ └─────────┬─────────┘ └──────┬──────┘
│ │ │
└────────────────────┼────────────────────┘
▼
┌─────────────────────┐
│ Schema validation │
│ + type generation │
└──────────┬──────────┘
▼
┌─────────────────────┐
│ Resource assembly │
│ explicit precedence │
└──────────┬──────────┘
▼
┌─────────────────────┐
│ private i18next │
│ instance │
└──────────┬──────────┘
▼
┌─────────────────────┐
│ getTranslator(lang) │
└──────────┬──────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
React UI server code third-party UI
This architecture remains useful even when none of the consuming code uses Astro.
17. Recommended React implementation
For React, retain the architecture but replace Astro.locals.t with React context through react-i18next.
A reasonable initialization layer is:
// i18n/createI18n.ts
import i18next from 'i18next';
import { initReactI18next } from 'react-i18next';
import { buildResources } from './resources';
import { defaultLocale } from './config';
export async function createAppI18n(locale: string) {
const instance = i18next.createInstance();
await instance
.use(initReactI18next)
.init({
lng: locale,
fallbackLng: defaultLocale,
resources: buildResources(),
defaultNS: 'common',
interpolation: {
escapeValue: false,
},
});
return instance;
}
Then at the React boundary:
<I18nextProvider i18n={i18n}>
<App />
</I18nextProvider>
Inside components:
function SaveButton() {
const { t } = useTranslation('common');
return <button>{t('actions.save')}</button>;
}
react-i18next's useTranslation() internally supplies a namespace-bound t function using i18next's getFixedT() mechanism, which is conceptually close to the API Starlight builds itself.
For SSR, create or select the appropriate instance for the request rather than assuming a browser-global mutable language. I18nextProvider is explicitly designed to support supplied instances and SSR use cases.
18. Suggested translation pipeline
A production pipeline can be defined as:
1. Developer adds or changes a source-language key.
2. Schema/type generation updates the canonical key contract.
3. CI validates the default catalog.
4. CI validates every locale:
- valid keys
- valid value types
- interpolation placeholders
- missing/extra keys according to project policy
5. Translation files are sent to translators or a localization platform.
6. Returned catalogs are validated again.
7. Build assembles:
core
+ packages/plugins
+ application overrides.
8. i18next receives normalized resources.
9. Server/router determines the active locale.
10. Components receive a locale-bound translation API.
11. Third-party UI gets translations from that same API.
12. Automated tests verify fallback and representative locales.
The main architectural principle is that i18next should sit inside this pipeline rather than be the pipeline.
19. Tests worth adding
An i18n implementation should test infrastructure, not every sentence.
High-value tests include:
Default locale contains every required key.
Supported locales contain no unknown keys.
Required locales meet the chosen completeness threshold.
Interpolated placeholders match between source and translation.
Plural forms work for representative languages.
Regional locale fallback works.
Application overrides beat package defaults.
Plugin/feature translations are registered.
Missing translation falls back to the configured locale.
RTL locales return rtl.
Unknown translation keys fail TypeScript checks.
Server requests for different locales remain isolated.
Third-party widgets receive the correct current-locale dictionary.
Starlight itself tests content collections and can mock i18n collection entries during tests, reinforcing the idea that translation catalogs are part of the application's data layer.
20. What specifically should be copied from Starlight?
The most transferable Starlight decisions are:
| Starlight decision | General lesson |
|---|---|
| Astro resolves the locale | Keep locale resolution outside the translation engine |
i18next.createInstance() |
Own an application-specific i18next instance |
getFixedT(locale, namespace) |
Give consumers locale-bound translators |
| Zod i18n schema | Validate translations as structured data |
| Strict bundled schema | Make canonical catalogs complete |
| Partial user schema | Allow controlled incremental overrides |
| Built-in → plugin → user merging | Define translation precedence |
Plugin injectTranslations()
|
Give modules an extension API |
| Generated plugin key types | Make translation keys part of the TS contract |
Astro.locals.t |
Expose one simple translation API |
t.exists() |
Support capability/optional-key checks |
t.dir() |
Treat writing direction as first-class i18n data |
t.all() |
Allow integration with external UI libraries |
pagefind.* translation forwarding |
Centralize localization even for third parties |
| BCP-47 locale tags | Use interoperable language identifiers |
| Explicit fallback locale | Make missing-translation behavior predictable |
21. What should not be copied literally?
Several Starlight decisions are appropriate for Starlight but should remain project-specific.
One namespace
Starlight uses one starlight namespace. Large applications may benefit from common, account, billing, editor, and other namespaces for ownership and lazy loading. i18next supports namespaces specifically for this purpose.
Eagerly assembled resources
Starlight's UI catalog is small enough to assemble into resources up front. A large client application may want lazy loading by language and namespace.
Astro content collections
The useful concept is schema-validated catalogs, not Astro's particular loader implementation. React projects can reproduce this with Zod, JSON Schema, custom build scripts, or localization-platform validation.
Astro middleware
The useful concept is request-bound translation state. Next.js middleware, Express request state, Remix loaders, React Router, or another request context can provide the equivalent.
Flat dotted keys
Starlight's dotted flat keys work well for its catalog. A project can choose flat or nested resources, but the convention should be consistent and reflected in schema/types.
22. Recommended baseline for a new project
For a new TypeScript/React project, a Starlight-inspired baseline would be:
Locale:
use BCP-47 identifiers
Locale ownership:
application/router resolves locale
Default:
configure one explicit production fallback locale
Catalogs:
JSON/YAML/TS resources organized by locale + namespace
Schema:
validate every catalog during CI/build
Source locale:
required and complete
Secondary locales:
complete or deliberately partial
Keys:
semantic, stable, domain-prefixed
Extensions:
provide registerTranslations()/injectTranslations()
Precedence:
platform < feature/package < application
Runtime:
private i18next instance
React:
I18nextProvider + useTranslation()
SSR:
request-safe locale/instance handling
TypeScript:
typed translation keys
RTL:
propagate lang and dir to document root
Third parties:
derive their dictionaries from the central catalog
Testing:
schema, fallback, placeholders, plurals, overrides, RTL
Conclusion
The biggest lesson from Starlight is not a particular i18next option.
It is the decision to make i18next a translation engine behind an application-owned localization architecture.
A robust system has three distinct layers:
Locale architecture
routing, detection, user preference, fallback policy
Translation architecture
schemas, catalogs, ownership, merging, plugins, types
Rendering integration
i18next, React/Astro adapters, t(), formatting, plurals
Keeping those layers separate makes the system easier to test, extend, migrate between frameworks, and operate at scale.
If only a few Starlight ideas are adopted, the highest-value ones are:
- resolve locale outside i18next;
- treat translation catalogs as validated data;
- establish explicit source precedence;
- expose a small locale-bound translation API;
- type translation keys;
- give packages/plugins a registration mechanism;
- centralize third-party UI translations;
- design fallback and RTL behavior as application features rather than afterthoughts.
Those principles transfer directly from Starlight to React, Next.js, server applications, component libraries, monorepos, and other TypeScript projects.
Top comments (0)