Translations
Write translatable text with Lingui, extract and compile the catalogs of each package, translate server error messages, and add a new language to Neatship.
Neatship speaks English and French from the first screen. This guide shows how to write text so it can be translated, how to update the translation files, how the server translates its error messages, and how to add a third language.
Translations use Lingui, a translation library. The words live in catalogs: one .po file per language, a plain-text format that translators and translation tools know. English is the source language: you write English in the code, and the catalogs hold the other languages.
Where the catalogs are
| Package | Catalogs | Languages |
|---|---|---|
| Web app | packages/app-front/src/locales/ | en.po, fr.po, plus pseudo-en.po (a fake language Lingui generates, to spot untranslated text) |
| Server | packages/app-server/src/engine/core-modules/i18n/locales/ | en.po, fr.po |
| Website | packages/app-website/src/locales/ (home page, header, footer) and packages/app-website/src/locales/feature-pages/ (the feature pages) | en.po, fr.po |
Each package compiles its catalogs into TypeScript files in a generated/ folder next to them. Don't edit those by hand.
Write translatable text
Every word a person sees goes through Lingui. Retrofitting translations later means touching every screen.
Inside a component, use t from the useLingui hook:
import { useLingui } from '@lingui/react/macro';
export const DeleteProjectTitle = ({ projectName }: DeleteProjectTitleProps) => {
const { t } = useLingui();
return <h2>{t`Delete ${projectName}?`}</h2>;
};The variable becomes a placeholder in the catalog: Delete {projectName}?. Translators move it where their language needs it.
For text inside JSX with markup, use Trans:
import { Trans } from '@lingui/react/macro';
<Trans>
Replace the line in <code>{filePath}</code> with:
</Trans>Outside components (constants, lists, error messages), write a message descriptor with msg. It is translated later, when shown:
import { msg } from '@lingui/core/macro';
export const getPlanLabel = (planKey: PlanKey): MessageDescriptor => {
switch (planKey) {
case 'free':
return msg`Free`;
case 'pro':
return msg`Pro`;
case 'business':
return msg`Business`;
default:
return assertUnreachable(planKey);
}
};
// In the component that shows it:
const { i18n } = useLingui();
i18n._(getPlanLabel(plan.key));For counts, use plural, so each language can have its own plural forms:
import { plural } from '@lingui/core/macro';
plural(memberCount, { one: '# member', other: '# members' });A few habits from docs/conventions/04-frontend.md and 06-design-system.md:
- UI text is in sentence case ("Connected accounts", not "Connected Accounts"), and buttons start with a verb ("Save", "Add filter").
- Write acronyms in capitals in messages: "API", "URL", "ID".
- Don't put an apostrophe right before a placeholder: the message format then prints the placeholder literally.
Update the catalogs
After adding or changing text, run the two commands of the package you changed:
npx nx run app-front:lingui:extract
# translate the new entries in packages/app-front/src/locales/fr.po
npx nx run app-front:lingui:compileThe same targets exist for app-server and app-website.
- Extract scans the code, adds new messages to every catalog and removes the ones no longer used.
- Compile turns the catalogs into the TypeScript files the app loads.
A new entry in fr.po looks like this before you translate it:
#. js-lingui-id: DLw3jv
#: src/modules/home/components/GetStartedCard.tsx
msgid "(done)"
msgstr ""Fill in msgstr with the French text, keep every {placeholder} exactly as written, then compile. The French uses the formal "vous".
yarn verify runs extract and compile for each package your change touches and lists any French entry still empty. CI runs them for the web app, the server and the website, and fails if the catalogs change: when your change adds or edits text, commit the updated catalogs. When it doesn't, don't commit catalog files that only changed because you ran the commands.
Tip: An AI assistant translates well if you give it the rules: "Translate the empty entries of packages/app-front/src/locales/fr.po into French, formal 'vous', keep placeholders, sentence case, then run npx nx run app-front:lingui:compile." Read the result anyway: tone and product words matter.
How the web app picks a language
- A
?locale=frin the address wins. - Otherwise, the language the person chose before (saved in the browser).
- Otherwise, the browser's preferred languages.
- English if none of them is supported.
People switch language in Settings → Profile or Settings → Appearance, or from the command menu (Cmd/Ctrl + K). Only the active language is downloaded. With Clerk sign-in, Clerk's own screens switch language at the same time, and Clerk's word "organization" is replaced by "workspace" in both languages.
Server messages, translated per request
The server sends error messages that people read, for example "A project with this name already exists." They are written with msg in each feature's exception file (see Add a feature):
case 'PROJECT_NAME_ALREADY_USED':
return msg`A project with this name already exists. Choose another name.`;The web app sends its active language in an x-locale header on every request. The server's GraphQL error handler translates userFriendlyMessage into that language (en or fr; English when the header is missing or unknown). You can see it with the API directly:
curl -s http://localhost:3100/graphql \
-H 'content-type: application/json' \
-H 'x-locale: fr' \
-H 'x-mock-user-email: anonymous' \
-d '{"query":"{ projects { name } }"}'The answer's extensions.userFriendlyMessage reads "Votre session a pris fin. Reconnectez-vous pour continuer."
After adding server messages, run npx nx run app-server:lingui:extract, translate packages/app-server/src/engine/core-modules/i18n/locales/fr.po, and run npx nx run app-server:lingui:compile.
Add a language
Say you add German (de).
Declare it. Add
'de'toAPP_LOCALESinpackages/app-shared/src/constants/AppLocales.ts, then rebuild the shared package:npx nx build app-shared --skip-nx-cache.Add it to each Lingui config. Add
'de'tolocalesinpackages/app-front/lingui.config.ts,packages/app-server/lingui.config.tsandpackages/app-website/lingui.config.ts.Create and fill the catalogs. Run
lingui:extractfor each of the three packages, translate the newde.pofiles, then runlingui:compile.Let the compiler guide you. Run
npx nx run-many -t typecheck. Every list keyed by language now reports the missing entry:File What to add packages/app-front/src/modules/localization/constants/LocaleNativeNames.tsthe language's name, written in itself ( Deutsch)packages/app-front/src/modules/auth/clerk/utils/loadClerkBaseLocalization.tsClerk's own translation for that language, from @clerk/localizations, if Clerk offers onepackages/app-server/src/engine/core-modules/i18n/utils/translate-message.util.tsthe server's compiled German catalog packages/app-website/src/modules/localization/constants/LocaleNativeNames.tsthe language's name packages/app-website/src/modules/localization/constants/CatalogMessages.tsandutils/loadCatalogMessages.tsthe landing page's German catalog packages/app-website/src/modules/seo/constants/OpenGraphLocales.tsthe social-media locale code ( de_DE)Two places the compiler doesn't flag:
- The command menu lists one "Switch to …" command per language, written out for English and French, in
packages/app-front/src/modules/command-menu/hooks/useCommandMenuCommands.ts. Add one for German. - The database only accepts known languages for a person's
locale: the CHECK constraintCHK_USER_LOCALEinpackages/app-server/src/engine/core-modules/user/user.entity.tslists'en', 'fr'. Add'de', give the constraint a new name (the migration generator compares CHECK constraints by name only, so an edited expression under the same name goes unnoticed), then generate a migration withnpx nx run app-server:database:migrate:generate --name=add-german-locale.
- The command menu lists one "Switch to …" command per language, written out for English and French, in
Check everything:
npx nx run-many -t lint typecheck test.
The landing page builds one page per language automatically: German will be served at /de/.