---
title: "Languages and formats"
description: "Translate messages and format money, dates and numbers per locale, with right-to-left and Indian scripts and missing strings caught by the check."
section: "interfaces"
status: "Preview · ships as a beta API"
spec: "i18n"
canonical: "https://mainsdk.dev/docs/interfaces/i18n"
updated: "2026-10-28"
---

# Languages and formats

> Translate messages and format money, dates and numbers per locale, with right-to-left and Indian scripts and missing strings caught by the check.

Status: Preview · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/i18n · Updated: 2026-10-28

`i18n` translates your messages and formats money, dates and numbers for each locale, with right-to-left and Indian scripts working from the start. Reach for it on the first string a user reads.

> **Note:** `i18n` is built and runs from the repository (spec `i18n`, ADR-0060, milestone 0.4); it is not published to npm. The catalog still lists it as planned until the milestone closes.

## At a glance

- Status: Preview · ships as a beta API
- Phase: 1 (Core, public beta)
- Milestone: 0.4 Hardening
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/i18n.md
- Category: Platform, OS and network

Calls:

- `t(key, values)`
- `i18n.locale()`
- `i18n.negotiate(req, { user })`
- `i18n.with(locale, fn)`
- `i18n.format.number(n, opts)`
- `i18n.format.money(amount, currency)`
- `i18n.format.date(d, opts)`
- `i18n.format.list(items, opts)`
- `i18n.format.relative(d)`
- `i18n.dir(locale)`
- `i18n.drafts.list({ locale })`
- `i18n.drafts.approve(id, { text })`
- `i18n.drafts.reject(id, { reason })`
- `new Catalog(cfg, messages?)`
- `new Drafts(o)`
- `FORMATS(const)`
- `I18N_DRIVERS(const)`
- `new I18n(o)`
- `NEGOTIATE_SOURCES(const)`
- `PSEUDO_LOCALES(const)`
- `checkMessages(o)`
- `compileMessage(src)`
- `createI18n(o)`
- `crowdinSource(o)`
- `detectSyntax(src)`
- `exportBundle(format, b, opts?)`
- `extractKeys(source, file)`
- `fileCache(file)`
- `fileDraftStore(dir)`
- `formatMessage(m, ctx)`
- `formatsFor(locale)`
- `icuToMf2(src)`
- `importBundle(format, text)`
- `lastGood(source, cache, { log })`
- `localeDir(locale)`
- `localeSupported(locale)`
- `lokaliseSource(o)`
- `matchLocale(tag, locales)`
- `memoryDraftStore()`
- `negotiate(o, req, ctx?)`
- `parseAcceptLanguage(header)`
- `parseI18nConfig(raw)`
- `parseIcu(src)`
- `parseMf2(src)`
- `printMf2(m)`
- `pseudoMessage(src, locale)`
- `pseudoMessages(messages, locale)`
- `readGlossary(file)`
- `readLocales(dir)`
- `splitPlural(src)`
- `tolgeeSource(o)`
- `variablesOf(m)`
- `writeLocale(dir, locale, { messages })`
- `i18n.handle(req, next, { user? })`

In main.yaml:

```yaml
i18n:
  driver: files
  locales: [en-IN, hi-IN, ta-IN]
```

Guarantees, whatever the driver:

- A missing translation fails the check, not the user
- Formats from Unicode CLDR through the runtime's Intl, with no locale data shipped
- Locale from the user's choice first, never guessed from IP alone
- A production page never shows a message key
- AI translations reach users only after a person approves them


## Drivers

Pick one in main.yaml with `i18n.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Local YAML files | `files` | yes |  |
| Tolgee | `tolgee` |  |  |
| Crowdin | `crowdin` |  |  |
| Lokalise | `lokalise` |  |  |
| AI draft with human review | `ai-draft` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-I18N-0001` | check | A key is missing from a required locale |
| `MAIN-I18N-0002` | check | A locale isn't supported by the target runtime's `Intl` |
| `MAIN-I18N-0003` | check | A message has a syntax error, or its variables differ from the default locale's |
| `MAIN-I18N-0006` | check | `review: none`: AI drafts reach users without review (warning) |
| `MAIN-I18N-0008` | check | `i18n.negotiate` lists `ip` or an unknown source |
| `MAIN-I18N-0004` | runtime | A message was missing and a fallback was used (logged once per key and locale) |
| `MAIN-I18N-0005` | runtime | `t()` was called without a value its message needs (logged) |
| `MAIN-I18N-0007` | runtime | An agent or service tried to approve a translation draft (403) |
| `MAIN-I18N-0009` | runtime | No message for the key in any locale; production never shows the key (logged) |
| `MAIN-I18N-0010` | runtime |  |
| `MAIN-I18N-0011` | runtime |  |
| `MAIN-I18N-0012` | runtime |  |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| Messages in MessageFormat 2 with ICU fallback | 1 | 0.4 | `i18n#mf2` |
| Numbers, money, dates, lists and relative times from Intl | 1 | 0.4 | `i18n#formats` |
| Locale negotiated per request | 1 | 0.4 | `i18n#negotiate` |
| Fallback chains | 1 | 0.4 | `i18n#fallback` |
| Missing keys and placeholder mismatches in check | 1 | 0.4 | `i18n#checks` |
| Pseudo-localization | 1 | 0.4 | `i18n#pseudo` |
| AI drafts with a glossary and a person's review | 1 | 0.4 | `i18n#ai-draft` |
| XLIFF, xcstrings, Android strings and ICU JSON import and export | 1 | 0.4 | `i18n#interchange-drivers` |
| Android and iOS string files from the same locales | 3 | 1.1 | `expo-adapter` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```yaml title="main.yaml"
i18n:
  driver: files
  locales: [en-IN, hi-IN, ta-IN]
```

