Add a Language
Ship your VitNode app in more than one language.
VitNode is English (en) out of the box. Every package - @vitnode/core and each plugin - carries its own translations inside node_modules, and VitNode merges them at request time. Nothing is copied into your app, so adding a language is two files and a config entry.
How messages are put together
Sources are merged in order, and later ones win:
@vitnode/core → plugin A → plugin B → your appOn top of that, the default locale is merged underneath the language being rendered. A half-finished translation therefore degrades key by key - untranslated strings show up in your default language instead of leaking core.global.save into the page.
You only write what you change
Your app's locale files are overrides. A file with three keys in it is perfectly valid; the other few hundred come from the packages.
Adding one
The CLI does the whole thing. It asks for the language's code and name, scaffolds an override file for every installed package, and wires the locale into your i18n config:
vitnode i18n:create[VitNode] Add a language. Press Ctrl+C to abort.
? Locale code (e.g. pl, de, pt-BR): de
? Language name (e.g. Polski, Deutsch): Deutsch
created src/locales/@vitnode/core/de.json
created src/locales/@vitnode/blog/de.json
updated src/i18n.ts
[VitNode] Deutsch (de) added. Translate the files above, then run vitnode i18n:check.Pass both inline to skip the prompts - handy in scripts and CI: vitnode i18n:create de Deutsch.
What it changes
Two things: one override file per installed package under
src/locales/{pluginId}/{locale}.json, each seeded with that package's English
strings so they are right there to translate in place, and your i18n config -
the { code, name } entry in locales plus a messages block pointing at
those files. An app with no i18n config yet - an API-only app, say - gets a
fresh src/i18n.ts instead, and the command prints the one line to import it
into your config.
It seeds only what the app renders
The seed matches the app's shape. An API-only app is seeded with each package's server strings alone - the emails, a few keys - not the admin UI it never renders. A frontend app gets the UI strings, and a single app (frontend and API together) gets both. A package that ships nothing for the app's shape - a plugin with no emails in an API-only app - is skipped rather than seeded with an empty file. See Server-side for the two trees.
Fill in the files it lists and you are done - the rest of this page is what create does under the hood, for when you would rather wire a language up by hand or the command meets an unusual config it will not edit blindly.
By hand
Create the locale file
App translations live in src/locales/{pluginId}/{locale}.json, one file per package you want to translate. Keys mirror the package's own en.json.
{
"core": {
"global": {
"save": "Zapisz",
"cancel": "Anuluj"
}
}
}Declare the locale and point at the file
Both entries live in your i18n config. Keep it in its own file when your app serves the web and the API together, so the two configs cannot drift apart.
import type { VitNodeI18nConfig } from "@vitnode/core/lib/i18n/types";
export const i18n = {
defaultLocale: "en",
locales: [
{ code: "en", name: "English" },
{ code: "pl", name: "Polski" },
],
messages: {
pl: {
"@vitnode/core": () => import("./locales/@vitnode/core/pl.json"),
"@vitnode/blog": () => import("./locales/@vitnode/blog/pl.json"),
},
},
} satisfies VitNodeI18nConfig;Write the path out in full
It has to be a literal path. A template literal such as () => import(`./locales/${pluginId}/${locale}.json`) is invisible to TypeScript
and to the bundler, so the JSON never makes it into your production build and
every string renders as its raw key.
Then hand that object to whichever configs your app has:
import { i18n } from "./i18n";
export const vitNodeConfig = buildConfig({
i18n,
plugins: [blogPlugin()],
metadata: { title: "VitNode" },
});import { i18n } from "./i18n";
export const vitNodeApiConfig = buildApiConfig({
i18n,
plugins: [blogApiPlugin()],
dbProvider: drizzle({ connection: POSTGRES_URL, casing: "camelCase" }),
});Check your work
vitnode i18n:checkIt lists every key the new language is still missing and flags keys that no longer exist in the default locale. It fails outright - a non-zero exit on its own - when a declared language is missing a package's locale file, or when a file you created was never wired into i18n.messages. Those are hard errors, since both leave strings silently untranslated at runtime. Missing individual keys are only warnings; add --ci to make those fail a pipeline too.
Keeping them in sync
Packages gain and lose keys as they evolve. i18n:update reconciles every translation file you have against the current English source in one pass:
vitnode i18n:updateFor each of your override files it:
- adds keys English has but the file is missing, seeded with the English string so they are ready to translate in place;
- removes keys the file still carries that English no longer defines;
- keeps every existing translation untouched - it never overwrites a string you have already translated.
[VitNode] Syncing 2 translation file(s) against en.
updated src/locales/@vitnode/core/pl.json +75 -7
+ core.search.admin.reindex
- core.search.admin.engine
... and 80 more
[VitNode] 2 file(s) updated: +75 added, -7 removed. Translate the added keys, then run vitnode i18n:check.Like the other commands it is scoped: an API-only app is synced against each package's server strings alone, a single app against both trees. A file whose package ships nothing for the app's shape is left untouched rather than emptied.
Added keys arrive in English
A freshly added key holds the English text until you translate it, so
i18n:check will report it as present. Run i18n:update to pull in new
strings, then work through the ones it added.
Translating with AI
i18n:update:ai does everything i18n:update does, then hands the keys that are still in English to a configured AI model to fill in. It reconciles each file against the English source first, so newly added keys are translated in the same pass:
vitnode i18n:update:aiIt asks two things:
- Which languages to translate - a checklist of every language you have, all ticked by default.
spacetoggles one,atoggles all,Enterconfirms. - Which model to translate with - the list comes straight from the
ai.modelsin yourvitnode.api.config.ts. The first entry is the default.
? Which languages should be translated from English?
❯◉ Polski (pl)
◉ Deutsch (de)
(space to toggle, a to select all, enter to confirm)
? Which AI model should translate? (from vitnode.api.config.ts)
❯ Claude Sonnet 5 anthropic/claude-sonnet-5
Google Gemini 3.5 Flash Lite gemini-3.5-flash-lite
[VitNode] Translating with Claude Sonnet 5 (anthropic/claude-sonnet-5) into: pl
pl 128 string(s) (96 unique)
96 unique string(s) in 3 batch(es), up to 3 in parallel
translated 3/3 batch(es)
updated src/locales/@vitnode/core/pl.json → pl +112
updated src/locales/@vitnode/blog/pl.json → pl +16
[VitNode] 2 file(s) updated, 128 string(s) translated.Translation runs through the Vercel AI SDK, so it works with any model you have configured - a Gateway id string or a provider instance. Make sure the matching credentials are set (e.g. AI_GATEWAY_API_KEY) before you run it.
How it stays fast and cheap
The work is network-bound - almost all the time is spent waiting on the model - and tokens are the cost, so the command is built to overlap the waiting and send as little as possible:
- Batches run concurrently. All the strings across every selected language and file are cut into fixed-size batches and pushed through a bounded pool (6 at a time by default). Tune it with
--concurrency <n>- raise it if your provider's rate limits allow, lower it if you hit429s. - Identical strings are translated once.
Save,Cancel, and friends recur across many keys; each unique English string is sent once per language and the result is fanned back out to every key that used it (see the(96 unique)count above). - Only untranslated strings are sent. Keys you have already translated, and values with nothing to translate (a lone
{count}, punctuation), never reach the model. - Compact wire format. Each batch is a bare JSON array of strings in, a JSON array of translations out - no per-item keys - which roughly halves the tokens versus a labelled
{ id, value }shape.temperature: 0keeps it deterministic, so re-runs don't churn. - A failed batch doesn't sink the run. Each batch retries with backoff; if it still fails, those strings are left in English and reported so a re-run picks them up - the rest are written normally.
Monorepo layouts
When the app you run this from has no ai.models of its own - a web app whose
API lives in a sibling package (apps/web next to apps/api) - it looks one
directory up for a vitnode.api.config.ts that does. Only the model list is
borrowed; which files are translated still comes from the app you are in. That
API app's .env is loaded too, so the provider keys (AI_GATEWAY_API_KEY,
GOOGLE_GENERATIVE_AI_API_KEY, ...) come from where the models are defined.
- It only translates leaves that still equal the English source, so a string you have already translated is never touched - run it as often as you like.
- Placeholders (
{name}, ICU plurals,<b></b>tags) and pure-punctuation values are preserved; the latter are skipped rather than sent to the model. - Pass the languages, model, and concurrency inline to skip the prompts in CI:
vitnode i18n:update:ai pl,de --model fast --concurrency 8.
Review machine translations
AI translations are a strong first draft, not a final one. Skim what it wrote - especially product terms and tone - before you ship. The files are already reconciled and translated in the same pass, so there is nothing to re-check.
Removing one
To drop a language, hand i18n:delete its code. It deletes that locale's override files, prunes the folders they leave empty, and unwires the { code, name } and messages entries from your config:
vitnode i18n:delete plRun it without a code to be asked for one. When interactive it lists everything it will remove and waits for a y before touching anything.
English and the default locale stay put
i18n:delete refuses to remove en - the built-in fallback every translation
degrades to - or whatever your defaultLocale is. Point defaultLocale at
another language first if you really mean to retire the current one. To take a
language out of circulation without deleting it - English included - disable
it instead (below).
Disabling one
Deleting a language throws its files away. When you only want to take a language
out of circulation - keep the translations on disk, just stop offering it - set
enabled: false on its locales entry instead:
export const i18n = {
defaultLocale: "en",
locales: [
{ code: "en", name: "English" },
{ code: "pl", name: "Polski", enabled: false },
],
// ...messages unchanged
} satisfies VitNodeI18nConfig;A disabled locale is no longer selectable: it drops out of the language
pickers built on it (the multi-language form field) and search stops indexing
documents for it. Everything else stays put - its src/locales files remain on
disk, its row and translated words in the database are untouched, and it still
resolves at runtime as a fallback. Flip enabled back to true (or drop the
field) and it returns exactly as it was.
You can disable the default locale too
Unlike deletion, disabling works on any locale, en and your defaultLocale
included - handy for a site that runs in another language but keeps English as
the technical fallback. The default locale still merges underneath every other
language whether or not it is enabled, so disabling it only hides it from the
pickers; it never leaves strings untranslated.
The API side
An API-only app needs no i18n block at all. The languages the installed packages ship are picked up on their own, and defaultLocale is en:
export const vitNodeApiConfig = buildApiConfig({
plugins: [blogApiPlugin()],
dbProvider: drizzle({ connection: POSTGRES_URL, casing: "camelCase" }),
});Add one when you want to restrict the locale list, change the default, or override strings. See Server-side for what the API does with them.
Searching in several languages
Each row in core_search_index carries a languageCode, so a post indexed in Polish is only matched by Polish queries. Rows with an empty languageCode match every language - see Search for indexing multi-language content.