MainSDK
Search⌘K
Interfaces

Platform, OS and network

Languages and formats

i18n

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

Previewships as a beta APIPhase 1 · Core, public betaSpec i18nFree in your app, Apache-2.0

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
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.

At a glance

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

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? })

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

5 drivers. Select one to see its line in main.yaml.

main.yamlLocal YAML files (default)
i18n:
  driver: files

Change this one line to swap providers; your calls stay the same.Built-in and local drivers integration

Codes

CodeWhere it appearsMeaning
MAIN-I18N-0001Checkmainsdk check, before deployA key is missing from a required locale
MAIN-I18N-0002Checkmainsdk check, before deployA locale isn't supported by the target runtime's Intl
MAIN-I18N-0003Checkmainsdk check, before deployA message has a syntax error, or its variables differ from the default locale's
MAIN-I18N-0006Checkmainsdk check, before deployreview: none: AI drafts reach users without review (warning)
MAIN-I18N-0008Checkmainsdk check, before deployi18n.negotiate lists ip or an unknown source
MAIN-I18N-0004RuntimeError responses and logsA message was missing and a fallback was used (logged once per key and locale)
MAIN-I18N-0005RuntimeError responses and logst() was called without a value its message needs (logged)
MAIN-I18N-0007RuntimeError responses and logsAn agent or service tried to approve a translation draft (403)
MAIN-I18N-0009RuntimeError responses and logsNo message for the key in any locale; production never shows the key (logged)
MAIN-I18N-0010RuntimeError responses and logsSee the spec
MAIN-I18N-0011RuntimeError responses and logsSee the spec
MAIN-I18N-0012RuntimeError responses and logsSee the spec

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
Messages in MessageFormat 2 with ICU fallbackPhase 10.4i18n#mf2
Numbers, money, dates, lists and relative times from IntlPhase 10.4i18n#formats
Locale negotiated per requestPhase 10.4i18n#negotiate
Fallback chainsPhase 10.4i18n#fallback
Missing keys and placeholder mismatches in checkPhase 10.4i18n#checks
Pseudo-localizationPhase 10.4i18n#pseudo
AI drafts with a glossary and a person's reviewPhase 10.4i18n#ai-draft
XLIFF, xcstrings, Android strings and ICU JSON import and exportPhase 10.4i18n#interchange-drivers
Android and iOS string files from the same localesPhase 31.1expo-adapter

Quick start

main.yaml
i18n:
  driver: files
  locales: [en-IN, hi-IN, ta-IN]
locales/en-IN.yaml
orders.count: "{count, plural, one {# order} other {# orders}}"
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

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
KeyWhat it does
driverfiles (default), tolgee, crowdin, lokalise or ai-draft
defaultThe locale every chain ends in; en when unset
localesThe locales you ship; the default alone when unset
requiredLocales where a missing key fails mainsdk check
fallbackPer-locale chain, such as fallback: { hi-IN: [hi, en-IN] }
negotiateSources in order: choice, account, header
ai-draftmodel, glossary (default locales/glossary.yaml) and review per locale (person, the default, or none)
crowdin, tolgee, lokaliseproject, token (a secret: reference), base-url, download-host, language-ids for the provider driver you pick
runtime.icufull 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.

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

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.

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

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 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 and 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().

CallWhat 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

CallWhat 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

CallWhat 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

CallWhat 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

CallWhat 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

CallWhat 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. See The check.

Swapping drivers

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:

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.

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

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet