Internationalization

NocoBase 3 supports multiple languages. This page covers writing translatable text, organizing locale files, and overriding a plugin's wording. If all you want is to switch language or add one, Built-in capabilities / Language switching is enough.

switch language

How a translation works

For a piece of text on screen to be translatable, it has to be written in a locale file and referenced from the component by its key.

You can tell a Coding Agent directly:

Make the title and empty-state text on the orders list page translatable, in both English and Chinese.

It adds the wording to client/locales/en-US.ts and zh-CN.ts, then edits the component.

The wording looks like this:

// client/locales/en-US.ts
orders: {
  title: 'Orders',
  empty: 'No orders yet',
},
// client/locales/zh-CN.ts
orders: {
  title: '订单',
  empty: '还没有订单',
},

A name like orders.title is the key: the same key in both files addresses the same sentence in each language.

And the component:

import { useTranslation } from '@nocobase/i18n/client';

export default function OrdersPage() {
  const { t } = useTranslation();

  return <h1>{t('orders.title')}</h1>;
}

To include a value, pass it in rather than concatenating. The wording holds a {{name}} placeholder:

// client/locales/en-US.ts
orders: {
  count: '{{count}} orders',
},
t('orders.count', { count: orders.length }); // 3 orders, when orders.length is 3

Where the locale files are

An application's own translations live in client/locales/:

FileWhat it does
en-US.tsStates which keys exist, and the English wording
zh-CN.tsThe Chinese wording, following the structure of en-US.ts
index.tsRegisters the languages, one dynamic import each

en-US.ts is the source. It exports a type that every other locale is annotated with, so a key you forget to translate is a compile error that pnpm typecheck reports:

// client/locales/en-US.ts
import type { LocaleResource } from '@nocobase/i18n';

const enUS = {
  orders: {
    title: 'Orders',
    empty: 'No orders yet',
  },
};

export type AppResource = LocaleResource<typeof enUS>;
export default enUS;
// client/locales/zh-CN.ts
import type { AppResource } from './en-US.js';

const zhCN: AppResource = {
  orders: {
    title: '订单',
    empty: '还没有订单',
  },
};

export default zhCN;

Keys nest, and a dot addresses them: t('orders.title').

There is a matching server/locales/, built the same way. Text the server produces — API errors, the body of an email — goes there; plain interface text does not.

Overriding a plugin's wording

A plugin's wording will not always match how your business talks, and you can override it from your own locale file.

When handing this to a Coding Agent, point at the text on screen:

The workflow plugin shows "Workflow" in the left menu. We call it "Approvals" internally. Change it to our wording, in both English and Chinese.

State two things: where on screen, and what it should say instead. Where matters, because one word can map to several keys inside a plugin — the menu entry and the page title are two of them — and without that the wrong one gets changed, or one gets missed.

The Agent adds an overrides block to your locale file, grouped by the plugin's package name:

const zhCN: AppResource = {
  // ...your own wording

  overrides: {
    '@nocobase/app-plugin-workflow': {
      nav: { title: '审批流程' },
    },
  },
};

Overrides are applied after every plugin has registered, so your wording wins regardless of load order.

Overriding is for a difference in what something is called — the plugin says "Workflow", your company says "Approvals". If a plugin has not translated a language at all, that belongs in an issue against the plugin rather than here.

Language on the server and in background jobs

The interface follows the browser. The server does not.

An API error follows the request. Take a translator inside the route; the middleware has already resolved the language:

import { getRequestTranslator } from '@nocobase/i18n/server';

const t = getRequestTranslator(context);
t('errors.orderNotFound');

A background job has no request to follow, so it has to decide for itself. Scheduled jobs, one-off jobs, and webhooks are all in this position, and there is one rule that is easy to get wrong:

Note

Outbound content follows its recipient, not whoever triggered it.

A submits a request, which notifies B. That notification belongs in B's language. Sending it in A's language, or in the server's default, is wrong.

// Load the recipient's language before translating
await i18n.ensureLocaleLoaded(recipient.locale);
const t = i18n.getFixedT(NS, recipient.locale);
t('notification.approved', { orderNo: order.no });

Skipping ensureLocaleLoaded does not throw — it quietly falls back to the default language, which a test will not necessarily catch, so it has to be remembered while writing.

Sending to several people means resolving each recipient's language separately; one language for a batch is not it.

Adding a language

Add a locale file under client/locales/ and register it in index.ts; add one under server/locales/ too when the server has wording of its own. Those files are what decides the languages an application offers — there is no second list to keep in step.

The full procedure, and what to ask a Coding Agent for, is in Built-in capabilities / Language switching.

Checking your work

pnpm typecheck                  # a key left untranslated fails here
pnpm nocobase locales check     # whether both sides declare the same languages

Common questions

I changed the wording in a component and the page looks the same

Check that you edited the file for the language you are viewing. Also check whether the text belongs to a plugin — a plugin's wording is changed through overrides, and editing your own key does nothing.

A component renders its keys, or fails, inside a test

Rendered on its own, that component has no i18n runtime. A defaultValue keeps it readable either way:

t('orders.title', { defaultValue: 'Orders' });

A component a plugin exports resolves the wrong translation

Say @acme/app-plugin-crm exports a component using the key orders.title, and the application happens to have a key by the same name. A translation is resolved by where a component renders, not by which package wrote it, so without anything further the application's wording wins.

Such a component has to name its own namespace — which is the plugin's package name:

// One way: name it where the translator is taken
const { t } = useTranslation('@acme/app-plugin-crm');
// The other: wrap the component, and write useTranslation() inside as usual
import { withNamespace } from '@nocobase/i18n/client';

export default withNamespace('@acme/app-plugin-crm', CustomerCard);

The test to apply: will this component ever render outside its own plugin's pages? Then name the namespace.