# How to Add Multi-Language Support to Your App (Copy-Paste Prompts Included)

- Tool: App Features
- Last updated: July 2026

## TL;DR

Multi-language support needs four pieces: an i18n library (next-intl for Next.js, react-i18next for Lovable/Vite), translation JSON files per locale, a language switcher that persists the choice to a cookie and Supabase profile, and RTL support if Arabic or Hebrew is included. With V0 you can scaffold a two-language app in 3–4 hours for $0/month. The actual translation work — filling in the JSON files — is on you, not the AI tool.

## What Multi-Language Support Actually Requires

Adding a second language to an app is not just wrapping text in translation function calls. It involves locale routing (so /en/dashboard and /fr/dashboard are distinct URLs for SEO), server-side locale detection on first visit (so users see French without having to switch), RTL layout flipping for Arabic and Hebrew (which requires logical Tailwind properties throughout, not just a dir attribute), locale-aware date and number formatting (so 1,234.56 in English becomes 1 234,56 in French), and translated notification emails. The translation files themselves — the actual translated strings — AI tools do not generate. They scaffold the infrastructure and populate English strings; the translation is human or machine work.

## Anatomy of the Feature

Seven components make up production multi-language support. AI tools scaffold the i18n library setup and language switcher well. The locale middleware, RTL layout layer, and translated notifications need explicit specification to avoid the most common failure modes.

- **i18n Library** (ui): next-intl for Next.js App Router applications (V0's stack). react-i18next for Vite + React applications (Lovable's stack). Both support ICU message format for plurals and interpolation. Translation strings live in JSON files per locale in the /messages/ directory: /messages/en.json, /messages/fr.json. Keys use dot notation: 'nav.home', 'auth.signIn', 'errors.required'.
- **Language Switcher** (ui): A Radix UI Select or shadcn/ui DropdownMenu showing language name in its native script plus a flag or country code. onChange writes the new locale to a cookie via next-intl's redirect() function (which navigates to the locale-prefixed path) or to the Supabase profiles.preferred_locale column for authenticated users. The current locale is highlighted in the switcher.
- **Locale Router Middleware** (backend): next-intl middleware.ts at the Next.js project root detects the user's locale on first visit by reading the Accept-Language header. Redirects to the appropriate locale-prefixed path (/en/... or /fr/...). Stores the chosen locale in a cookie for subsequent visits. For authenticated Supabase users, reads preferred_locale from the profiles table and uses it to override the Accept-Language detection.
- **RTL Layout Layer** (ui): For Arabic (ar) and Hebrew (he) locales, the HTML dir attribute is set to 'rtl' on the root layout element. Tailwind CSS logical properties throughout the codebase flip automatically: ms-4 becomes mr-4 in RTL (margin-inline-start), me-4 becomes ml-4 (margin-inline-end), ps-4 becomes pr-4 (padding-inline-start), text-start becomes text-right. Icons and chevrons that indicate direction (arrows, dropdowns) must be mirrored using the rtl: Tailwind variant.
- **Translation Files** (data): JSON files per locale stored in the /messages/ directory. ICU message format for complex strings: 'You have {count, plural, one {# message} other {# messages}}'. Variable interpolation: 'Welcome back, {name}!'. Nested keys for organization: { auth: { signIn: 'Sign In', signOut: 'Sign Out' } }. Missing translation keys fall back to English with a console warning in development.
- **Intl Formatters** (ui): All dates, numbers, and currency values use the Intl API with the current locale passed explicitly: new Intl.DateTimeFormat(locale, { dateStyle: 'long' }).format(date). Intl.NumberFormat(locale, { style: 'currency', currency: 'EUR' }).format(amount). Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(-1, 'day') outputs 'yesterday' in English or 'hier' in French. No hardcoded date strings anywhere.
- **Translated Notifications** (backend): A Supabase Edge Function reads the recipient user's preferred_locale from the profiles table before sending an email via Resend or SendGrid. Selects the correct email template from /emails/{locale}/ directory. Formats any dates or numbers in the email using the server-side Intl API with the user's locale. Falls back to English if the user's locale template is missing.

