Lovable i18n with Lingui
You are the Lovable agent working on one project. You cannot run terminal commands — no npm, no lingui CLI, no git. You can edit files, add npm dependencies, and read preview build errors and console logs. Everything below is designed around that.
Three consequences of that design, worth internalizing before you start:
- You never run
lingui compile.@lingui/vite-plugincompiles.pocatalogs at build time, when the app dynamically imports them. The preview build is the compiler. - You never run
lingui extract. A GitHub Actions workflow (Phase 5) runs extraction in CI after Lovable syncs commits to GitHub. Until that lands, you maintain.pofiles by hand, following the catalog maintenance protocol later in this skill. - You scaffold catalogs yourself. Since nothing can run the CLI here, you create the
messages.pofiles directly, with valid PO headers (step A7).
The library is Lingui v6, with one PO catalog per locale at src/locales/{locale}/messages.po.
Because the whole pipeline — the catalog-sync workflow (Phase 5) and the Globalize.now hand-off (Phase 6) — runs through GitHub, this skill confirms GitHub is connected before doing anything, and stops until it is. See Prerequisite: GitHub connection below.
One thing the terminal limit does not block: connecting Globalize. Once the user adds the Globalize MCP connector (Phase 6), you create the project and link the repo yourself, right here in the chat — no leaving Lovable. A paste-ready fallback covers anyone who'd rather not add the connector.
Phases
| Phase | What happens |
|---|---|
| 1.1 Detect | Identify the project stack (Vite SPA or TanStack Start) and any existing i18n |
| 1.2 Ask | One chat message collecting source locale, target locales, URL routing, opt-ins |
| 2A / 2B Setup | Add dependencies, build config, i18n.ts, provider, catalogs, formatters module, language switcher |
| 3 Rules | Add Lingui coding rules to AGENTS.md so every future edit stays localized |
| 4 Wrap | Wrap the app's existing hardcoded strings in Lingui macros |
| 5 CI | Add a GitHub Action that runs lingui extract and keeps catalogs in sync |
| 6 Connect | Connect Globalize.now (default) — create the project + link the repo via the Globalize MCP |
Work the phases in order. Detect and Ask are quick; Setup is the bulk of the work. Before Phase 1, confirm GitHub is connected (Prerequisite below) — the skill stops until it is. Phase 6 (Connect) runs by default unless the user opts out.
Prerequisite: GitHub connection
Before any i18n work — before even detecting the stack — confirm this Lovable project is connected to GitHub. This is a hard gate. Everything this skill ultimately delivers runs through GitHub: the catalog-sync workflow (Phase 5) and the Globalize.now translation hand-off (Phase 6) both operate on the GitHub repository, which lives outside Lovable's sandbox. Globalize reads your message catalogs from GitHub and delivers translations back through it — there is no supported way to send your text out for translation or get it back without a connected repo.
You know whether GitHub is connected from your Lovable project context — you don't need to read a file or run any command to tell. (If you genuinely can't determine it from your context, ask the user to confirm before continuing.) Do not infer connection from files in the tree: .gitignore, package.json, and a .git folder ship in every Lovable scaffold whether or not GitHub is connected, so they're false positives.
If GitHub is connected → say so in one line and continue to Phase 1.
If GitHub is not connected → stop here. Don't detect the stack, don't install anything, don't edit files. Tell the user:
To translate this app I need it connected to GitHub first. The translation platform (Globalize.now) and the catalog-sync workflow both read and write your message catalogs through your GitHub repo — without it, there's no way to send your text out for translation or get it back.
Connect it with the GitHub button at the top-right of the editor (or Settings → GitHub): authorize the Lovable GitHub App and create or link a repository. Once it's connected, tell me to continue and I'll set up translations.
Then wait. Resume at Phase 1 once the user confirms GitHub is connected.
Phase 1: Detect & Ask
1.1 Detect
Read package.json and decide which stack this project is. First match wins.
Path B — TanStack Start (SSR). Authoritative signal: @tanstack/react-start in dependencies. Corroborating signals (any of these confirm, none is required once the dependency is present):
src/routes/__root.tsxexistssrc/router.tsxexporting agetRouter()function@lovable.dev/vite-tanstack-configwrapping the config invite.config.tswrangler.jsoncat the project root (Cloudflare Workers deploy target)
→ Follow Phase 2B: Setup — TanStack Start (SSR).
Path A — Vite SPA (legacy stack). vite in devDependencies and no @tanstack/react-start. Corroborating signals:
@vitejs/plugin-react-swcindevDependenciesreact-router-domindependenciesand a<Routes>block insrc/App.tsxcomponents.jsonat the project root (shadcn/ui)lovable-taggerindevDependencies- root
index.htmlandsrc/main.tsx
→ Follow Phase 2A: Setup — Vite SPA.
If neither matches, tell the user what you found and that this skill covers Lovable's two project stacks only — don't guess your way into a setup.
Escape hatches
Check these before starting setup. They change or stop the plan.
Babel instead of SWC (Path A). If vite.config.ts uses @vitejs/plugin-react (no -swc suffix), the SWC macro plugin won't work. Tell the user, then substitute the Babel plugin:
This project uses the Babel-based React plugin instead of SWC. Same result, slightly different wiring: I'll use
@lingui/babel-plugin-lingui-macroinstead of the SWC plugin.
Add @lingui/babel-plugin-lingui-macro@^6 as a dev dependency (instead of @lingui/swc-plugin), and in A2 configure the React plugin as:
react({
babel: {
plugins: ['@lingui/babel-plugin-lingui-macro'],
},
})
Everything else in Phase 2A is identical.
Existing i18n library. If react-i18next, i18next, react-intl, next-translate, or any other i18n library is already in dependencies: STOP and ask the user. Two options: keep the existing library (this skill doesn't apply — its setup, catalogs, and CI are Lingui-specific), or remove the existing library and its usages first, then re-run this skill. Never migrate or rip out an i18n library silently.
Existing Lingui config. If a lingui.config.ts (or .js) already exists, run in additive mode: verify the config matches the shape in A3 (PO formatter, src/locales/{locale}/messages catalog path), add any locales the user requested that are missing (config + new .po files per A7), confirm the provider is wired (A5) and the vite plugins are present (A2) — or their Phase 2B equivalents on Path B — fix only what's missing, then continue from Phase 3 (make sure the AGENTS.md coding rules are in place) before wrapping strings in Phase 4.
1.2 Ask
Auto-detect before asking — pull defaults from the project so the user mostly confirms:
- Source locale: the
<html lang="...">value inindex.html; any existingsrc/locales/<locale>/directories; defaulten. - Target locales: any language the user already named in their request ("add Spanish" →
esis pre-selected); existing locale directories.
Then send one chat message with every question and a sensible default pre-selected. Do not drip-feed questions one at a time. Include question 3 only on Path A (Vite SPA) — on Path B locale handling is cookie-based by default (see B6); URL-prefix routing is only added if the user asks. Shape it like this:
Before I set up translations, a few choices — defaults in bold, just say "go" to accept all:
- Source language — the language your app is written in. Detected: en (from
<html lang>).- Target languages — which languages to translate into. You mentioned Spanish (es); common additions: French (fr), German (de), Portuguese (pt), Japanese (ja). Which do you want?
- Locale in the URL?
- No URL locale (default) — language is remembered per visitor (saved choice → browser language). URLs stay exactly as they are. Simplest, no link changes.
- URL prefix (
/es/dashboard) — every page exists per language; shareable, SEO-friendly language URLs, but all internal links need a locale prefix.- Catalog sync via GitHub Actions — a workflow that extracts new texts into the catalogs whenever code changes. Default: yes. GitHub is already connected (the prerequisite), so this just adds the workflow.
- Connect Globalize.now — the translation platform that fills in the actual translations via PRs. Default: yes. I'll connect it through the Globalize MCP connector (a quick one-time setup I'll walk you through). Say "skip Globalize" to set it up later.
After the user answers, execute the whole plan without further pauses. Only stop again for blockers: a build error you cannot resolve, or an escape hatch from 1.1. GitHub connection is already settled by the prerequisite, so it's not a mid-flow stop.
Record the answers — SOURCE_LOCALE, the locale list, routing choice, opt-ins — you will substitute them into every snippet below. The snippets use en as source and ['en', 'es', 'fr'] as the locale list; replace with the real choices everywhere.
Narrate structural edits. Before modifying build config or app-entry files — vite.config.ts, src/main.tsx, src/App.tsx, index.html on Path A; vite.config.ts, src/router.tsx, src/routes/__root.tsx, src/start.ts on Path B — state in one chat sentence what will change and why (e.g. "I'm adding the Lingui plugins to vite.config.ts so translations compile at build time"), then make the edit. Don't wait for permission — just narrate.
Phase 2A: Setup — Vite SPA
Ten steps, A1–A10, in order. Then the optional URL-routing variant if the user opted in.
A1. Dependencies
Add these dependencies (do not write install commands — add them to the project's dependencies directly):
Runtime dependencies:
| Package | Version | Purpose |
|---|---|---|
@lingui/core |
^6 |
i18n runtime |
@lingui/react |
^6 |
React bindings (I18nProvider, Trans, useLingui) |
@lingui/detect-locale |
^6 |
Browser locale detection (URL, storage, navigator) |
Dev dependencies:
| Package | Version | Purpose |
|---|---|---|
@lingui/cli |
^6 |
Used only by the GitHub Actions workflow (Phase 5) — never run here |
@lingui/swc-plugin |
^6 |
SWC macro transform (skip if on the Babel escape hatch) |
@lingui/vite-plugin |
^6 |
Compiles .po catalogs when the app imports them |
@lingui/format-po |
^6 |
PO catalog formatter for lingui.config.ts |
Version compatibility caveat: SWC's Wasm plugin ABI has been backward-compatible since @swc/core 1.15.0 (announcement), and @lingui/swc-plugin is built against a pinned swc_core: 6.2.0-6.6.0 against swc_core@66.0.3, and 6.7.0+ against swc_core@77.1.1. ^6 resolves to 6.7.0 today, so ^6 needs no pin on a host whose @swc/core resolves to 1.16.x (swc_core@77.0.2) — check the lockfile, not the @vitejs/plugin-react-swc range: ^4's dependency is only @swc/core@^1.15.11/^1.15.46, and 1.15.11 carries swc_core@56.0.0. If the preview build fails with an AST schema error or a plugin invocation error after this setup, the Lovable scaffold is on an older @vitejs/plugin-react-swc — raise it to ^4 first. If it cannot move, look the host range up at https://plugins.swc.rs and pin a @lingui/swc-plugin version whose @lingui/core peer admits the major installed here — for ^6 that is 6.0.0-6.1.0 and nothing older, since every 5.x requires @lingui/core@5 (a required peer, so ERESOLVE rather than a fix).
A2. vite.config.ts
Add two things: the SWC macro plugin inside the existing react() call, and lingui() as a top-level Vite plugin. Preserve everything already there — lovable-tagger's componentTagger(), the @ path alias, server options, conditional plugin logic. Only add, never replace.
A typical Lovable Vite config becomes:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
import { lingui } from '@lingui/vite-plugin'
import { componentTagger } from 'lovable-tagger'
import path from 'path'
export default defineConfig(({ mode }) => ({
server: {
host: '::',
port: 8080,
},
plugins: [
react({
plugins: [['@lingui/swc-plugin', {}]],
}),
lingui(),
mode === 'development' && componentTagger(),
].filter(Boolean),
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
}))
The SWC plugin entry must be the tuple shape ['@lingui/swc-plugin', {}] — a string + options object. Passing the plugin name as a bare string silently disables the macro transform: the build succeeds, but <Trans> never resolves and raw macro output leaks into the UI.
A3. lingui.config.ts
Create lingui.config.ts at the project root:
// lingui.config.ts
import type { LinguiConfig } from '@lingui/conf'
import { formatter } from '@lingui/format-po'
const config: LinguiConfig = {
sourceLocale: 'en',
locales: ['en', 'es', 'fr'],
catalogs: [
{
path: '<rootDir>/src/locales/{locale}/messages',
include: ['<rootDir>/src'],
exclude: ['**/node_modules/**', '**/locales/**'],
},
],
format: formatter({ lineNumbers: false }),
}
export default config
Substitute sourceLocale and locales from the Ask phase. format: formatter(...) from @lingui/format-po is required — Lingui 6 removed the format: 'po' string form, so a string here fails. lineNumbers: false keeps #: references to file paths only — line numbers would change on almost every edit and generate a CI catalog-sync commit after every push; paths alone are stable. The **/locales/** exclusion keeps the extractor (in CI) from scanning the catalogs themselves.
A4. src/i18n.ts
Create src/i18n.ts — locale constants, detection, activation, persistence:
// src/i18n.ts
import { i18n } from '@lingui/core'
import { detect, fromUrl, fromStorage, fromNavigator } from '@lingui/detect-locale'
// Must match the `locales` array in lingui.config.ts
export const LOCALES: readonly string[] = ['en', 'es', 'fr']
export const SOURCE_LOCALE = 'en'
const RTL_LOCALES = new Set(['ar', 'he', 'fa', 'ur', 'ps', 'sd', 'yi'])
function getDirection(locale: string): 'ltr' | 'rtl' {
return RTL_LOCALES.has(locale.split('-')[0]) ? 'rtl' : 'ltr'
}
export function detectLocale(): string {
let detected: string | null
try {
detected = detect(fromUrl('lang'), fromStorage('lang'), fromNavigator())
} catch {
// localStorage threw (sandboxed iframe / blocked storage) — retry without it
detected = detect(fromUrl('lang'), fromNavigator())
}
if (detected) {
if (LOCALES.includes(detected)) return detected
// Regional fallback: es-MX → es
const base = detected.split('-')[0]
if (LOCALES.includes(base)) return base
}
return SOURCE_LOCALE
}
export async function activateLocale(locale: string) {
try {
const { messages } = await import(`./locales/${locale}/messages.po`)
i18n.loadAndActivate({ locale, messages })
} catch (e) {
console.error(`Failed to load "${locale}" catalog, falling back to "${SOURCE_LOCALE}"`, e)
const { messages } = await import(`./locales/${SOURCE_LOCALE}/messages.po`)
i18n.loadAndActivate({ locale: SOURCE_LOCALE, messages })
}
document.documentElement.lang = i18n.locale
document.documentElement.dir = getDirection(i18n.locale)
}
export function saveLocale(locale: string) {
try {
localStorage.setItem('lang', locale)
} catch {
// Storage unavailable — the choice just won't persist
}
// `detectLocale()` reads `?lang=` *before* localStorage, so the URL has to be updated too.
// Outside the try/catch on purpose: when storage is blocked, the URL is the only thing left.
const url = new URL(window.location.href)
url.searchParams.set('lang', locale)
history.replaceState(history.state, '', url)
}
export { i18n }
Notes:
- The dynamic import targets the
.pofile directly (./locales/${locale}/messages.po) —@lingui/vite-plugincompiles it to runtime messages at that moment. No compiled.js/.tscatalogs ever exist in the repo. detectLocale()tries sources in order:?lang=URL parameter →langkey in localStorage → browser language (with regional fallback,es-MX→es) → source locale.activateLocale()also keeps<html lang>and<html dir>in sync, so RTL locales (Arabic, Hebrew, Farsi, Urdu…) flip the document direction automatically.- Call
saveLocale()only on an explicit user choice (the language switcher), so the choice persists across visits. saveLocale()writes the URL as well as storage, and both writes are required. BecausedetectLocale()reads?lang=first, a visitor who arrives on/?lang=esfrom a shared link, switches to another locale, and reloads would be thrown straight back into Spanish if onlylocalStoragehad been updated — with no way out short of hand-editing the URL. Writing the param keeps read and write agreed, and has the useful side effect that the address bar always reflects the active locale, so the URL stays shareable.history.replaceStaterather thanpushState: switching locale should not add a back-button entry.- Why the try/catch around storage: in sandboxed preview iframes and cookie-blocking browsers, touching
localStoragethrows aSecurityError— unguarded, that happens insidedetectLocale()before first render and leaves the app blank.
TypeScript doesn't know what a .po import is, so add a module declaration. Append to src/vite-env.d.ts (it exists in every Lovable Vite project), or create src/po-modules.d.ts if you prefer not to touch it:
declare module '*.po' {
import type { Messages } from '@lingui/core'
export const messages: Messages
}
A5. Provider in src/main.tsx
Wrap the app with I18nProvider, and detect + activate the locale before the first render so the UI never flashes untranslated. Preserve the file's existing imports (./index.css, etc.):
// src/main.tsx
import { createRoot } from 'react-dom/client'
import { I18nProvider } from '@lingui/react'
import { i18n, detectLocale, activateLocale } from './i18n'
import App from './App.tsx'
import './index.css'
async function bootstrap() {
await activateLocale(detectLocale())
createRoot(document.getElementById('root')!).render(
<I18nProvider i18n={i18n}>
<App />
</I18nProvider>,
)
}
void bootstrap()
If main.tsx already wraps <App /> in other providers, keep them and put I18nProvider outermost (everything that renders text needs it above them in the tree).
The preview will show a dynamic-import error from this point until step A7 creates the .po catalogs — complete A7 before reading anything into the build output.
A6. index.html
Check the <html lang="..."> value at the project root:
- Set it to the source locale (e.g.
<html lang="en">). It's the pre-JavaScript default;activateLocale()takes over at runtime. - If the existing value disagrees with the chosen source locale, flag it to the user — one of the two is wrong.
- Remove any hardcoded
dirattribute.activateLocale()setsdirdynamically; a hardcoded value flashes the wrong direction for RTL locales.
A7. Scaffold the catalogs
Nothing here can run lingui extract, so create the catalog files yourself. For every locale — including the source locale — create src/locales/{locale}/messages.po containing only a valid PO header:
msgid ""
msgstr ""
"Content-Type: text/plain; charset=utf-8\n"
"Language: es\n"
"MIME-Version: 1.0\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
Set Language: to the file's locale. Set Plural-Forms from this list where the locale appears (one line per locale group, expression after the →); omit the line otherwise (Lingui stores plurals as ICU expressions inside messages, so the header is informational):
en, es, de, it, nl, pt → nplurals=2; plural=(n != 1);
fr, pt-BR, tr → nplurals=2; plural=(n > 1);
ja, zh, ko, th, vi, id → nplurals=1; plural=0;
ru, uk → nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);
pl → nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);
ar → nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 ? 4 : 5);
A header-only catalog is valid: it compiles to an empty message set, and Lingui falls back to the source text for any missing message. Entries get added by the Wrap phase and by CI extraction later.
A8. Language switcher
Every Lovable project ships shadcn/ui, so build the switcher on the shadcn Select:
// src/components/LanguageSwitcher.tsx
import { useLingui } from '@lingui/react/macro'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { LOCALES, activateLocale, saveLocale } from '@/i18n'
export function LanguageSwitcher() {
const { i18n } = useLingui()
const displayNames = new Intl.DisplayNames([i18n.locale], { type: 'language' })
async function switchLocale(locale: string) {
await activateLocale(locale)
saveLocale(locale)
}
return (
<Select value={i18n.locale}
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LOCALES.map((locale) => (
<SelectItem key={locale} value={locale}>
{displayNames.of(locale) ?? locale}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
useLingui() subscribes the component to locale changes, so the selected value updates after activateLocale() resolves. Intl.DisplayNames renders each language's name in the current locale ("Spanish" / "espagnol" / "Spanisch") with no hand-maintained name map.
Place the switcher where the user can see it — the app's existing header or navigation component if there is one, otherwise the top-level layout in App.tsx. Match the surrounding styling (Tailwind classes) rather than keeping the bare w-[140px].
A9. Create src/i18n/format.ts
Ten locale-aware functions — money, numbers, percentages, dates, relative time, lists — behind one seam, formatLocale(). Wrapping strings (Phase 4) makes text translatable; this module is what makes a value — a price, a date, a count — render correctly per locale, and it's what the AGENTS.md rules (Phase 3) point at.
Create the file exactly as below. It exports two ways to get at the ten functions: useFormatters(), a hook for components, and getFormatters(locale), for everywhere else — see the "Numbers, currencies, dates" section of the Phase 3 rules for when to use which:
// src/i18n/format.ts
import { useMemo } from 'react'
import { useLingui } from '@lingui/react'
export type DateInput = Date | number | string
export type DatePreset = 'short' | 'medium' | 'long'
/** The project's currency. Formatting follows the locale; the currency follows the data. */
export const DEFAULT_CURRENCY = 'USD' // adjust to this project's currency
/** Date presets. Trim to what this app actually formats. */
export const DATE_PRESETS: Record<DatePreset, Intl.DateTimeFormatOptions> = {
short: { dateStyle: 'short' },
medium: { dateStyle: 'medium' },
long: { dateStyle: 'long' },
}
export type Formatters = {
money(amount: number, currency?: string): string
number(value: number, opts?: Intl.NumberFormatOptions): string
percent(value: number): string
compact(value: number): string
unit(value: number, unit: string): string
date(value: DateInput, preset?: DatePreset): string
time(value: DateInput): string
dateTime(value: DateInput): string
relativeTime(value: DateInput, now?: DateInput): string
list(items: string[], type?: 'and' | 'or'): string
}
/**
* THE SEAM. Formatting follows the UI locale today. To give this project a
* separate regional preference — an English UI that still renders 1.234,56 € —
* change this one function. Every formatter reads its locale from here.
*/
export function formatLocale(uiLocale: string): string {
return uiLocale
}
/** Intl instances keyed by locale + kind. Holds no request state, so it is safe under SSR. */
const memo = new Map<string, unknown>()
function cached<T>(key: string, make: () => T): T {
let f = memo.get(key) as T | undefined
if (f === undefined) memo.set(key, (f = make()))
return f
}
const toDate = (v: DateInput): Date => (v instanceof Date ? v : new Date(v))
const UNITS: Array<[Intl.RelativeTimeFormatUnit, number]> = [
['second', 1000],
['minute', 60_000],
['hour', 3_600_000],
['day', 86_400_000],
['week', 604_800_000],
['month', 2_629_746_000],
['year', 31_556_952_000],
]
/** Largest unit whose magnitude is at least 1; falls back to seconds. */
export function pickRelativeUnit(deltaMs: number): [Intl.RelativeTimeFormatUnit, number] {
const abs = Math.abs(deltaMs)
for (let i = UNITS.length - 1; i >= 0; i--) {
const [unit, ms] = UNITS[i]
if (abs >= ms || i === 0) return [unit, Math.round(deltaMs / ms)]
}
return ['second', 0]
}
export function createFormatters(uiLocale: string): Formatters {
const locale = formatLocale(uiLocale)
const nf = (key: string, opts: Intl.NumberFormatOptions) =>
cached(`n:${locale}:${key}`, () => new Intl.NumberFormat(locale, opts))
const df = (key: string, opts: Intl.DateTimeFormatOptions) =>
cached(`d:${locale}:${key}`, () => new Intl.DateTimeFormat(locale, opts))
return {
money: (amount, currency = DEFAULT_CURRENCY) =>
nf(`cur:${currency}`, { style: 'currency', currency }).format(amount),
number: (value, opts) =>
opts
? new Intl.NumberFormat(locale, opts).format(value)
: nf('dec', { style: 'decimal' }).format(value),
percent: (value) => nf('pct', { style: 'percent' }).format(value),
compact: (value) => nf('cmp', { notation: 'compact' }).format(value),
unit: (value, unit) => nf(`unit:${unit}`, { style: 'unit', unit }).format(value),
date: (value, preset = 'medium') =>
df(`p:${preset}`, DATE_PRESETS[preset]).format(toDate(value)),
time: (value) => df('t', { timeStyle: 'short' }).format(toDate(value)),
dateTime: (value) =>
df('dt', { dateStyle: 'medium', timeStyle: 'short' }).format(toDate(value)),
relativeTime: (value, now) => {
const from = now === undefined ? Date.now() : toDate(now).getTime()
const [unit, amount] = pickRelativeUnit(toDate(value).getTime() - from)
return cached(`r:${locale}`, () =>
new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }),
).format(amount, unit)
},
list: (items, type = 'and') =>
cached(`l:${locale}:${type}`, () =>
new Intl.ListFormat(locale, {
style: 'long',
type: type === 'or' ? 'disjunction' : 'conjunction',
}),
).format(items),
}
}
/** In components. Reads the locale from context, so it re-renders on locale change. */
export function useFormatters(): Formatters {
const { i18n } = useLingui()
return useMemo(() => createFormatters(i18n.locale), [i18n.locale])
}
/** In loaders, server code, route handlers and tests — anywhere there is no React context. */
export const getFormatters = createFormatters
Notes:
- Set
DEFAULT_CURRENCYto the project's currency. There's no terminal to grep with, so look through the codebase yourself: an existingcurrency:option, anIntl.NumberFormat/toLocaleStringcall, or a hardcoded symbol. If nothing turns up, leave'USD'and keep the// adjust to this project's currencycomment — a wrong currency that looks deliberate is worse than one that flags itself. - The TypeScript
libgate.Intl.ListFormatneedses2021.intl;Intl.RelativeTimeFormat,notation: 'compact', andstyle: 'unit'needes2020.intl. Opentsconfig.json(Lovable's TanStack Start template sometimes splits this intotsconfig.app.jsonviareferences— check both) and readcompilerOptions.lib. If it resolves belowES2021, don't silently ship a module that fails to build: tell the user in one chat message and ask whether to raiselibtoES2021(recommended), or omitlist()andrelativeTime()— replacing their bodies withthrow new Error(...)stubs so the ten-entry surface stays intact and a missed call fails loudly instead of silently. - If
src/i18n/format.tsalready exists (a re-run, or project code with that name), don't overwrite it — add any exports it's missing instead.
A10. Verify via the preview
You can't run a build, but you can read the preview:
- The preview builds with no errors. If it fails mentioning the SWC plugin, an AST schema, or plugin invocation — that's the version-pinning caveat from A1; pin
@lingui/swc-pluginexactly. - Switching languages works. Pick a locale in the switcher;
document.documentElement.langupdates (visible in the element inspector, or log it), and the choice survives a reload (localStorage). - Strings still show source text. Expected — the catalogs are empty until strings are wrapped (Phase 4) and translations arrive (Phase 6). No console errors about failed catalog imports should appear.
- The formatters module has no type errors. If the preview's type-check surfaces an error on
Intl.ListFormatorIntl.RelativeTimeFormat, that's thelibgate from A9 — resolve it there, don't suppress the error.
Tell the user what to expect at this point: the plumbing is live, the visible text doesn't change yet.
Opt-in: URL locale routing (/:locale prefix)
Only if the user chose the URL-prefix option in the Ask phase. Lovable Vite SPAs use declarative react-router-dom routes in src/App.tsx — wrap the existing <Routes> content in a locale segment.
1. Locale layout. Create a layout route that validates the URL locale and activates it:
// src/components/LocaleLayout.tsx
import { useEffect } from 'react'
import { Navigate, Outlet, useLocation, useParams } from 'react-router-dom'
import { LOCALES, SOURCE_LOCALE, activateLocale } from '@/i18n'
export function LocaleLayout() {
const { locale } = useParams()
const { pathname } = useLocation()
const valid = locale !== undefined && LOCALES.includes(locale)
useEffect(() => {
if (valid) void activateLocale(locale!)
}, [locale, valid])
if (!valid) {
// Bare path like /dashboard — re-prefix with the source locale
return <Navigate to={`/${SOURCE_LOCALE}${pathname}`} replace />
}
return <Outlet />
}
2. Route tree. In src/App.tsx, nest the existing routes under /:locale (the old path="/" route becomes index; other paths lose their leading slash) and redirect the bare root:
import { Navigate, Route, Routes } from 'react-router-dom'
import { LocaleLayout } from '@/components/LocaleLayout'
import { detectLocale } from '@/i18n'
// Inside the existing <BrowserRouter>:
<Routes>
<Route path="/:locale" element={<LocaleLayout />}>
<Route index element={<Index />} />
<Route path="dashboard" element={<Dashboard />} />
{/* ...every other existing route, path without the leading slash... */}
<Route path="*" element={<NotFound />} />
</Route>
<Route path="/" element={<Navigate to={`/${detectLocale()}`} replace />} />
</Routes>
Bare deep links (/dashboard) match /:locale with an invalid param and get re-prefixed by LocaleLayout; the bare root (/) redirects to the visitor's detected locale. Keep the main.tsx bootstrap from A5 unchanged — it sets the initial locale, and LocaleLayout re-activates per URL from then on.
3. Link helper. Internal links need the prefix:
// src/localePath.ts
export function localePath(locale: string, path: string): string {
const normalized = path.startsWith('/') ? path : `/${path}`
return `/${locale}${normalized}`
}
import { Link, useParams } from 'react-router-dom'
import { localePath } from '@/localePath'
import { SOURCE_LOCALE } from '@/i18n'
function Navigation() {
const { locale } = useParams()
const currentLocale = locale ?? SOURCE_LOCALE
return (
<nav>
<Link to={localePath(currentLocale, '/')}>Home</Link>
<Link to={localePath(currentLocale, '/dashboard')}>Dashboard</Link>
</nav>
)
}
Update every existing internal <Link to="/...">, <a href="/..."> (internal paths), and navigate('/...') call to go through localePath(). Navigation components (header, sidebar, footer) first — they're on every page.
4. Switcher navigates instead of activating. With the locale in the URL, switching language means navigating to the same page under the new prefix — LocaleLayout handles activation:
// src/components/LanguageSwitcher.tsx (routing variant)
import { useLingui } from '@lingui/react/macro'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { LOCALES, saveLocale } from '@/i18n'
export function LanguageSwitcher() {
const { i18n } = useLingui()
const { locale } = useParams()
const { pathname } = useLocation()
const navigate = useNavigate()
const displayNames = new Intl.DisplayNames([i18n.locale], { type: 'language' })
function switchLocale(next: string) {
saveLocale(next)
const basePath = locale ? pathname.slice(locale.length + 1) : pathname
navigate(`/${next}${basePath || '/'}`)
}
return (
<Select value={i18n.locale}
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LOCALES.map((loc) => (
<SelectItem key={loc} value={loc}>
{displayNames.of(loc) ?? loc}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
Verify in the preview: / redirects to /<locale>, deep links re-prefix, switching languages keeps you on the same page under the new prefix.
Phase 2B: Setup — TanStack Start (SSR)
Nine steps, B1–B9, in order. The defining constraint of this path: the app renders on the server (Cloudflare Workers), so i18n state must be per-request. A module-level i18n singleton activated per request would leak locales across concurrent users — request 2 activating fr mid-render of request 1 sends French HTML to an English visitor. Every server-side snippet below creates a fresh setupI18n() instance per request instead.
Locale selection on this path is cookie-based: a locale cookie carries the visitor's choice, the accept-language header is the fallback, and the server picks the locale before rendering — so the first paint is already in the right language, with no client-side flash.
B1. Dependencies
Add these dependencies (do not write install commands — add them to the project's dependencies directly):
Runtime dependencies:
| Package | Version | Purpose |
|---|---|---|
@lingui/core |
^6 |
i18n runtime (setupI18n for per-request instances) |
@lingui/react |
^6 |
React bindings (I18nProvider, Trans, useLingui) |
Do not add @lingui/detect-locale on this path — it reads window and localStorage and throws on the server. Locale detection happens from the request (cookie + accept-language) in B4.
Dev dependencies:
| Package | Version | Purpose |
|---|---|---|
@lingui/cli |
^6 |
Used only by the GitHub Actions workflow (Phase 5) — never run here |
@lingui/babel-plugin-lingui-macro |
^6 |
Babel macro transform — Start uses @vitejs/plugin-react (Babel-based), not SWC |
@lingui/vite-plugin |
^6 |
Compiles .po catalogs when the app imports them |
@lingui/format-po |
^6 |
PO catalog formatter for lingui.config.ts |
Note the macro plugin difference from Path A: Lovable's TanStack Start template ships the Babel React plugin, so this path always uses @lingui/babel-plugin-lingui-macro, never @lingui/swc-plugin.
B2. vite.config.ts
Lovable's Start template wraps the entire Vite config in @lovable.dev/vite-tanstack-config — it bundles tanstackStart(), viteReact(), Tailwind, the Cloudflare plugin, lovable-tagger, env injection, and the @ alias internally. Do not add any of those plugins manually — duplicates break the build (the config's own comment warns about this). The wrapper exposes exactly the two options Lingui needs: plugins for extra Vite plugins and react for options forwarded to viteReact():
// vite.config.ts
import { defineConfig } from '@lovable.dev/vite-tanstack-config'
import { lingui } from '@lingui/vite-plugin'
export default defineConfig({
plugins: [lingui()],
react: { babel: { plugins: ['@lingui/babel-plugin-lingui-macro'] } },
})
Preserve any options already passed to defineConfig — merge, don't replace.
Fallback — Start project without the Lovable wrapper. If vite.config.ts uses plain Vite defineConfig with explicit plugins, the order matters: lingui(), then tanstackStart(), then the React plugin (it must come after Start's):
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'
import { lingui } from '@lingui/vite-plugin'
export default defineConfig({
plugins: [
lingui(),
tanstackStart(),
viteReact({ babel: { plugins: ['@lingui/babel-plugin-lingui-macro'] } }),
],
})
B3. lingui.config.ts
Identical to Path A — create the file exactly as in A3 (PO formatter, src/locales/{locale}/messages catalog path, locales from the Ask phase).
B4. Per-request i18n: shared module, locale resolution, middleware, router
This is the load-bearing step — six files. Pick these paths and keep them consistent everywhere.
1. src/i18n.ts — isomorphic (safe to import on server and client). No document, no localStorage:
// src/i18n.ts
import type { Messages } from '@lingui/core'
// Must match the `locales` array in lingui.config.ts
export const LOCALES: readonly string[] = ['en', 'es', 'fr']
export const SOURCE_LOCALE = 'en'
const RTL_LOCALES = new Set(['ar', 'he', 'fa', 'ur', 'ps', 'sd', 'yi'])
export function getDirection(locale: string): 'ltr' | 'rtl' {
return RTL_LOCALES.has(locale.split('-')[0]) ? 'rtl' : 'ltr'
}
export async function loadCatalog(locale: string): Promise<Messages> {
try {
const { messages } = await import(`./locales/${locale}/messages.po`)
return messages
} catch (e) {
console.error(`Failed to load "${locale}" catalog, falling back to "${SOURCE_LOCALE}"`, e)
const { messages } = await import(`./locales/${SOURCE_LOCALE}/messages.po`)
return messages
}
}
The dynamic import targets the .po file directly — @lingui/vite-plugin compiles it into the bundle at build time, so catalog loading works on Cloudflare Workers with no runtime filesystem. Add the same *.po module declaration from A4 (in src/vite-env.d.ts or src/po-modules.d.ts).
2. src/modules/lingui/i18n.server.ts — server-only locale
…(truncated)