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:

FileHoldsWho writes it
src/package-messages.gen.tsone loader per language each installed package shipsyour build
src/locales/app.tsyour own rewordings, merged lastyou
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/package-messages.gen.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.

The generated half

Registering a plugin is the whole step. Every VitNode build reads the plugins in your vitnode.config.ts, takes the localeFiles each factory declares, and writes src/package-messages.gen.ts - core's own languages plus one block per plugin:

apps/web/src/package-messages.gen.ts
export const packageMessages: Record<string, LocaleMessagesMap> = {
  '@vitnode/core': {
    en: async () => await import('@vitnode/core/locales/en.json'),
  },
  '@acme/blog': {
    en: async () => await import('@acme/blog/locales/en.json'),
  },
}

Don't edit it - it is rewritten on every dev and build, which is why it sits in your .gitignore. Every specifier is a literal because that is the only kind a bundler can resolve: import(pkg + '/locales/' + locale + '.json') resolves to nothing. Every loader stays dynamic, so a language's JSON is a chunk of its own and the server loads only the locale a request asked for.

Packages ship English

@vitnode/core and the plugins in this repository ship en and nothing else. Every other language is the install's own, which is what the next section is for - and it is why a language you add is a file in your app rather than a pull request against a package.


Overriding Strings

Your own translations live in apps/web/src/locales/{pluginId}/{locale}.json, registered in src/locales/app.ts. The same file does both jobs: a whole language a package does not ship, and a reword of a string it does.

One file per package per language, holding that package's web and email strings together - an app keeps them in one tree where a package ships two, so the copy in an email cannot drift from the copy on the page.

To reword something core already says:

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.

A whole language looks exactly the same, because it is the same mechanism - this repository's own Polish is a pair of files nobody's node_modules contains:

apps/web/src/locales/app.ts
export const appMessages: AppMessagesMap = {
  pl: {
    '@vitnode/blog': async () => await import('./@vitnode/blog/pl.json'),
    '@vitnode/core': async () => await import('./@vitnode/core/pl.json'),
  },
}

If your app also serves the API, register the same map there so emails speak the language too - i18n.messages in vitnode.api.config.ts:

apps/web/src/vitnode.api.config.ts
export const vitNodeApiConfig = buildApiConfig({
  i18n: { ...vitNodeConfig.i18n, messages: appMessages }, 
  // ...
})

Your app's own strings

Copy that belongs to no package - your landing page, say - gets a namespace of its own. Pick an id no package uses, put the tree under it, and register it like any other file:

apps/web/src/locales/site/en.json
{
  "site": {
    "home": { "title": "A home for your people" }
  }
}
apps/web/src/locales/app.ts
export const appMessages: AppMessagesMap = {
  en: { site: async () => await import('./site/en.json') },
  pl: { site: async () => await import('./site/pl.json') },
}

i18n:check treats it like a package: your default-locale file is the source of truth, and every other language is checked against it for missing and leftover keys. No default-locale file at all is an error - there would be nothing to translate from.


Translation Architecture

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

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


Localized URLs

Strings aren't the only thing you can translate. The same i18n block decides how public URLs look in each language: a prefix (/pl/...), a domain per language (vitnode.pl), and translated paths such as /pl/odkrywaj for /discover:

apps/web/src/vitnode.config.ts
i18n: {
  defaultLocale: 'en',
  routePaths: {
    pl: { '/discover': '/odkrywaj' },
  },
},

Localized URLs covers the rules, domains, redirects and SEO.

Learn More