Skip to main content
RapidDev - Software Development Agency
App Featurespersonalization-ux21 min read

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

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.

4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members

Feature spec

Intermediate

Category

personalization-ux

Build with AI

3–5 hours with Lovable or V0

Custom build

1–2 weeks custom dev

Running cost

$0–$50/mo depending on machine translation volume

Works on

Web

Everything it takes to ship Multi-Language Support — parts, prompts, and real costs.

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.

What users consider table stakes in 2026

  • Language switcher visible in the nav or footer with language names in their native script (English, Français, Deutsch, العربية)
  • Language preference persisted across sessions via cookie and, for authenticated users, saved to their Supabase profile
  • RTL layout correctly mirrored for Arabic and Hebrew — not just text direction but margin, padding, and icon orientation
  • Numbers, dates, and currency formatted per locale using the Intl API — not hardcoded format strings
  • No flash of untranslated content on page load — locale is known server-side before the first render
  • Translated email notifications matching the user's language preference, not always English

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.

Layers:UIDataBackend

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'.

Note: next-intl integrates at the middleware level, making the locale available during server-side rendering. react-i18next initializes in the client and must be initialized before the component tree renders — async initialization that is not awaited causes translation keys to render briefly before strings load.

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.

Note: Show language names in the target language's script, not the current language. 'Arabic' in English is less useful than 'العربية' — the user selecting Arabic may not read English labels.

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.

Note: The middleware.ts file must be at the project root (not inside src/app) and the matcher config must cover all app routes. A common mistake is a matcher pattern that excludes locale-prefixed paths, causing infinite redirect loops.

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.

Note: Non-logical Tailwind classes (ml-4, mr-4, pl-4, pr-4, text-left, text-right) do NOT flip in RTL mode. They must be replaced with their logical equivalents (ms-4, me-4, ps-4, pe-4, text-start, text-end) throughout the codebase. This audit takes more time than the i18n setup itself for large apps.

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.

Note: AI tools generate the English translation file accurately from existing UI text. The French, German, Arabic, etc. files must be populated separately — by a human translator, DeepL, or Google Cloud Translation API. Budget for translation as a separate task, not part of the AI build time.

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.

Note: Never use toLocaleDateString() without passing the locale — it uses the browser's OS locale, which may differ from the app locale the user has selected. Always pass the app locale explicitly.

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.

Note: Storing preferred_locale in the profiles table (not just a cookie) is essential for server-side notification sending — the Edge Function has no access to the user's browser cookie.

The 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:

schema.sql
1-- Add preferred_locale to the profiles table
2-- Run after the profiles table exists (see user-profiles feature for table creation)
3alter table public.profiles
4 add column if not exists preferred_locale text not null default 'en';
5
6-- Add a check constraint to limit to supported locales
7-- Update this list as you add new languages
8alter table public.profiles
9 add constraint profiles_preferred_locale_check
10 check (preferred_locale in ('en', 'fr', 'de', 'ar', 'es', 'pt', 'ja', 'zh'));
11
12-- RLS on profiles table allows UPDATE for own row (existing policy covers this)
13-- No additional policies needed if profiles table already has UPDATE policy for auth.uid() = id
14
15-- Optional: database-driven translation keys table
16-- Use only if translations are CMS-managed (not static JSON files)
17create table if not exists public.i18n_keys (
18 key text primary key,
19 translations jsonb not null default '{}',
20 updated_at timestamptz not null default now()
21);
22
23alter table public.i18n_keys enable row level security;
24
25-- All authenticated users can read translation keys
26create policy "All authenticated users can read i18n keys"
27 on public.i18n_keys for select
28 to authenticated
29 using (true);
30
31-- Only service role (admin) can modify translation keys
32create policy "Service role can manage i18n keys"
33 on public.i18n_keys for all
34 to service_role
35 using (true);

Heads up: 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 it — pick your path

Each tool fits this feature differently. Switch tabs to compare fit, time, the exact steps, and a copy-paste prompt.

Best UI, Next.js ecosystemFit for this feature:

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.

Step by step

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

Where this path bites

  • 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

Third-party services you'll need

The i18n infrastructure is free. Third-party services are optional for machine translation or translation management:

ServiceWhat it doesFree tierPaid from
next-intlNext.js App Router i18n — middleware, locale routing, translation hooksOpen-source, zero costFree
DeepL APIMachine translation for generating initial translation file drafts500K characters/mo freePro from €5.49/mo plus usage (approx)
Google Cloud Translation APIAlternative machine translation; wider language support than DeepL500K chars/mo freeBasic $20 per 1M chars; Advanced $80 per 1M chars (approx)
CrowdinTranslation Management System — web UI for human translators, TM, glossaryFree for open-source projectsTeam $50/mo (approx)

Swipe the table sideways to see pricing.

What it costs to run

Drag through the tiers to see how your monthly bill scales with users — no surprises later.

Estimated monthly running cost

$0/mo

Static translation JSON files. next-intl and react-i18next are free. Locale routing is server-side with no per-request cost. Supabase free tier for preferred_locale storage.

Estimates use verified 2026 pricing and assume typical usage per user. Your real bill depends on activity, storage, and third-party plans.

What breaks when AI tools build this

The failures people actually hit on their first build — the symptom, why it happens, and the exact fix.

Flash of English content before the correct language loads (FOUC)

Symptom: 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

Symptom: 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

Symptom: 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

Symptom: 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

Symptom: 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

1

Use websearch_to_tsquery for user-facing queries instead of to_tsquery — it handles natural language without requiring boolean operators from the user

2

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

3

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

4

Set the HTML lang attribute from the active locale on every page — this is required for screen readers and affects browser spell-check behavior

5

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

6

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

7

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)

When You Need Custom Development

Two-language support with static JSON files is well within AI-tool territory. Four scenarios require going further:

  • User-generated content must also be translated on the fly — a post written in English must appear in French for French-speaking users — this requires per-request machine translation via DeepL or Google Cloud Translation API, caching, and a translation queue for longer content
  • 10+ language support with locale-specific legal pages, regional feature flags (EU cookie consent, GDPR), and currency and tax rules per jurisdiction — this is workflow and product complexity, not just engineering
  • Professional human translation workflow with review and approval stages — Crowdin, Phrase, or Lokalise integration, branch-per-locale workflows, translation memory, and glossary management
  • Mixed RTL and LTR content in the same view — Arabic UI labels alongside embedded English product codes or screenshots — requires per-element direction control, not just a document-level dir attribute

RapidDev builds these features for production

Real auth, database, and payments — fixed price, delivered in weeks. You own 100% of the code.

Get a fixed-price quote

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.

RapidDev

Need this feature production-ready?

RapidDev builds multi-language support into real apps — auth, database, payments — at $13K–$25K.

Book a free consultation

30-min call. No commitment.

Want this built for you?

We ship production apps at a fixed price — $13K–$25K, 6–10 weeks, source code yours. You've seen what it takes; we do it every week.

Get a fixed-price quote

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.