## Data model

One column addition to the profiles table stores the user's locale preference for server-side use. Run this in the Supabase SQL Editor:

```sql
-- Add preferred_locale to the profiles table
-- Run after the profiles table exists (see user-profiles feature for table creation)
alter table public.profiles
  add column if not exists preferred_locale text not null default 'en';

-- Add a check constraint to limit to supported locales
-- Update this list as you add new languages
alter table public.profiles
  add constraint profiles_preferred_locale_check
  check (preferred_locale in ('en', 'fr', 'de', 'ar', 'es', 'pt', 'ja', 'zh'));

-- RLS on profiles table allows UPDATE for own row (existing policy covers this)
-- No additional policies needed if profiles table already has UPDATE policy for auth.uid() = id

-- Optional: database-driven translation keys table
-- Use only if translations are CMS-managed (not static JSON files)
create table if not exists public.i18n_keys (
  key text primary key,
  translations jsonb not null default '{}',
  updated_at timestamptz not null default now()
);

alter table public.i18n_keys enable row level security;

-- All authenticated users can read translation keys
create policy "All authenticated users can read i18n keys"
  on public.i18n_keys for select
  to authenticated
  using (true);

-- Only service role (admin) can modify translation keys
create policy "Service role can manage i18n keys"
  on public.i18n_keys for all
  to service_role
  using (true);
```

The i18n_keys table is optional and only needed for CMS-managed translations where non-technical team members update strings without a code deployment. For most apps, static JSON files in the repository are simpler and load faster. The profiles.preferred_locale column is always needed if you send translated notification emails.

## Build paths

### Lovable — fit 3/10, 3–5 hours

Lovable scaffolds react-i18next with translation JSON files and a language switcher. Locale routing and cookie persistence require careful prompting — Lovable's Vite stack does not have Next.js middleware-level locale routing. Best for adding a second language to an existing Lovable app.

1. Open your Lovable project and paste the prompt below in Agent Mode
2. After generation, verify the translation keys render correctly by switching the language in the UI — if you see 'nav.home' instead of 'Home', the i18n initialization has an async timing issue
3. Add your French (or other language) translation strings to /messages/fr.json — the AI generates the English file; the other language files must be populated manually or with a translation tool
4. Test RTL if Arabic is included: switch to Arabic and verify that padding and margins flip, not just text direction
5. Run the SQL from this page in Lovable Cloud to add the preferred_locale column to profiles

Starter prompt:

```
Add multi-language support to this Lovable app using react-i18next. Install react-i18next and i18next. Create translation files at /public/locales/en/translation.json and /public/locales/fr/translation.json. Extract ALL user-visible text strings from existing components into the English JSON file, then create the French file with the same keys and French translations (use accurate French). Initialize i18next in src/i18n.ts using initReactI18next with i18next-resources-to-backend for synchronous JSON loading — wrap the App with Suspense and pass a loading fallback so components never render before translations are ready. Create a LanguageSwitcher component in the navbar: a dropdown showing 'English' and 'Français' in their native scripts; onChange saves the locale to localStorage using i18n.changeLanguage(locale) and also upserts preferred_locale to the Supabase profiles table for authenticated users. Use the useTranslation hook in all components that render text: t('nav.home'), t('auth.signIn'), etc. Use ICU format for plurals: t('messages', {count}) with the plural key. Add Intl.DateTimeFormat(i18n.language, ...) for all date display — remove any hardcoded date format strings. Include edge case: if a translation key is missing in the active locale, fall back to English and log a warning to the console. Use the lang attribute on the html element matching the current locale for screen readers.
```

Limitations:

