Skip to content
zap.ts
Esc
navigateopen⌘Jpreview
On this page

Internationalization

How zap.ts shows text in more than one language, where the words live, and how to add a locale.

Internationalization means showing your app in more than one language. zap.ts does it with Paraglide.

Paraglide is a tool that turns a file of translated words into normal TypeScript functions you call from your code.

Which apps have translations

Two apps do: apps/web and apps/marketing.

The other apps do not. apps/api sends data, not text for people to read. apps/admin and apps/docs ship in English only.

The locales that ship

A locale is one language. Both apps ship two:

  • en, English. This is the base locale.
  • fr, French.

You can see this in project.inlang/settings.json in either app:

{
  "baseLocale": "en",
  "locales": ["en", "fr"],
  "plugin.inlang.messageFormat": {
    "pathPattern": "./messages/{locale}.json"
  }
}

The base locale is the one the app falls back to. If a word is missing in French, the English one is used.

Where the words live

Each app has a messages folder, one file per locale:

apps/web/messages/
├── en.json
└── fr.json

Inside, each line is a key and the text it stands for:

{
  "account_password_title": "Password",
  "account_password_submit": "Change password"
}

apps/web has about 185 of these. apps/marketing has about 39.

How the app picks a language

Four ways, tried in this order. The first one that gives an answer wins.

  1. url — the language in the address, such as /fr/pricing.
  2. cookie — the language the user chose last time. A cookie is a small note the browser keeps for your site.
  3. preferredLanguage — the language set in the user’s browser.
  4. baseLocale — English, when nothing else answered.

This order is set in vite.config.ts in each app:

paraglideVitePlugin({
  project: "./project.inlang",
  outdir: "./src/paraglide",
  strategy: ["url", "cookie", "preferredLanguage", "baseLocale"],
});

Change the order there if you want different behaviour. For example, drop url if you do not want the language in the address.

Using a message in your code

Paraglide writes a src/paraglide folder for you. You import m from it, then call the key as a function:

import { m } from "../paraglide/messages.js";

<AlertDialog.Description>{m.confirm_irreversible()}</AlertDialog.Description>;

A message can take a value. In apps/marketing/messages/en.json:

{
  "legal_last_updated": "Last updated {date}"
}

You then pass it in: m.legal_last_updated({ date }).

Because these are real functions, TypeScript catches a key that does not exist and a value you forgot to pass.

Adding a new message

  1. Open messages/en.json in the app and add your key and its English text.
  2. Open messages/fr.json and add the same key with the French text.
  3. Run the compile step, shown below.
  4. Import m and call your new key.

Use the same key in every locale file. A key that exists in one file and not another falls back to the base locale.

You must compile after a change

The src/paraglide folder is generated. It is not saved to git. Editing a messages file changes nothing on its own, because your code reads the generated folder, not the JSON.

Run this in the app you changed:

pnpm --filter web run i18n:compile

Or for the marketing site:

pnpm --filter marketing run i18n:compile

Both run the same thing:

paraglide-js compile --project ./project.inlang --outdir ./src/paraglide

Adding a new locale

Say you want German.

  1. Add "de" to the locales list in project.inlang/settings.json.
  2. Copy messages/en.json to messages/de.json.
  3. Translate every line in messages/de.json. Keep the keys exactly as they are. Only change the text on the right.
  4. Run pnpm --filter web run i18n:compile.
  5. Start the app and switch to German to check it.

Do this in each app you want translated. The two apps have separate settings files and separate messages, so adding German to apps/web does not add it to apps/marketing.

Last updated on September 22, 2026

Was this page helpful?