Internationalization (I18n)

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:

Add a language
bun run vitnode i18n:create de Deutsch
bun run vitnode i18n:check

i18n: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:

apps/web/src/vitnode.config.ts
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:

FileHolds
src/locales/packages.tsone loader per language each installed package ships
src/locales/app.tsyour own rewordings, merged last
apps/web/src/vitnode.server.config.ts
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:

apps/web/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:

apps/web/src/locales/@vitnode/core/en.json
{
  "core": {
    "global": {
      "save": "Update Changes"
    }
  }
}
apps/web/src/locales/app.ts
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

SourceRoleOrder
@vitnode/coreBase strings for auth, admin shells, and dialogsBase layer
PluginsDomain strings declared in plugins/*/src/localesSecond layer
Host ApplicationCustom overrides in apps/web/src/localesHighest priority (wins)

Missing keys automatically fall back to defaultLocale (en), preventing raw key paths from displaying in production.

Learn More