- Lovable's Vite + React Router stack does not support URL-based locale routing (/en/..., /fr/...) natively — locale is managed via React Context and localStorage, not URL paths; this affects SEO for locale-specific pages
- react-i18next initialization is async; if components render before i18n.init() resolves, translation keys appear briefly — specify i18next-resources-to-backend synchronous loading or Suspense wrapping explicitly

### V0 — fit 5/10, 3–4 hours

V0's Next.js App Router stack maps perfectly to next-intl middleware. Locale-prefixed URL routing, server-side locale detection, and shadcn/ui RTL support via the HTML dir attribute all work correctly. Best path for a new Next.js app or adding i18n to an existing one.

1. Paste the prompt below in V0 to generate the next-intl setup and language switcher
2. Add your locale JSON files to /messages/ — V0 generates en.json; populate fr.json (or other languages) manually
3. Set the NEXT_PUBLIC_DEFAULT_LOCALE env var in the Vars panel if needed
4. Publish to Vercel and test: visit the app in a French browser to verify it redirects to /fr/ automatically
5. Test RTL by switching to Arabic — inspect the CSS classes in DevTools and verify ms-/me- classes are flipping, not ml-/mr-

Starter prompt:

```
Add multi-language support using next-intl to this Next.js App Router app. Languages: English (en) and French (fr). Add Arabic (ar) with RTL support. Setup: install next-intl; create middleware.ts at the project root that uses createMiddleware({ locales: ['en', 'fr', 'ar'], defaultLocale: 'en' }) to detect browser Accept-Language on first visit and redirect to the correct locale prefix; store locale in a cookie. Create /messages/en.json, /messages/fr.json, /messages/ar.json with all current UI strings extracted from components — French and English must have accurate translated strings, Arabic can use placeholder text. Update layout.tsx: use locale from params, set the html lang attribute and dir attribute (dir='rtl' for 'ar'). Wrap content with NextIntlClientProvider. Update all components to use useTranslations() hook replacing hardcoded strings: const t = useTranslations('ComponentName'). Add Intl.DateTimeFormat(locale, { dateStyle: 'long' }) for all date rendering; Intl.NumberFormat(locale) for all numbers — remove hardcoded format strings. LanguageSwitcher component: shadcn/ui DropdownMenu in the navbar showing language name in native script; on select, call redirect(pathname, newLocale) from next-intl navigation; also UPDATE the Supabase profiles.preferred_locale column if the user is authenticated. RTL layout: audit all Tailwind spacing classes — replace ml- with ms-, mr- with me-, pl- with ps-, pr- with pe-, text-left with text-start, text-right with text-end throughout all components. For directional icons (chevrons, arrows), add rtl:rotate-180 class. Missing translation key fallback: fall back to English, log a warning. HTML lang attribute must update on locale change without full page reload.
```

Limitations:

- V0 generates the translation file structure and English strings; all other languages must be populated manually — plan translation as a separate step after the technical scaffold is complete
- V0 may miss some Tailwind non-logical classes during the RTL audit — do a manual find-replace for ml-, mr-, pl-, pr- after generation and verify with Chrome DevTools RTL emulation

### Custom — fit 3/10, 1–2 weeks

Custom development is justified for 10+ languages, database-driven CMS translations, or machine translation of user-generated content. The code infrastructure is similar — the complexity is workflow and translation management.

1. Integrate DeepL API or Google Cloud Translation API to generate machine translation drafts for each new locale — automate the initial fill of translation JSON files on string addition
2. Implement Crowdin, Phrase, or Lokalise as a Translation Management System — sync JSON files to and from the TMS so non-technical translators can review and approve strings through a web interface
3. Build database-driven translations using the i18n_keys table from the data model — allows marketing or support to update strings without a code deploy
4. Handle locale-specific legal pages, regional feature flags (EU cookie consent, GDPR banners, currency display), and different Terms of Service per jurisdiction

Limitations:

- Translation management at 10+ languages is primarily a workflow problem, not a code problem — the TMS integration cost (setup, maintenance, translator coordination) often exceeds the engineering cost

## Gotchas

