Skip to content

Translation guide

SnapOtter ships with 21 languages out of the box. The i18n system uses a lightweight custom runtime with TypeScript-enforced locale completeness and dynamic code-splitting.

Supported languages

CodeLanguageNative NameDirection
enEnglishEnglishLTR
zh-CNChinese (Simplified)简体中文LTR
zh-TWChinese (Traditional)繁體中文LTR
jaJapanese日本語LTR
koKorean한국어LTR
esSpanishEspañolLTR
frFrenchFrançaisLTR
itItalianItalianoLTR
pt-BRPortuguese (Brazil)Português (Brasil)LTR
deGermanDeutschLTR
nlDutchNederlandsLTR
svSwedishSvenskaLTR
ruRussianРусскийLTR
plPolishPolskiLTR
ukUkrainianУкраїнськаLTR
arArabicالعربيةRTL
trTurkishTürkçeLTR
hiHindiहिन्दीLTR
viVietnameseTiếng ViệtLTR
idIndonesianBahasa IndonesiaLTR
thThaiไทยLTR

How language detection works

SnapOtter uses a three-tier resolution order:

  1. User preference - stored in localStorage("snapotter-locale") and synced to user settings when authenticated
  2. Browser auto-detect - walks the navigator.languages array with BCP 47 prefix matching
  3. Instance default - the admin's DEFAULT_LOCALE env var (fetched from GET /api/v1/config/locale)
  4. English fallback - always available

Users can change language from:

  • The footer Globe selector (desktop, always visible)
  • The login page language selector (pre-auth)
  • The Settings > General section (per-user preference)
  • The mobile sidebar language dropdown
  • The Settings > System section sets the instance-wide default (admin only)

How translations work

All UI strings live in packages/shared/src/i18n/. The reference file is en.ts, which exports a typed object with every string the app uses (~1500 keys). Other languages are separate files (e.g., de.ts, fr.ts) that export the same shape.

The TranslationKeys type uses DeepStringRecord to accept any string value while enforcing the key structure. TypeScript catches missing keys in any translation file at compile time.

Only the active locale is loaded at runtime via dynamic import(), keeping the main bundle small.

Using translations in components

tsx
import { useTranslation } from "@/contexts/i18n-context";
import { format, plural } from "@/lib/format";

function MyComponent() {
  const { t, locale, setLocale } = useTranslation();
  
  return (
    <div>
      <h1>{t.common.settings}</h1>
      <p>{format(t.settings.people.deleteConfirm, { username: "admin" })}</p>
      <p>{plural(count, t.automate.fileCount, t.automate.fileCountPlural)}</p>
    </div>
  );
}

Contributing a translation

We welcome translation PRs directly. You can improve an existing locale or add a new one.

To report a mistranslation without submitting code, open a GitHub Issue with the language, the incorrect string, and the suggested fix.

TIP

Translation PRs do not require prior approval. Fork the repo, make your changes, and open a PR. See the Contributing Guide for the full PR process and CLA requirement.

How to create or update a translation

1. Fork and clone

bash
git clone https://github.com/<your-username>/snapotter.git
cd snapotter
pnpm install

2. Copy the reference file (new language only)

Skip this step if you are improving an existing translation.

bash
cp packages/shared/src/i18n/en.ts packages/shared/src/i18n/XX.ts

3. Translate the strings

Open your new file and translate every string value. Keep the object structure and keys exactly the same.

ts
import type { TranslationKeys } from "./en.js";

export const xx: TranslationKeys = {
  common: {
    upload: "Your translation here",
    // ... translate all entries
  },
  // ... translate all sections
} as const;

Rules:

  • Do not translate object keys, only string values
  • Keep as const at the end
  • Import TranslationKeys from ./en.js and type your export
  • Keep {variable} placeholders exactly as-is
  • Arrays (rotatingPhrases, progressMessages) must have the same number of entries
  • Do not translate: SnapOtter, JPEG, PNG, WebP, EXIF, API, and other technical terms

4. Register the locale (new language only)

Add your locale to SUPPORTED_LOCALES in packages/shared/src/i18n/index.ts:

ts
{ code: "xx", name: "Language Name", nativeName: "Native Name", dir: "ltr" },

5. Verify

bash
pnpm typecheck    # catches missing or mistyped keys
pnpm lint         # formatting check
pnpm dev          # manually verify strings appear correctly

6. Submit

Open a PR against main with a title like feat(i18n): add Swedish translation or fix(i18n): correct German typos. The CLA bot will ask you to sign on your first contribution.

Adding new translation keys

When adding a new feature that needs new UI strings:

  1. Add the new keys to en.ts first (the reference file)
  2. Run pnpm typecheck - every locale file will fail if missing the new key
  3. Add the new key to all locale files (use English as a temporary fallback)

Configuration

Set the instance default language via environment variable:

yaml
DEFAULT_LOCALE: "de"  # German as the default for all new users

File reference

FilePurpose
packages/shared/src/i18n/en.tsEnglish strings (reference locale, ~1500 keys)
packages/shared/src/i18n/index.tsSUPPORTED_LOCALES, loadTranslations(), type exports
packages/shared/src/i18n/<locale>.tsPer-language translation files
apps/web/src/contexts/i18n-context.tsxI18nProvider, useTranslation() hook
apps/web/src/lib/format.tsformat(), plural(), formatFileSize() helpers
apps/api/src/routes/config.tsGET /api/v1/config/locale public endpoint

Translating the website, docs, and API reference

The 21-language support above covers the app. The public website (snapotter.com), this documentation site, and the REST API reference are also translated into all 21 languages, by a separate hash-gated pipeline that reuses the same tool names and descriptions from packages/shared/src/i18n, so terminology stays consistent everywhere.

Machine-translated by default

Every non-English page on the website and docs is machine-translated on the first pass (by a Claude Code session, not a third-party service) and carries a small, dismissible banner saying so, with a link back here. That is deliberate: it ships all 21 languages quickly and honestly, then invites the community to refine the pages that matter most. Machine translation gets the meaning across; human review makes it read naturally.

How the pipeline decides what to translate

Each translatable unit of English source is hashed, and the hash is stored next to its translation. On each run the pipeline:

  • translates any unit that has no translation yet,
  • skips any unit whose stored hash still matches the English source,
  • re-translates a machine unit when its English source changes,
  • and flags a human-refined unit as stale (needs review) when its English source changes, instead of overwriting your work.

Refining a web translation by PR

You improve a website, docs, or API-reference translation the same way you improve an app locale: by editing the generated file and opening a PR.

  1. Find the generated translation for your language:
    • website UI strings: apps/landing/src/i18n/<locale>.json
    • a docs page: apps/docs/<locale>/**.md
    • the API reference: apps/api/src/openapi.<locale>.yaml
  2. Edit the text. Keep code, links, {placeholders}, and any ⸤I18N…⸥ markers exactly as they are; the pipeline's validator rejects a translation that drops or reorders them.
  3. Open a PR. Editing a unit flips its provenance from machine to human, so the pipeline will never overwrite it on a later run. If the English source changes afterwards, your unit is flagged stale for review rather than silently replaced.

To report a mistranslation without submitting code, open a GitHub Issue with the page URL, the language, the incorrect text, and your suggested fix.

TIP

Maintainers run the translation pipeline; you do not need an API key to contribute. Just edit the generated file and open a PR. See scripts/i18n/README.md for how the pipeline runs.