```yaml title="locales/en-IN.yaml"
orders.count: "{count, plural, one {# order} other {# orders}}"
```

```ts title="app/orders/heading.ts"
import { i18n, t } from "mainsdk";

export function heading(orders: unknown[]) {
  const title = t("orders.count", { count: orders.length });
  const total = i18n.format.money(125000, "INR");   // ₹1,25,000.00 in en-IN
  return `${title} · ${total}`;
}
```

## Configuration

```yaml title="main.yaml"
i18n:
  driver: files                    # files | tolgee | crowdin | lokalise | ai-draft
  default: en-IN
  locales: [en-IN, hi-IN, ta-IN]   # the locales you ship
  required: [en-IN, hi-IN]         # check fails on a missing key in these
  negotiate: [choice, account, header]   # never ip
  cookie: locale                   # cookie that holds the reader's explicit choice
  dir: locales                     # folder of locale files
```

| Key | What it does |
| --- | --- |
| `driver` | `files` (default), `tolgee`, `crowdin`, `lokalise` or `ai-draft` |
| `default` | The locale every chain ends in; `en` when unset |
| `locales` | The locales you ship; the default alone when unset |
| `required` | Locales where a missing key fails `mainsdk check` |
| `fallback` | Per-locale chain, such as `fallback: { hi-IN: [hi, en-IN] }` |
| `negotiate` | Sources in order: `choice`, `account`, `header` |
| `ai-draft` | `model`, `glossary` (default `locales/glossary.yaml`) and `review` per locale (`person`, the default, or `none`) |
| `crowdin`, `tolgee`, `lokalise` | `project`, `token` (a `secret:` reference), `base-url`, `download-host`, `language-ids` for the provider driver you pick |
| `runtime.icu` | `full` or `small`; tells `check` which ICU data your runtime has |

An unknown key under `i18n` stops the app at load with `MAIN-CONFIG-0003`, naming the key.

Locale files live in `locales/<locale>.yaml` and fall back along a chain: `hi-IN`, then `hi`, then `en-IN`, then `en`. Templates follow the same chain. A market overlay can set its own locales; see [main.yaml](https://mainsdk.dev/docs/concepts/main-yaml).

```yaml title="main.in.yaml"
i18n:
  locales: [en-IN, hi-IN, ta-IN]
```

## What you get

Messages use Unicode MessageFormat 2, and ICU MessageFormat strings still load. Formats come from Unicode CLDR through the runtime's `Intl`, so ₹1,25,000 shows correctly in India and MainSDK ships no locale data.

## Examples

### Right-to-left layout

```ts title="app/html-attributes.ts"
import { i18n } from "mainsdk";

export function htmlAttributes() {
  return { lang: i18n.locale(), dir: i18n.dir() };
}
```

Spread the result on `<html>` in your root layout: `<html {...htmlAttributes()}>`.

### Negotiate the locale per request

`i18n.handle()` negotiates the locale from the choice cookie, the account and `Accept-Language`, then runs your handler in it. `t()` and `i18n.format.*` inside use that locale.

```ts title="app/api/orders/route.ts"
import { auth, i18n, t } from "mainsdk";

export async function GET(req: Request) {
  const session = await auth.require(req);
  return i18n.handle(req, () => Response.json({ title: t("orders.count", { count: 2 }) }), {
    user: { id: session.user },
  });
}
```

Use `i18n.negotiate(req, { user })` to get only the locale, and `i18n.with("hi-IN", fn)` to run code in a locale you choose, such as an email in the recipient's language.

### Dates and other formats in the reader's locale

```ts title="lib/format.ts"
import { i18n } from "mainsdk";

export function shippedOn(date: Date) {
  return i18n.format.date(date);   // "27 Oct 2026" in en-IN
}
```

`i18n.format` also has `number()`, `money()`, `list()` and `relative()`: `list(["a", "b", "c"])` gives "a, b and c" and `number(1234567.5)` gives 12,34,567.5 in en-IN.

### Formats in PDF templates

[`pdf`](https://mainsdk.dev/docs/interfaces/pdf) templates format money, numbers and dates through the same locale formats, for example `{{total | money:INR}}`. Locale-specific template files (`order.shipped.hi-IN.yaml`) for [`email`](https://mainsdk.dev/docs/interfaces/email) and [`notify`](https://mainsdk.dev/docs/interfaces/notify) are planned.

## Reference

Everything below is exported from `mainsdk` and is pure TypeScript. Most apps use only `t()` and the `i18n` instance; the rest is for tools, tests and custom drivers.

### Formats

Each takes the active locale from `i18n.locale()`.

| Call | What it does |
| --- | --- |
| `i18n.format.number(n, opts)` | Formats a number; `opts` are `Intl.NumberFormat` options |
| `i18n.format.list(items, opts)` | Joins items as a locale list, such as "a, b and c" |
| `i18n.format.relative(d)` | Formats a date relative to now, such as "2 days ago" |

`formatsFor(locale)` returns the same formats for one locale, `localeSupported(locale)` is true when the runtime's `Intl` can format it, and `localeDir(locale)` returns `ltr` or `rtl`.

### Build the interface

| Call | What it does |
| --- | --- |
| `createI18n(o)` | Builds the interface from `config`, `messages`, `sources`, `log`, `ai` and a draft store |
| `new I18n(o)` | The class `createI18n` returns |
| `new Catalog(cfg, messages)` | Holds every locale's messages and walks the fallback chain |
| `new Drafts(o)` | The drafts service behind `i18n.drafts`: generate, list, approve and reject |
| `parseI18nConfig(raw)` | Reads and validates the `i18n` section; an unknown key or driver is refused with its name |
| `I18N_DRIVERS(const)` | The driver names: `files`, `tolgee`, `crowdin`, `lokalise`, `ai-draft` |
| `NEGOTIATE_SOURCES(const)` | The sources a locale may come from: `choice`, `account`, `header` |

### Messages

| Call | What it does |
| --- | --- |
| `compileMessage(src)` | Parses a message in either syntax, once per process; a syntax error is `MAIN-I18N-0003` |
| `detectSyntax(src)` | Returns `mf2` or `icu` for a message string |
| `parseMf2(src)` | Parses MessageFormat 2 into a tree |
| `parseIcu(src)` | Parses ICU MessageFormat 1 into the same tree |
| `printMf2(m)` | Prints a tree as MessageFormat 2 text that parses back to the same tree |
| `icuToMf2(src)` | Converts an ICU string to MessageFormat 2 with the same output |
| `formatMessage(m, ctx)` | Formats a parsed message for a locale and values |
| `variablesOf(m)` | Lists the external variables a message reads |
| `splitPlural(src)` | Splits a whole-message plural into its variable and cases, or returns null |
| `checkMessages(o)` | Returns `MAIN-I18N-0001` and `MAIN-I18N-0003` findings for `config`, `messages` and the keys used |
| `extractKeys(source, file)` | Finds `t("key")` calls with a literal key; a computed key cannot be checked |

### Locale negotiation

| Call | What it does |
| --- | --- |
| `negotiate(o, req, ctx)` | Picks one request's locale from the choice cookie, the account and the header; never reads an IP address |
| `matchLocale(tag, locales)` | Returns the declared locale a tag selects: exact, then by truncation, then any region of the language |
| `parseAcceptLanguage(header)` | Returns the tags of an `Accept-Language` header ordered by quality, dropping `q=0` |

### Files, drafts and providers

| Call | What it does |
| --- | --- |
| `readLocales(dir)` | Reads every `<locale>.yaml` in a folder; nested maps become dotted keys |
| `writeLocale(dir, locale, { messages })` | Writes one locale as a flat file sorted by key |
| `readGlossary(file)` | Reads the glossary; a missing file is an empty glossary |
| `fileDraftStore(dir)` | Keeps drafts in `<dir>/.drafts/<locale>.json` so a pull request can review them |
| `memoryDraftStore()` | Keeps drafts in memory, for tests |
| `tolgeeSource(o)`, `crowdinSource(o)`, `lokaliseSource(o)` | The provider drivers; each returns a message source |
| `lastGood(source, cache, { log })` | Wraps a source so a failed load serves the last good set and logs the outage once |
| `fileCache(file)` | Keeps the last good set in one JSON file |

### Interchange and pseudo-locales

| Call | What it does |
| --- | --- |
| `FORMATS(const)` | The interchange formats: `xliff`, `xcstrings`, `android`, `icu-json` |
| `exportBundle(format, b, opts)` | Writes a bundle in a format; XLIFF and Android hold one locale, named with `opts.locale` when there are several |
| `importBundle(format, text)` | Reads a file in a format; one message that does not parse returns nothing and raises `MAIN-I18N-0003` |
| `PSEUDO_LOCALES(const)` | The pseudo-locale tags: `en-XA` and `ar-XB` |
| `pseudoMessage(src, locale)` | Pseudo-localizes one message |
| `pseudoMessages(messages, locale)` | Pseudo-localizes every message of a locale |

## Errors and checks

- A key missing from a `required` locale fails `mainsdk check` with `MAIN-I18N-0001`, and a message that does not parse or whose variables differ from the default locale's with `MAIN-I18N-0003`.
- A locale the target runtime's `Intl` can't format is `MAIN-I18N-0002`; `negotiate` listing `ip` is `MAIN-I18N-0008`.
- `review: none` on an AI-draft locale is warned with `MAIN-I18N-0006`.
- At run time a fallback logs `MAIN-I18N-0004`, a value `t()` was not given logs `MAIN-I18N-0005`, and a key missing everywhere logs `MAIN-I18N-0009`; production returns an empty string, never the key.
- `mainsdk i18n pseudo` writes long, accented and right-to-left pseudo-locales to catch layout breaks before any translator sees them.

Every code is in [check codes](https://mainsdk.dev/docs/reference/check-codes). See [The check](https://mainsdk.dev/docs/concepts/the-check).

## Swapping drivers

```yaml title="main.yaml"
i18n:
  driver: tolgee   # was files
```

Your `t()` and `i18n.*` calls stay the same. The provider drivers pull messages from Tolgee, Crowdin or Lokalise and keep the last good set when the provider is down.

With `ai-draft`, `mainsdk i18n draft [--locale hi-IN]` asks the `ai` interface for the messages a locale lacks, follows your glossary, and holds each draft as pending in `locales/.drafts/`. The call goes through every ai guard. A draft reaches users only after a person approves it:

```ts title="scripts/review-drafts.ts"
import { i18n } from "mainsdk";

const { items } = await i18n.drafts.list({ locale: "hi-IN" });
for (const d of items.filter((x) => x.status === "pending")) {
  await i18n.drafts.approve(d.id);                          // or approve(d.id, { text: "edited" })
  // await i18n.drafts.reject(d.id, { reason: "wrong term" });
}
```

An agent or service that tries to approve or reject gets `MAIN-I18N-0007`; the refusal is audited.

Strings move between drivers and translators' tools with `mainsdk i18n export --format <f>` and `mainsdk i18n import <file>`, where the format is `xliff`, `xcstrings`, `android` or `icu-json`. `mainsdk i18n extract` lists the keys your code uses and the ones the default locale lacks.

## Testing

Point the test app at your `main.yaml` and project folder; it reads `locales/` and runs on in-memory drivers.

```ts title="app/orders/heading.test.ts"
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";

describe("heading", () => {
  it("pluralises the order count in Hindi and English", () => {
    const app = createTestApp({ main: "./main.yaml", dir: process.cwd() });
    assert.equal(app.i18n.t("orders.count", { count: 1 }), "1 order");
    assert.equal(app.i18n.with("hi-IN", () => app.i18n.t("orders.count", { count: 3 })), "3 ऑर्डर");
  });
});
```

## Security and privacy

- The locale comes from the user's choice first, then their account, then the browser. It is never guessed from IP alone.
- AI drafts go through the `ai` interface with redaction, limits and audit, and are held for a person's review.
- Android `values-hi/` and iOS `.xcstrings` files generated from the same locales with the Expo adapter are planned for phase 3.

## FAQ

**Do Indian scripts render correctly?** In the browser, yes: shaping is the browser's job. In PDFs, the built-in renderer doesn't shape Devanagari or Tamil, so use the `gotenberg` or `docraptor` driver; `check` reports `MAIN-DOC-0011` otherwise.

**What happens if a string is missing at run time?** The check fails before merge, so it should not reach users. At run time the fallback chain applies, and the first miss for a key is logged once.

## Next

- [Translate your app](https://mainsdk.dev/docs/guides/i18n)
- [PDF documents](https://mainsdk.dev/docs/interfaces/pdf)
- [main.yaml](https://mainsdk.dev/docs/concepts/main-yaml)
