In today's globalized digital landscape, offering your web application in multiple languages isn't just a nice-to-haveβit's often essential for reaching broader audiences and complying with regional regulations. Whether you're building a financial app for Canadian freelancers (where French support is legally required in Quebec), or simply want to make your product more accessible, implementing a robust language switcher is a crucial feature.
In this comprehensive guide, we'll walk through building a professional bilingual language switcher for a Next.js application styled with Tailwind CSS and deployed on Vercel. We'll create a solution that not only detects the user's preferred language automatically but also allows them to manually switch between languages at any timeβstoring their preference for future visits.
By the end of this tutorial, you'll have a production-ready language system that:
- Automatically detects the user's browser language
- Allows manual language switching with a smooth UX
- Persists language preferences across sessions
- Supports dynamic content formatting (dates, currencies, numbers)
- Maintains SEO-friendly URLs with locale prefixes
- Works seamlessly with server-side rendering (SSR)
Prerequisites
Before diving in, make sure you have the following set up:
Technical Requirements
- Node.js version 18.17 or higher installed
- npm, yarn, or pnpm package manager
- Basic knowledge of React and Next.js
- Familiarity with Tailwind CSS for styling
- A Vercel account (free tier works perfectly)
Project Setup
If you're starting fresh, create a new Next.js project:
bash
npx create-next-app@latest my-bilingual-app
cd my-bilingual-app
When prompted, select:
- β TypeScript (recommended but optional)
- β Tailwind CSS
- β App Router
- β ESLint
Required Dependencies
We'll be using next-i18next
for internationalization. Install it along with its peer dependencies:
bash
npm install next-i18next react-i18next i18next
# or
yarn add next-i18next react-i18next i18next
Step-by-Step Implementation Guide
Step 1: Configure Next.js for Internationalization
First, create a next-i18next.config.js
file in your project root:
javascript
// next-i18next.config.js
module.exports = {
i18n: {
defaultLocale: 'en',
locales: ['en', 'fr'],
localeDetection: true, // Automatically detect user's language
},
fallbackLng: 'en',
ns: ['common', 'home', 'navigation'], // Translation namespaces
defaultNS: 'common',
interpolation: {
escapeValue: false, // React already escapes values
},
}
Update your next.config.js
to include the i18n configuration:
javascript
// next.config.js
const { i18n } = require('./next-i18next.config')
/** @type {import('next').NextConfig} */
const nextConfig = {
i18n,
reactStrictMode: true,
}
module.exports = nextConfig
Step 2: Create Translation Files Structure
Set up your translation files in a public/locales
directory:
public/
βββ locales/
βββ en/
β βββ common.json
β βββ home.json
β βββ navigation.json
βββ fr/
βββ common.json
βββ home.json
βββ navigation.json
Create sample translation files:
json
// public/locales/en/common.json
{
"welcome": "Welcome",
"language": "Language",
"select_language": "Select Language",
"continue": "Continue",
"save": "Save",
"cancel": "Cancel"
}
json
// public/locales/fr/common.json
{
"welcome": "Bienvenue",
"language": "Langue",
"select_language": "SΓ©lectionner la langue",
"continue": "Continuer",
"save": "Enregistrer",
"cancel": "Annuler"
}
json
// public/locales/en/navigation.json
{
"home": "Home",
"about": "About",
"services": "Services",
"contact": "Contact"
}
json
// public/locales/fr/navigation.json
{
"home": "Accueil",
"about": "Γ propos",
"services": "Services",
"contact": "Contact"
}
Step 3: Create the Language Context Provider
Create a context to manage language state globally:
typescript
// contexts/LanguageContext.tsx
'use client'
import React, { createContext, useContext, useState, useEffect } from 'react'
import { useRouter } from 'next/router'
import { setCookie, getCookie } from 'cookies-next'
interface LanguageContextType {
locale: string
setLocale: (locale: string) => void
isLoading: boolean
}
const LanguageContext = createContext<LanguageContextType | undefined>(undefined)
export function LanguageProvider({ children }: { children: React.ReactNode }) {
const router = useRouter()
const [locale, setLocaleState] = useState(router.locale || 'en')
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
// Get saved preference from cookie
const savedLocale = getCookie('preferred-locale')
if (savedLocale && savedLocale !== locale) {
setLocale(savedLocale as string)
}
}, [])
const setLocale = async (newLocale: string) => {
setIsLoading(true)
// Save preference to cookie
setCookie('preferred-locale', newLocale, {
maxAge: 60 * 60 * 24 * 365, // 1 year
sameSite: 'lax',
})
// Change the route to the new locale
await router.push(router.pathname, router.asPath, { locale: newLocale })
setLocaleState(newLocale)
setIsLoading(false)
}
return (
<LanguageContext.Provider value={{ locale, setLocale, isLoading }}>
{children}
</LanguageContext.Provider>
)
}
export const useLanguage = () => {
const context = useContext(LanguageContext)
if (!context) {
throw new Error('useLanguage must be used within a LanguageProvider')
}
return context
}
Step 4: Build the Language Switcher Component
Create a beautiful and accessible language switcher:
typescript
// components/LanguageSwitcher.tsx
'use client'
import React, { useState, useRef, useEffect } from 'react'
import { useTranslation } from 'next-i18next'
import { useLanguage } from '@/contexts/LanguageContext'
import { ChevronDownIcon } from '@heroicons/react/24/outline'
const languages = [
{ code: 'en', name: 'English', flag: 'π¬π§' },
{ code: 'fr', name: 'FranΓ§ais', flag: 'π«π·' },
]
export default function LanguageSwitcher() {
const { t } = useTranslation('common')
const { locale, setLocale, isLoading } = useLanguage()
const [isOpen, setIsOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
const currentLanguage = languages.find(lang => lang.code === locale) || languages[0]
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handleLanguageChange = async (langCode: string) => {
if (langCode !== locale) {
await setLocale(langCode)
}
setIsOpen(false)
}
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className={`
flex items-center gap-2 px-4 py-2
bg-white dark:bg-gray-800
border border-gray-300 dark:border-gray-600
rounded-lg shadow-sm
hover:bg-gray-50 dark:hover:bg-gray-700
focus:outline-none focus:ring-2 focus:ring-blue-500
transition-all duration-200
${isLoading ? 'opacity-50 cursor-not-allowed' : ''}
`}
disabled={isLoading}
aria-label={t('select_language')}
aria-expanded={isOpen}
aria-haspopup="true"
>
<span className="text-2xl" aria-hidden="true">{currentLanguage.flag}</span>
<span className="font-medium text-gray-700 dark:text-gray-200">
{currentLanguage.name}
</span>
<ChevronDownIcon
className={`w-4 h-4 text-gray-500 transition-transform duration-200 ${
isOpen ? 'rotate-180' : ''
}`}
/>
</button>
{/* Dropdown Menu */}
{isOpen && (
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800
border border-gray-200 dark:border-gray-700
rounded-lg shadow-lg z-50
animate-in fade-in slide-in-from-top-1 duration-200">
<div className="py-1" role="menu">
{languages.map((lang) => (
<button
key={lang.code}
onClick={() => handleLanguageChange(lang.code)}
className={`
w-full px-4 py-2 text-left flex items-center gap-3
hover:bg-gray-100 dark:hover:bg-gray-700
transition-colors duration-150
${locale === lang.code
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
: 'text-gray-700 dark:text-gray-200'
}
`}
role="menuitem"
>
<span className="text-2xl" aria-hidden="true">{lang.flag}</span>
<span className="font-medium">{lang.name}</span>
{locale === lang.code && (
<span className="ml-auto">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd" />
</svg>
</span>
)}
</button>
))}
</div>
</div>
)}
</div>
)
}
Step 5: Update Your App Layout
Integrate the language switcher into your main layout:
typescript
// pages/_app.tsx (or app/layout.tsx for App Router)
import { appWithTranslation } from 'next-i18next'
import { LanguageProvider } from '@/contexts/LanguageContext'
import LanguageSwitcher from '@/components/LanguageSwitcher'
import type { AppProps } from 'next/app'
import '@/styles/globals.css'
function MyApp({ Component, pageProps }: AppProps) {
return (
<LanguageProvider>
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Navigation Header */}
<header className="bg-white dark:bg-gray-800 shadow-sm">
<nav className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<div className="flex-shrink-0">
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
Your App
</h1>
</div>
{/* Language Switcher in Navigation */}
<LanguageSwitcher />
</div>
</nav>
</header>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<Component {...pageProps} />
</main>
</div>
</LanguageProvider>
)
}
export default appWithTranslation(MyApp)
Step 6: Implement Server-Side Translation Loading
For each page, load the necessary translations:
typescript
// pages/index.tsx
import { GetStaticProps } from 'next'
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
import { useTranslation } from 'next-i18next'
export default function Home() {
const { t } = useTranslation(['common', 'home'])
return (
<div className="space-y-6">
<h1 className="text-4xl font-bold text-gray-900 dark:text-white">
{t('common:welcome')}
</h1>
<p className="text-lg text-gray-600 dark:text-gray-300">
{t('home:hero_description')}
</p>
</div>
)
}
export const getStaticProps: GetStaticProps = async ({ locale }) => {
return {
props: {
...(await serverSideTranslations(locale ?? 'en', ['common', 'home'])),
},
}
}
Step 7: Add Dynamic Content Formatting
Create utility functions for locale-aware formatting:
typescript
// utils/formatting.ts
export const formatCurrency = (amount: number, locale: string): string => {
const formatter = new Intl.NumberFormat(locale === 'fr' ? 'fr-CA' : 'en-CA', {
style: 'currency',
currency: 'CAD',
})
return formatter.format(amount)
}
export const formatDate = (date: Date, locale: string): string => {
const formatter = new Intl.DateTimeFormat(locale === 'fr' ? 'fr-CA' : 'en-CA', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
return formatter.format(date)
}
export const formatNumber = (number: number, locale: string): string => {
const formatter = new Intl.NumberFormat(locale === 'fr' ? 'fr-CA' : 'en-CA')
return formatter.format(number)
}
Step 8: Deploy to Vercel
- Push your code to GitHub
- Connect to Vercel:
- Go to vercel.com
- Import your GitHub repository
- Vercel will automatically detect Next.js
- Configure environment variables (if any):
NEXT_PUBLIC_DEFAULT_LOCALE=en
- Deploy: Vercel will automatically handle the i18n routing configuration
Step 9: Testing Your Implementation
Create a test component to verify everything works:
typescript
// components/LanguageTest.tsx
import { useTranslation } from 'next-i18next'
import { useLanguage } from '@/contexts/LanguageContext'
import { formatCurrency, formatDate, formatNumber } from '@/utils/formatting'
export default function LanguageTest() {
const { t } = useTranslation('common')
const { locale } = useLanguage()
const testAmount = 5000
const testDate = new Date()
const testNumber = 1234567.89
return (
<div className="p-6 bg-white dark:bg-gray-800 rounded-lg shadow space-y-4">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
{t('language')} Test
</h2>
<div className="space-y-2 text-gray-600 dark:text-gray-300">
<p>Current Locale: <span className="font-mono">{locale}</span></p>
<p>Currency: {formatCurrency(testAmount, locale)}</p>
<p>Date: {formatDate(testDate, locale)}</p>
<p>Number: {formatNumber(testNumber, locale)}</p>
</div>
</div>
)
}
Step 10: Optimize for Production
Add these optimizations for better performance:
- Lazy load translations for better performance:
typescript
// next-i18next.config.js
module.exports = {
// ... existing config
reloadOnPrerender: process.env.NODE_ENV === 'development',
serializeConfig: false,
react: {
useSuspense: false,
},
}
- Add language persistence middleware:
typescript
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const response = NextResponse.next()
// Check for language cookie
const preferredLocale = request.cookies.get('preferred-locale')
if (preferredLocale && !request.url.includes(`/${preferredLocale.value}`)) {
// Redirect to preferred language
const url = request.nextUrl.clone()
url.locale = preferredLocale.value
return NextResponse.redirect(url)
}
return response
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}
Conclusion
You now have a fully functional, production-ready bilingual language switcher for your Next.js application! This implementation provides:
β
Automatic language detection based on browser settings
β
Manual language switching with a beautiful UI
β
Persistent language preferences using cookies
β
SEO-friendly URLs with locale prefixes
β
Dynamic content formatting for currencies, dates, and numbers
β
Smooth transitions and loading states
β
Full accessibility support
β
Dark mode compatibility
The beauty of this approach is that it scales easilyβyou can add more languages by simply adding new locale folders and updating your configuration. The system is also highly maintainable, with all translations centralized in JSON files that can be easily managed by translators or content teams.
Next Steps
To further enhance your multilingual application, consider:
- Adding language-specific fonts for better typography
- Implementing RTL (right-to-left) support for languages like Arabic
- Creating a translation management workflow with tools like Crowdin or Lokalise
- Adding language-specific SEO meta tags
- Implementing automatic translation fallbacks for missing keys
- Adding analytics to track language preferences and usage patterns
Remember that localization goes beyond just translationβconsider cultural differences in design, color preferences, and user expectations for each market you're targeting.
Happy coding, and may your app reach users across the globe! π
Member discussion