- **Flash of English content before the correct language loads (FOUC)** — In React apps using react-i18next (Lovable's stack), i18n.init() is asynchronous. The component tree renders before initialization resolves, briefly showing translation keys like 'nav.home' or the English fallback before the French strings appear. Users see a flicker of the wrong language on every page load. Fix: For Lovable (react-i18next): use i18next-resources-to-backend to load translations synchronously from bundled JSON files instead of async HTTP fetches. Wrap the app root in React Suspense with a loading fallback. For V0 (next-intl): the middleware detects locale server-side, so the correct language is available before the first HTML byte is sent — no FOUC is possible. This is next-intl's primary advantage over client-side detection.
- **RTL layout only partially flips in Arabic mode** — Setting dir='rtl' on the html element flips text direction but does not flip Tailwind margin and padding classes. Components with ml-4 (left margin) will still have left margin in RTL, not right margin as expected. Mixed use of logical (ms-4) and non-logical (ml-4) classes creates an inconsistent layout where some elements flip and others do not. Fix: Do a global find-and-replace audit: ml- becomes ms-, mr- becomes me-, pl- becomes ps-, pr- becomes pe-, text-left becomes text-start, text-right becomes text-end. Enable Tailwind's rtl: variant in tailwind.config.js for any class that cannot be made logical. Test with Chrome DevTools: Rendering panel has a 'Force RTL' option that flips the direction without changing the locale, making it easy to spot non-logical classes.
- **next-intl locale routing works locally but 404s on Vercel deployment** — The middleware.ts file is not at the project root — it is inside src/app/ or src/ — and the Vercel build does not pick it up for middleware execution. Alternatively, the matcher config in the middleware uses a pattern that excludes locale-prefixed paths, causing the middleware to not run on any actual app routes. Fix: Ensure middleware.ts is at the project root (the same level as package.json, not inside any subdirectory). Verify the matcher pattern: '/((?!api|_next|.*\..*).*)' catches all app routes while excluding API routes, Next.js internals, and static files. Test by deploying to Vercel's preview environment and visiting /fr/ directly — a 404 means the middleware is not running.
- **Date shows ISO format in French locale despite changing the language** — Dates are formatted using toLocaleDateString() without passing the locale explicitly. This uses the browser's OS locale, which may be English even when the user has selected French in the app. On an English OS with the app set to French, dates show in English format. Fix: Always pass the app locale explicitly to the Intl API: new Intl.DateTimeFormat(locale, { dateStyle: 'long' }).format(date). Replace every toLocaleDateString() call in the codebase with this pattern. In next-intl, use the useFormatter() hook which picks up the current locale automatically from the context: const format = useFormatter(); format.dateTime(date, { dateStyle: 'long' }).
- **Translation keys render as literal strings after adding a second language in Lovable** — react-i18next initializes asynchronously. If the first component that calls t('nav.home') renders before i18n.init() resolves, it returns the key string 'nav.home' as the value. This happens specifically when a second language is added because the i18n setup was working before (English was the only language, so the missing-key fallback returned the key which happened to be readable English). Fix: Add i18next-resources-to-backend with synchronous JSON imports: import en from './locales/en/translation.json'; and pass resources: { en: { translation: en } } to i18next.init(). This makes initialization synchronous. Alternatively, wrap the App component in React Suspense — i18next will suspend rendering until init() resolves, preventing any component from rendering with uninitialized translations.

## Best practices

- Use websearch_to_tsquery for user-facing queries instead of to_tsquery — it handles natural language without requiring boolean operators from the user
- Store preferred_locale in the Supabase profiles table, not just a cookie — cookies are inaccessible from Edge Functions, so translated email notifications require the database column
- Use ICU message format for all strings with variables or plurals — never string-concatenate translated text with dynamic values; pluralization rules differ dramatically across languages
- Set the HTML lang attribute from the active locale on every page — this is required for screen readers and affects browser spell-check behavior
- Test RTL layout with Chrome DevTools Rendering panel's 'Force RTL' option before adding actual Arabic content — catching non-logical Tailwind classes early prevents a large audit later
- Never call toLocaleDateString() or toLocaleString() without passing the locale explicitly — always use new Intl.DateTimeFormat(locale) to ensure the app locale controls formatting, not the browser OS locale
- Budget translation as a separate task distinct from the technical scaffold — AI tools generate the English JSON file from existing UI text; filling in the other languages is a translation job that takes time and either costs money (translators) or requires review (machine translation)

## Frequently asked questions

### How do I add a language without breaking the existing English UI?

The safe order is: first extract all existing English strings into the en.json translation file and wrap them with t() calls. Deploy and verify everything still displays correctly in English — the t() call should return the English string if the key exists. Then add the fr.json (or other language) file and the language switcher. At this point, switching to French may show English for any missing keys, but English never breaks. Add translations incrementally.

### Does multi-language support help with SEO?

Yes, significantly. With next-intl's URL-based routing (/en/..., /fr/...), each language version is a distinct URL that Google indexes separately. Add hreflang link tags to the page head pointing from each language version to the others — next-intl generates these automatically when configured. This tells Google which version to show to which audience and prevents duplicate content penalties.

### How do I handle plurals and gender in translations?

Use ICU message format supported by both next-intl and react-i18next. Plurals: 'You have {count, plural, one {# message} other {# messages}}'. This adapts to each language's plural rules (Russian has four plural forms; Arabic has six). For gender: '{gender, select, male {He updated his profile} female {She updated her profile} other {They updated their profile}}'. Never concatenate translated fragments to build pluralized strings — that approach breaks in most languages.

### Can I use AI to automatically translate my app?

Yes, for a draft. DeepL API (free tier: 500K chars/mo) or Google Cloud Translation API generate accurate initial translations for major language pairs. Use them to populate the non-English JSON files, then have a native speaker review the output — automated translation handles common UI strings well (button labels, field names) but struggles with marketing copy, error messages with technical terms, and branded language. Never ship machine translation for legal text without human review.

### What is the difference between i18n and l10n?

i18n (internationalization) is the engineering process: extracting strings into translation files, setting up locale routing, making the UI structure support multiple languages. l10n (localization) is the content process: actually translating the strings, adapting dates and currencies for each region, and handling cultural differences in UX patterns. AI tools handle i18n; l10n requires translators or machine translation plus review.

### How do I support right-to-left languages like Arabic?

Three changes: set dir='rtl' on the html element when the locale is 'ar' or 'he', replace all directional Tailwind classes with their logical equivalents (ml- with ms-, mr- with me-, etc.), and mirror directional icons (use rtl:rotate-180 on chevrons and arrows). Test using Chrome DevTools Rendering panel's 'Force RTL' option to find non-logical classes without needing actual Arabic content. The dir attribute change alone is not sufficient — non-logical CSS classes will leave some elements pointing the wrong direction.

### Do I need different URLs for each language?

For SEO, yes — /en/pricing and /fr/tarifs are distinct pages that Google ranks independently for English and French queries. next-intl handles this with locale-prefixed routing built into the middleware. For apps where SEO is not a concern (logged-in SaaS tools, internal dashboards), locale management via React Context and localStorage is simpler and produces no meaningful SEO difference.

### How do I translate email notifications?

Store the user's preferred_locale in the Supabase profiles table (the SQL above adds this column). In your Supabase Edge Function that sends emails, read the recipient's preferred_locale before selecting the email template. Keep email templates as separate files per locale in /emails/en/, /emails/fr/, etc. Format any dates or numbers in the email with Intl.DateTimeFormat(userLocale) — server-side Deno supports the Intl API. Fall back to English if the user's locale template does not exist.

---

Source: https://www.rapidevelopers.com/app-features/multi-language-support
© RapidDev — https://www.rapidevelopers.com/app-features/multi-language-support
