Language Switcher in React: Dropdown, Flag Icons, i18n Routing
Build a polished language switcher in React with flag icons, i18n routing, and next-intl. Dropdown variants, RTL support, and URL-based locale switching covered.
Why a Language Switcher Is Harder Than It Looks
You'd think a language switcher is just a <select>. Pick a value, update a cookie, done. In practice, it's one of those components that quietly swallows a week if you don't plan it right — because it touches routing, state, fonts, text direction, and sometimes even your CSS layout.
The two approaches people reach for in 2026 are locale-prefixed URLs (/fr/about) and cookie/localStorage-based locale detection. URL-based wins almost every time for SEO and shareability. Honestly, storing locale only in a cookie means Google can't crawl your French content without executing JavaScript — which it sometimes doesn't.
Worth noting: Next.js 13+ with the App Router has first-class i18n routing baked in, but it deliberately removed the old next.config.js i18n block. So if you're on Next.js 14 or 15 you'll use middleware + next-intl (or similar) rather than the built-in config you might remember from Next 12.
This article builds the whole thing — a dropdown with flag icons, locale routing with next-intl, RTL support, and a standalone React version for non-Next projects. Let's go.
Project Setup: next-intl and Locale Routing
Install next-intl first. As of version 3.x it ships a createNavigation API that auto-wraps Link, useRouter, and redirect with locale awareness — which saves you from manually threading locale through every link.
npm install next-intlCreate a middleware.ts at your project root. This is the key piece — it intercepts every request and either redirects to the right locale prefix or lets it pass through.
// middleware.ts
import createMiddleware from 'next-intl/middleware';
export default createMiddleware({
locales: ['en', 'fr', 'de', 'ar'],
defaultLocale: 'en',
localePrefix: 'always' // forces /en/about, /fr/about, etc.
});
export const config = {
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)'],
};Then create your messages folder at messages/en.json, messages/fr.json, etc. Keep them flat for small apps, nested for larger ones. One more thing — add a [locale] segment at the top of your app/ directory: app/[locale]/layout.tsx. Everything nests under it.
// app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
export default async function LocaleLayout({ children, params: { locale } }) {
const messages = await getMessages();
return (
<html lang={locale} dir={locale === 'ar' ? 'rtl' : 'ltr'}>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}The Language Switcher Dropdown Component
Here's where it gets interesting. You need two things: a list of locales with display names and flags, and a way to switch the current URL to the new locale without losing the current path. next-intl's useRouter handles the path rewrite automatically.
// components/LanguageSwitcher.tsx
'use client';
import { useLocale } from 'next-intl';
import { useRouter, usePathname } from 'next/navigation';
import { useState, useRef, useEffect } from 'react';
const LOCALES = [
{ code: 'en', label: 'English', flag: '🇺🇸' },
{ code: 'fr', label: 'Français', flag: '🇫🇷' },
{ code: 'de', label: 'Deutsch', flag: '🇩🇪' },
{ code: 'ar', label: 'العربية', flag: '🇸🇦' },
] as const;
export function LanguageSwitcher() {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const current = LOCALES.find((l) => l.code === locale) ?? LOCALES[0];
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, []);
function switchLocale(code: string) {
// Strip the current locale prefix from pathname, then re-navigate
const segments = pathname.split('/');
segments[1] = code; // replace the [locale] segment
router.push(segments.join('/'));
setOpen(false);
}
return (
<div ref={ref} className="relative inline-block">
<button
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-white/10 bg-white/5 hover:bg-white/10 transition-colors text-sm"
aria-haspopup="listbox"
aria-expanded={open}
>
<span aria-hidden="true">{current.flag}</span>
<span>{current.label}</span>
<svg className={`w-4 h-4 transition-transform ${open ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<ul
role="listbox"
className="absolute top-full mt-1 end-0 w-44 rounded-lg border border-white/10 bg-neutral-900 shadow-xl z-50 overflow-hidden"
>
{LOCALES.map((l) => (
<li key={l.code}>
<button
role="option"
aria-selected={l.code === locale}
onClick={() => switchLocale(l.code)}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/10 transition-colors ${
l.code === locale ? 'text-blue-400 font-medium' : 'text-white/80'
}`}
>
<span aria-hidden="true">{l.flag}</span>
{l.label}
</button>
</li>
))}
</ul>
)}
</div>
);
}Notice end-0 instead of right-0 on the dropdown. That's a logical property — in RTL (Arabic) it automatically flips to the left side. 16 pixels of thought saves you a headache later.
That said, emoji flags have inconsistent rendering across platforms. On Windows they render as two-letter country codes rather than actual flag images. If you need pixel-perfect flags everywhere, use an SVG icon library like flag-icons (the npm package) or country-flag-icons instead.
// Using flag-icons CSS classes instead of emoji
import 'flag-icons/css/flag-icons.min.css';
const LOCALES = [
{ code: 'en', label: 'English', iso: 'us' },
{ code: 'fr', label: 'Français', iso: 'fr' },
];
// In JSX:
<span className={`fi fi-${l.iso} rounded-sm`} aria-hidden="true" />Flag Icon Libraries: Emoji vs SVG vs CSS
Emoji flags are zero-dependency and work fine on macOS and Android. Windows is the problem — Chrome on Windows 11 renders 🇫🇷 as the letters "FR" because Microsoft doesn't ship emoji flags in the OS. So decide early: is your audience desktop-heavy? Then you need a proper icon set.
The three realistic options in 2026 are: flag-icons (CSS sprite, 4kB gzipped per flag), country-flag-icons (React components, tree-shakeable SVG), or just a self-hosted SVG folder. Look, for most apps country-flag-icons is the call — you import only what you use and you're done.
npm install country-flag-iconsimport FR from 'country-flag-icons/react/3x2/FR';
import US from 'country-flag-icons/react/3x2/US';
import DE from 'country-flag-icons/react/3x2/DE';
// Each flag is a standalone SVG component:
<FR title="French" className="w-5 h-4 rounded-sm" />Quick aside: if you're building a component with heavy visual polish — like a glassmorphism navbar — flag icons look great with a subtle backdrop blur on the dropdown. The glassmorphism components page has copy-paste patterns you can drop straight into your switcher's panel styling.
Standalone React (No Next.js) Implementation
Not every project is on Next.js. If you're using React Router v7 or just client-side React, the pattern is similar but you manage locale state yourself — typically in context, with persistence in localStorage.
// i18n/context.tsx
import { createContext, useContext, useState, useEffect } from 'react';
type Locale = 'en' | 'fr' | 'de';
interface I18nContextType {
locale: Locale;
setLocale: (l: Locale) => void;
t: (key: string) => string;
}
const I18nContext = createContext<I18nContextType | null>(null);
export function I18nProvider({ children }: { children: React.ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(() => {
return (localStorage.getItem('locale') as Locale) ?? 'en';
});
const [messages, setMessages] = useState<Record<string, string>>({});
useEffect(() => {
import(`../messages/${locale}.json`).then((m) => setMessages(m.default));
localStorage.setItem('locale', locale);
document.documentElement.lang = locale;
document.documentElement.dir = locale === 'ar' ? 'rtl' : 'ltr';
}, [locale]);
function setLocale(l: Locale) {
setLocaleState(l);
}
function t(key: string) {
return messages[key] ?? key;
}
return (
<I18nContext.Provider value={{ locale, setLocale, t }}>
{children}
</I18nContext.Provider>
);
}
export const useI18n = () => {
const ctx = useContext(I18nContext);
if (!ctx) throw new Error('useI18n must be used within I18nProvider');
return ctx;
};Dynamic import() on the messages file means you only load the active locale's strings — no 300kB bundle of all translations on first paint. This matters. On a 3G connection, that's the difference between 800ms and 1.4 seconds.
Then your switcher just calls setLocale from the context. No routing changes needed. The tradeoff is that URLs don't encode the locale, so users can't share localized links — which is fine for dashboards and apps, not fine for public content sites.
For React Router v7 specifically, you can use a URL search param (?lang=fr) or a route segment (/:locale/*) and read it from useParams. The former is simpler, the latter is better for SEO if you're doing SSR.
RTL Support and Layout Considerations
Adding Arabic, Hebrew, or Persian means your layout needs to handle RTL. The good news: setting dir="rtl" on <html> flips the entire document automatically for most things — padding, margin, flexbox direction, absolute positioning anchored to start/end. The bad news: a lot of existing CSS uses left/right explicitly and will break.
Tailwind v3.3+ has RTL variants out of the box. Use ltr: and rtl: prefixes for directional overrides.
// Tailwind RTL-aware utility example
<div className="flex rtl:flex-row-reverse items-center gap-3">
<FlagIcon />
<span>{label}</span>
</div>
// Or use logical properties (no variant needed):
<div className="ms-4 pe-3"> {/* ms = margin-start, pe = padding-end */}
...
</div>One thing people miss: fonts. Arabic script needs a different typeface than Latin. You can't just set font-family: Inter and call it a day — Inter doesn't include Arabic glyphs at all. Load a proper Arabic font (Noto Sans Arabic, IBM Plex Arabic) conditionally when the locale is ar.
useEffect(() => {
if (locale === 'ar') {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'https://fonts.googleapis.com/css2?family=Noto+Sans+Arabic:wght@400;600&display=swap';
document.head.appendChild(link);
document.body.style.fontFamily = "'Noto Sans Arabic', sans-serif";
} else {
document.body.style.fontFamily = '';
}
}, [locale]);If your app has a lot of visual flair — say, a cyberpunk or vaporwave aesthetic with neon text and gradient borders — test it in RTL. Text shadows, gradient directions, and icon placements often need explicit directional fixes that you'll only catch by actually switching the UI to Arabic and scrolling around.
Accessibility and UX Patterns for the Switcher
The dropdown in the code above uses role="listbox" and aria-selected — that's the right ARIA pattern for a selector where one option is always active. Don't use role="menu" with role="menuitem" for this; menus are for actions, not value selection. It's a small distinction that screen reader users notice immediately.
Keyboard navigation should work without custom code if you use <button> elements inside the list — they're focusable by default, Tab cycles through them, and Enter/Space fires the click. What you do need to wire up manually is Escape closing the dropdown and focus returning to the trigger.
// Add to the trigger button's onKeyDown:
onKeyDown={(e) => {
if (e.key === 'Escape') {
setOpen(false);
// Return focus to trigger
}
}}Worth noting: the flag itself should always have aria-hidden="true". The locale label ('Français', 'Deutsch') is the accessible name. Screen readers don't need to announce the flag emoji — and on Windows where it renders as "FR", you definitely don't want it read aloud.
For placement, put the language switcher in the navbar patterns — top-right corner for LTR, top-left for RTL. Avoid burying it in a settings page. Internationalized users know to look for it in the header, and making them hunt for it is a bad first impression before they've even read a word.
FAQ
next-intl is purpose-built for the App Router and handles server components natively — i18next requires extra wiring to work in RSC. Start with next-intl unless you already have an i18next setup you're migrating.
For URL-based routing, the locale is in the URL so persistence is automatic. For client-only apps, store the choice in localStorage and read it on mount to set the initial locale.
No — Windows renders emoji flags as two-letter text codes, not actual flags. Use a library like country-flag-icons or flag-icons for consistent cross-platform rendering.
Yes: set dir="rtl" on the <html> element, swap left/right CSS to logical properties (start/end), and load an Arabic-compatible font — Latin fonts like Inter don't include Arabic glyphs.