---
title: Internationalization
description: 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](https://paraglidejs.com).

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:

```json
{
  "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:

```json
{
  "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:

```ts
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:

```tsx
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`:

```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:

```bash
pnpm --filter web run i18n:compile
```

Or for the marketing site:

```bash
pnpm --filter marketing run i18n:compile
```

Both run the same thing:

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

:::note
`pnpm run build` and `pnpm run typecheck` run `i18n:compile` first, so a build
is never out of date. The dev server also compiles, through the Vite plugin.
You mostly need the command by hand after a fresh clone, or when your editor
shows an error on a key you just added.
:::

## 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`.

## Related

- [Web app](/apps/web) — the app behind the sign-in
- [Marketing site](/apps/marketing) — the public site
- [Project structure](/project-structure) — what each folder holds
- [Local development](/guides/local-development) — running the apps
