Languages & Localization
How VitNode merges translations from installed packages, and how to add, translate, and override languages.
VitNode provides full internationalization out of the box. Every package (@vitnode/core and plugins) maintains its own locale files, which VitNode merges per request: core strings first, plugin strings second, and your host app overrides last.
Quick start
Add a new language in two CLI commands:
bun run vitnode i18n:create de Deutsch
bun run vitnode i18n:checki18n:create adds the language to src/vitnode.config.ts, seeds a translation file per installed package, and registers the loaders in src/locales/app.ts. i18n:check scans for missing or untranslated keys - including a file nobody imports, which is the usual reason a translation "does not apply".
Language Configuration
Locale metadata is plain data, so it lives in the browser-safe shared config - one declaration, read by the router, the document shell and (in a single app) the API that sends your emails:
export const vitNodeConfig = buildConfig({
i18n: {
defaultLocale: 'en',
locales: [
{ code: 'en', name: 'English' },
{ code: 'de', name: 'Deutsch' },
],
timeZone: 'UTC',
},
// ...
})timeZone is explicit on purpose: your app renders on a server, and without one use-intl formats dates in whatever zone the server happens to run in - then warns that the client will disagree.
Because buildConfig keeps those codes as literal types, 'de' is now part of your Locale union and a typo in defaultLocale is a type error.
Where the loaders go
A () => import('./de.json') reads a file out of a package's build output, so it is the one part of i18n that must never reach a browser. Two files own it, and both are registered through the server-only config:
| File | Holds |
|---|---|
src/locales/packages.ts | one loader per language each installed package ships |
src/locales/app.ts | your own rewordings, merged last |
export const vitNodeServerConfig = buildServerConfig({
config: vitNodeConfig, // the locale list above
messages: appMessages, // src/locales/app.ts
packageMessages, // src/locales/packages.ts
})Not in vitnode.config.ts
Putting a loader in the shared config puts every plugin's AdminCP copy in your
browser bundle, and makes your Vite build execute it. vitnode i18n:create
writes to the right file for you.
Adding a language to a package that ships it needs one line in src/locales/packages.ts:
[CORE.pluginId]: {
en: async () => await import('@vitnode/core/locales/en.json'),
de: async () => await import('@vitnode/core/locales/de.json'),
},Overriding Strings
To customize existing text from core or a third-party plugin, add an override file in apps/web/src/locales/{pluginId}/{locale}.json and register it in src/locales/app.ts:
{
"core": {
"global": {
"save": "Update Changes"
}
}
}export const appMessages: AppMessagesMap = {
en: {
'@vitnode/core': async () => await import('./@vitnode/core/en.json'),
},
}Because your app overrides are merged last, only the keys you specify are overwritten. Everything else continues to fall back to the package defaults.
Translation Architecture
| Source | Role | Order |
|---|---|---|
@vitnode/core | Base strings for auth, admin shells, and dialogs | Base layer |
| Plugins | Domain strings declared in plugins/*/src/locales | Second layer |
| Host Application | Custom overrides in apps/web/src/locales | Highest priority (wins) |
Missing keys automatically fall back to defaultLocale (en), preventing raw key paths from displaying in production.