Building a multilingual website expands your audience across language barriers. Internationalization (i18n) involves structuring your application to support multiple languages and locales, including translated content, date/number formatting, and RTL support. This guide covers implementing i18n in modern web frameworks on your VPS.
i18n Architecture Decisions
URL Strategies
- Subdirectory:
example.com/en/,example.com/fr/— best for SEO, easiest to implement - Subdomain:
en.example.com,fr.example.com— separate deployments possible - Domain:
example.com,example.fr— strongest geo-targeting, most expensive
Next.js i18n Implementation
// next.config.mjs
export default {
i18n: {
locales: ['en', 'fr', 'es', 'de', 'ja'],
defaultLocale: 'en',
localeDetection: true,
},
}
// Or with App Router (Next.js 14+):
// Use middleware for locale detection
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
import { match } from '@formatjs/intl-localematcher'
import Negotiator from 'negotiator'
const locales = ['en', 'fr', 'es', 'de', 'ja']
const defaultLocale = 'en'
function getLocale(request: NextRequest): string {
const headers = { 'accept-language': request.headers.get('accept-language') || '' }
const languages = new Negotiator({ headers }).languages()
return match(languages, locales, defaultLocale)
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
const pathnameHasLocale = locales.some(
locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
)
if (pathnameHasLocale) return
const locale = getLocale(request)
request.nextUrl.pathname = `/${locale}${pathname}`
return NextResponse.redirect(request.nextUrl)
}
Translation Files
// messages/en.json
{
"common": {
"nav": {
"home": "Home",
"about": "About Us",
"contact": "Contact",
"blog": "Blog"
},
"footer": {
"copyright": "All rights reserved.",
"privacy": "Privacy Policy"
}
},
"home": {
"hero": {
"title": "Welcome to Our Platform",
"subtitle": "Build something amazing today",
"cta": "Get Started"
}
}
}
// messages/fr.json
{
"common": {
"nav": {
"home": "Accueil",
"about": "À propos",
"contact": "Contact",
"blog": "Blog"
},
"footer": {
"copyright": "Tous droits réservés.",
"privacy": "Politique de confidentialité"
}
},
"home": {
"hero": {
"title": "Bienvenue sur notre plateforme",
"subtitle": "Construisez quelque chose d'incroyable aujourd'hui",
"cta": "Commencer"
}
}
}
Using Translations in Components
// Using next-intl (recommended for App Router)
npm install next-intl
// app/[locale]/page.tsx
import { useTranslations } from 'next-intl'
export default function HomePage() {
const t = useTranslations('home')
return (
<section>
<h1>{t('hero.title')}</h1>
<p>{t('hero.subtitle')}</p>
<button>{t('hero.cta')}</button>
</section>
)
}
// Pluralization and interpolation
// messages/en.json:
// "items": "{count, plural, =0 {No items} =1 {One item} other {# items}}"
// Usage: t('items', { count: 5 }) → "5 items"
SEO for Multilingual Sites
// app/[locale]/layout.tsx
export default function LocaleLayout({
children,
params: { locale },
}: {
children: React.ReactNode
params: { locale: string }
}) {
return (
<html lang={locale} dir={locale === 'ar' ? 'rtl' : 'ltr'}>
<head>
{/* hreflang tags for SEO */}
<link rel="alternate" hrefLang="en" href="https://example.com/en" />
<link rel="alternate" hrefLang="fr" href="https://example.com/fr" />
<link rel="alternate" hrefLang="es" href="https://example.com/es" />
<link rel="alternate" hrefLang="x-default" href="https://example.com/en" />
</head>
<body>{children}</body>
</html>
)
}
Date and Number Formatting
// Using the Intl API (built into JavaScript)
const formatDate = (date: Date, locale: string) => {
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(date)
}
formatDate(new Date(), 'en-US') // "March 15, 2026"
formatDate(new Date(), 'fr-FR') // "15 mars 2026"
formatDate(new Date(), 'ja-JP') // "2026年3月15日"
const formatCurrency = (amount: number, locale: string, currency: string) => {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
}).format(amount)
}
formatCurrency(1234.50, 'en-US', 'USD') // "$1,234.50"
formatCurrency(1234.50, 'de-DE', 'EUR') // "1.234,50 €"
formatCurrency(1234.50, 'ja-JP', 'JPY') // "¥1,235"
Language Switcher Component
// components/LanguageSwitcher.tsx
'use client'
import { usePathname, useRouter } from 'next/navigation'
const languages = [
{ code: 'en', name: 'English', flag: '🇺🇸' },
{ code: 'fr', name: 'Français', flag: '🇫🇷' },
{ code: 'es', name: 'Español', flag: '🇪🇸' },
{ code: 'de', name: 'Deutsch', flag: '🇩🇪' },
{ code: 'ja', name: '日本語', flag: '🇯🇵' },
]
export function LanguageSwitcher({ currentLocale }: { currentLocale: string }) {
const pathname = usePathname()
const router = useRouter()
const switchLocale = (newLocale: string) => {
const newPath = pathname.replace(`/${currentLocale}`, `/${newLocale}`)
router.push(newPath)
document.cookie = `NEXT_LOCALE=${newLocale};path=/;max-age=31536000`
}
return (
<select
value={currentLocale} => switchLocale(e.target.value)}
>
{languages.map(lang => (
<option key={lang.code} value={lang.code}>
{lang.flag} {lang.name}
</option>
))}
</select>
)
}
Best Practices
- Use subdirectory routing (
/en/,/fr/) for the best SEO results - Always include hreflang tags to help search engines serve the right language version
- Use the Intl API for dates, numbers, and currencies — don't hardcode formats
- Support RTL layouts with the
dirattribute for Arabic, Hebrew, etc. - Keep translation keys organized by page/section for maintainability
- Use professional translators or AI-assisted translation with human review
- Test thoroughly — text expansion in some languages can break layouts