Internationalization (I18n)

Server-side

Translate emails and API responses with the request-scoped translator.

The API gets the same message tree the frontend does, reachable in any route as c.get("i18n").

const t = await c.get("i18n").getTranslator();

return c.json({ message: t("core.global.save") });

Nothing is copied

The API reads locale files straight out of node_modules. There is no build step to remember and no src/locales/ folder to keep in sync - install a plugin and its translations are available on the server immediately.

The server loads a different, smaller set of strings than the frontend. Packages ship two trees - the frontend's UI copy in src/locales/, and the server's strings (emails) in src/locales/api/ - and the API only ever loads the second. An API-only app never pulls in the admin UI's messages.

Which locale a request gets

resolveLocale walks four options in order and takes the first one your app actually lists in i18n.locales:

An explicit choice

getTranslator("pl"), or the locale you passed to email.send(). Use it when the language is a property of the thing you are rendering rather than of the caller.

The signed-in user's language

core_users.language, already loaded on c.get("user").

The Accept-Language header

Parsed with its q weights, matched exactly first and then on the primary subtag - so pl-PL still finds pl.

defaultLocale

en unless your i18n config says otherwise.

Anything not in i18n.locales is skipped rather than used, so a stale language code on a user row cannot produce a half-English page.

Rendering for someone else

That chain describes the caller. When the language belongs to whoever you are rendering for rather than to whoever made the request - an email recipient, a queued job's target - reach for resolveSupportedLocale instead:

const locale = c.get("i18n").resolveSupportedLocale(recipient.language);

It takes the language you hand it when the app ships it and defaultLocale when it doesn't, and it never touches the request. Dropping a language from i18n.locales should send that user English, not the language of the admin who clicked the button.

The API

Prop

Type

Merged trees are cached per locale for the life of the process, so only the first call per language touches the filesystem.

Emails

Emails run through resolveSupportedLocale, so they render in the recipient's language or defaultLocale - never in the sender's. You rarely call the translator yourself:

src/api/modules/users/routes/welcome.route.ts
await c.get("email").send({
  user, // `user.language` decides the locale
  subject: ({ i18n }) => createTranslator(i18n)("welcome.email.subject"),
  content: props => <WelcomeEmail {...props} />,
});

Sending to a bare address instead of a user? Pass locale explicitly:

await c.get("email").send({
  to: "hello@example.com",
  locale: "pl", 
  subject: "Cześć",
  content: props => <WelcomeEmail {...props} />,
});

The template receives i18n as a prop and turns it into a translator with createTranslator from next-intl:

src/emails/welcome.tsx
import { createTranslator } from "next-intl";

export default function WelcomeEmail({ i18n }: DefaultTemplateEmailProps) {
  const t = createTranslator(i18n);

  return <Text>{t("welcome.email.body")}</Text>;
}

Shipping translations with a plugin

A plugin owns its languages, and it splits them the same way the framework does: frontend strings in src/locales/, server strings (emails) in src/locales/api/. Each tree gets a barrel and is registered with the matching config - the frontend tree with buildPlugin in config.tsx, the server tree with buildApiPlugin in config.api.ts.

Most plugins render nothing server-side, so they ship only the frontend tree and register messages in config.tsx alone. Add the api/ tree only when your plugin sends email:

plugins/{your_plugin}/src/locales/api/index.ts
import type { LocaleMessagesMap } from "@vitnode/core/lib/i18n/types";

const messages: LocaleMessagesMap = {
  en: async () => await import("./en.json", { with: { type: "json" } }),
};

export default messages;
plugins/{your_plugin}/src/config.api.ts
import messages from "./locales/api"; 

export const yourApiPlugin = () =>
  buildApiPlugin({
    pluginId: CONFIG_PLUGIN.pluginId,
    messages, 
    modules: [postsModule],
  });

Adding a language later is a new file plus one line in the barrel - apps pick it up on their next install, and can translate your plugin without forking it by dropping a file in their own src/locales/{your_plugin}/.

Typing the keys

t() autocompletes from a global.d.ts that augments next-intl's Messages. Import the tree an app actually uses: an API-only app types against the server tree alone, a frontend-only app against the frontend tree, and a single app - one that serves the frontend and runs the API together - against both.

global.d.ts (single app)
/// <reference types="next-intl" />

import coreApi from "@vitnode/core/locales/api/en.json" with { type: "json" };
import core from "@vitnode/core/locales/en.json" with { type: "json" };

declare module "next-intl" {
  interface AppConfig {
    Messages: typeof core & typeof coreApi; 
  }
}

Keys must be namespaced

Everything a plugin ships belongs under its own namespace, or it will collide with core. See Namespaces.

When a translation goes missing

VitNode says so, once per package and locale, rather than quietly rendering raw keys:

[VitNode i18n] Could not load "pl" messages for "@vitnode/blog" - its strings will render as raw keys.

Run vitnode i18n:check to find gaps before your users do.

On this page