Skip to content

i18n Mini Project — Build a Multilingual Web Application

DodaTech Updated 2026-06-28 11 min read

In this tutorial, you will learn about i18n mini project. We cover key concepts, practical examples, and best practices to help you master this topic.

Build a complete multilingual web application demonstrating Internationalization, locale detection, RTL support, and i18n best practices.

What You'll Learn

By the end of this project, you'll build a production-ready multilingual dashboard with locale detection, translation management, RTL layout, locale-aware formatting, and a locale switcher — all using i18n best practices you've learned throughout this series.

Why It Matters

This project combines every i18n technique you've learned into one practical application. You'll implement locale detection (browser, cookie, URL), translation loading, ICU plurals, gender handling, RTL layout, date/number formatting, and SEO-friendly routing. Completing this project proves you can build a fully internationalized web application from scratch.

Real-World Use

A SaaS dashboard used by teams in 6 countries supports English, Spanish, French, German, Arabic, and Japanese. New team members see the interface in their browser's language automatically. They can switch languages from a dropdown, and the choice persists. Arabic users see a mirrored layout. All dates, numbers, and currencies follow local conventions.

Application Architecture

graph LR
    A[Multilingual
Dashboard] --> B[Locale Detection
URL path, cookie, navigator] A --> C[Translation Engine
i18next + ICU] A --> D[RTL Support
CSS logical properties] A --> E[Locale Formatting
Intl.DateTimeFormat + NumberFormat] B --> F[/en/dashboard, /ar/dashboard
Cookie: locale=ar] C --> G[locales/en.json, locales/ar.json
ICU plurals, select] D --> H[dir=rtl, logical CSS
Flipped icons] E --> I[Date, currency, number
per locale] style A fill:#4a90d9,color:#fff

Project Structure

multilingual-dashboard/
├── index.html              # Entry point with locale redirect
├── locales/
│   ├── en.json             # English translations
│   ├── es.json             # Spanish translations
│   ├── fr.json             # French translations
│   ├── de.json             # German translations
│   ├── ar.json             # Arabic translations
│   └── ja.json             # Japanese translations
├── css/
│   ├── base.css            # Logical properties layout
│   ├── dashboard.css       # Dashboard styles
│   └── rtl.css             # RTL-specific adjustments
├── js/
│   ├── i18n.js             # i18next configuration
│   ├── locale-detector.js  # Locale detection logic
│   ├── app.js              # Main application logic
│   ├── dashboard.js        # Dashboard component
│   └── locale-switcher.js  # Language switcher component
└── pages/
    ├── dashboard.html      # Dashboard page
    ├── profile.html        # User profile page
    └── settings.html       # Settings page

Step 1: Locale Detection

// js/locale-detector.js — Multi-strategy locale detection
class LocaleDetector {
    constructor(supportedLocales, defaultLocale) {
        this.supportedLocales = supportedLocales;
        this.defaultLocale = defaultLocale;
    }

    detect() {
        return this.fromUrl()
            || this.fromCookie()
            || this.fromNavigator()
            || this.defaultLocale;
    }

    fromUrl() {
        const match = window.location.pathname.match(/^\/(\w{2}(?:-\w{2})?)\//);
        const locale = match ? match[1] : null;
        if (locale && this.supportedLocales.includes(locale)) {
            console.log(`Locale detected from URL: ${locale}`);
            return locale;
        }
        return null;
    }

    fromCookie() {
        const cookie = document.cookie
            .split('; ')
            .find(row => row.startsWith('locale='));
        if (cookie) {
            const value = cookie.split('=')[1];
            if (this.supportedLocales.includes(value)) {
                console.log(`Locale detected from cookie: ${value}`);
                return value;
            }
        }
        return null;
    }

    fromNavigator() {
        const languages = navigator.languages || [navigator.language];
        for (const lang of languages) {
            const code = lang.split('-')[0];
            if (this.supportedLocales.includes(code)) {
                console.log(`Locale detected from browser: ${code}`);
                return code;
            }
        }
        return null;
    }

    persist(locale) {
        document.cookie = `locale=${locale}; max-age=31536000; path=/`;
        try {
            localStorage.setItem('preferred_locale', locale);
        } catch (e) {}
    }
}

// Initialize
const supportedLocales = ['en', 'es', 'fr', 'de', 'ar', 'ja'];
const detector = new LocaleDetector(supportedLocales, 'en');
const detectedLocale = detector.detect();

Step 2: Translation Files

// locales/en.json — English translations
{
    "app": {
        "title": "Multilingual Dashboard",
        "tagline": "A complete i18n demo application"
    },
    "nav": {
        "dashboard": "Dashboard",
        "profile": "Profile",
        "settings": "Settings",
        "logout": "Sign Out"
    },
    "dashboard": {
        "welcome": "Welcome, {name}!",
        "stats_title": "Performance Overview",
        "users_total": "Total Users",
        "users_active": "Active Users",
        "revenue": "Revenue",
        "revenue_change": "vs last month",
        "items_processed": "{count, plural, =0 {No items processed} one {# item processed} other {# items processed}}",
        "last_updated": "Last updated: {date, date, long}",
        "view_details": "View Details"
    },
    "profile": {
        "title": "User Profile",
        "name": "Name",
        "email": "Email",
        "language": "Preferred Language",
        "joined": "Member since {date, date, long}",
        "notifications": "{count, plural, =0 {No notifications} one {# notification} other {# notifications}}"
    },
    "settings": {
        "title": "Settings",
        "theme": "Theme",
        "theme_light": "Light",
        "theme_dark": "Dark",
        "timezone": "Timezone",
        "currency": "Display Currency",
        "save": "Save Changes",
        "cancel": "Cancel"
    },
    "common": {
        "loading": "Loading...",
        "error": "An error occurred",
        "retry": "Try Again",
        "save": "Save",
        "cancel": "Cancel",
        "delete": "Delete",
        "confirm": "Are you sure?"
    }
}
// locales/ar.json — Arabic translations (RTL)
{
    "app": {
        "title": "لوحة المعلومات متعددة اللغات",
        "tagline": "تطبيق تجريبي كامل للتدويل"
    },
    "nav": {
        "dashboard": "لوحة المعلومات",
        "profile": "الملف الشخصي",
        "settings": "الإعدادات",
        "logout": "تسجيل الخروج"
    },
    "dashboard": {
        "welcome": "!مرحبًا، {name}",
        "stats_title": "نظرة عامة على الأداء",
        "users_total": "إجمالي المستخدمين",
        "users_active": "المستخدمون النشطون",
        "revenue": "الإيرادات",
        "revenue_change": "مقارنة بالشهر الماضي",
        "items_processed": "{count, plural, zero {لا توجد عناصر} one {عنصر واحد} two {عنصران} few {{count} عناصر} many {{count} عنصرًا} other {{count} عنصر}}",
        "last_updated": ":آخر تحديث {date, date, long}",
        "view_details": "عرض التفاصيل"
    },
    "profile": {
        "title": "الملف الشخصي",
        "name": "الاسم",
        "email": "البريد الإلكتروني",
        "language": "اللغة المفضلة",
        "joined": "عضو منذ {date, date, long}",
        "notifications": "{count, plural, zero {لا توجد إشعارات} one {إشعار واحد} two {إشعاران} few {{count} إشعارات} many {{count} إشعارًا} other {{count} إشعار}}"
    },
    "settings": {
        "title": "الإعدادات",
        "theme": "المظهر",
        "theme_light": "فاتح",
        "theme_dark": "داكن",
        "timezone": "المنطقة الزمنية",
        "currency": "عملة العرض",
        "save": "حفظ التغييرات",
        "cancel": "إلغاء"
    },
    "common": {
        "loading": "...جارٍ التحميل",
        "error": "حدث خطأ",
        "retry": "حاول مرة أخرى",
        "save": "حفظ",
        "cancel": "إلغاء",
        "delete": "حذف",
        "confirm": "؟هل أنت متأكد"
    }
}

Step 3: i18next Configuration

// js/i18n.js — i18next configuration
import i18next from 'i18next';
import ICU from 'i18next-icu';
import { detector } from './locale-detector.js';

// Load translation files
async function loadTranslations(locale) {
    try {
        const response = await fetch(`/locales/${locale}.json`);
        if (!response.ok) throw new Error(`Failed to load ${locale}.json`);
        return await response.json();
    } catch (error) {
        console.error(error);
        // Fallback to English
        const fallback = await fetch('/locales/en.json');
        return await fallback.json();
    }
}

// Initialize i18next
async function initI18n(locale) {
    const translations = await loadTranslations(locale);
    const fallbackTranslations = await loadTranslations('en');

    await i18next.use(ICU).init({
        lng: locale,
        fallbackLng: 'en',
        resources: {
            [locale]: { translation: translations },
            en: { translation: fallbackTranslations }
        },
        interpolation: {
            format: (value, format, lng) => {
                if (value instanceof Date) {
                    if (format === 'date') {
                        return new Intl.DateTimeFormat(lng, { dateStyle: 'long' }).format(value);
                    }
                    if (format === 'short') {
                        return new Intl.DateTimeFormat(lng, { dateStyle: 'short' }).format(value);
                    }
                }
                return value;
            }
        }
    });

    return i18next;
}

export { initI18n };

Step 4: Dashboard Component

<!-- pages/dashboard.html -->
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title data-i18n="app.title">Multilingual Dashboard</title>
    <link rel="stylesheet" href="/css/base.css">
    <link rel="stylesheet" href="/css/dashboard.css">
</head>
<body>
    <header class="header">
        <h1 data-i18n="app.title">Multilingual Dashboard</h1>
        <nav class="nav">
            <a href="/{locale}/dashboard" data-i18n="nav.dashboard">Dashboard</a>
            <a href="/{locale}/profile" data-i18n="nav.profile">Profile</a>
            <a href="/{locale}/settings" data-i18n="nav.settings">Settings</a>
        </nav>
        <div id="locale-switcher-container"></div>
    </header>

    <main class="dashboard">
        <section class="welcome-section">
            <h2 data-i18n="dashboard.welcome" data-i18n-options='{"name": "Admin"}'>
                Welcome, Admin!
            </h2>
        </section>

        <section class="stats-grid">
            <div class="stat-card">
                <h3 data-i18n="dashboard.users_total">Total Users</h3>
                <p class="stat-value" id="total-users">1,234</p>
            </div>
            <div class="stat-card">
                <h3 data-i18n="dashboard.users_active">Active Users</h3>
                <p class="stat-value" id="active-users">856</p>
            </div>
            <div class="stat-card">
                <h3 data-i18n="dashboard.revenue">Revenue</h3>
                <p class="stat-value" id="revenue">$45,678</p>
                <small data-i18n="dashboard.revenue_change">vs last month</small>
            </div>
        </section>

        <section class="activity-section">
            <h2 data-i18n="dashboard.stats_title">Performance Overview</h2>
            <p id="items-processed" data-i18n="dashboard.items_processed" data-i18n-options='{"count": 150}'>
                150 items processed
            </p>
            <p id="last-updated" data-i18n="dashboard.last_updated" data-i18n-options='{"date": "2026-06-28T12:00:00Z"}'>
                Last updated: June 28, 2026
            </p>
        </section>
    </main>

    <script type="module">
        import { detector, supportedLocales } from '/js/locale-detector.js';
        import { initI18n } from '/js/i18n.js';
        import { LocaleSwitcher } from '/js/locale-switcher.js';
        import { Dashboard } from '/js/dashboard.js';

        const locale = detector.detect();

        // Initialize i18n
        const i18n = await initI18n(locale);

        // Set HTML attributes
        document.documentElement.lang = locale;
        document.documentElement.dir = i18n.dir(locale);

        // Initialize components
        const dashboard = new Dashboard(i18n);
        dashboard.render();

        const switcher = new LocaleSwitcher(i18n, supportedLocales, detector);
        switcher.render(document.getElementById('locale-switcher-container'));
    </script>
</body>
</html>

Step 5: Dashboard JavaScript

// js/dashboard.js — Dashboard component
import { formatCurrency, formatNumber, formatDate } from './formatters.js';

export class Dashboard {
    constructor(i18n) {
        this.i18n = i18n;
        this.elements = new Map();
    }

    render() {
        this.updateContent();
        this.updateFormats();
        this.updateStats();
    }

    updateContent() {
        // Update all elements with data-i18n attributes
        document.querySelectorAll('[data-i18n]').forEach(el => {
            const key = el.dataset.i18n;
            const options = el.dataset.i18nOptions
                ? JSON.parse(el.dataset.i18nOptions)
                : {};

            // Process options — format dates
            if (options.date) {
                options.date = new Date(options.date);
            }

            el.textContent = this.i18n.t(key, options);
        });

        // Update navigation links
        this.updateNavLinks();
    }

    updateNavLinks() {
        const currentLocale = this.i18n.language;
        document.querySelectorAll('nav a').forEach(link => {
            const href = link.getAttribute('href');
            if (href) {
                link.setAttribute('href', href.replace('{locale}', currentLocale));
            }
        });
    }

    updateFormats() {
        const locale = this.i18n.language;

        // Format numbers with Intl
        const totalUsers = document.getElementById('total-users');
        if (totalUsers) {
            totalUsers.textContent = formatNumber(1234, locale);
        }

        const activeUsers = document.getElementById('active-users');
        if (activeUsers) {
            activeUsers.textContent = formatNumber(856, locale);
        }

        // Format currency
        const revenue = document.getElementById('revenue');
        if (revenue) {
            const currency = locale === 'ar' ? 'SAR' :
                locale === 'de' || locale === 'fr' ? 'EUR' :
                locale === 'ja' ? 'JPY' : 'USD';
            revenue.textContent = formatCurrency(45678, currency, locale);
        }
    }

    updateStats() {
        // Simulate real-time updates
        setInterval(() => {
            const count = Math.floor(Math.random() * 200);
            const itemsEl = document.getElementById('items-processed');
            if (itemsEl) {
                const key = itemsEl.dataset.i18n;
                itemsEl.textContent = this.i18n.t(key, { count });
            }
        }, 5000);
    }
}

Step 6: Locale Switcher

// js/locale-switcher.js — Language switcher component
export class LocaleSwitcher {
    constructor(i18n, locales, detector) {
        this.i18n = i18n;
        this.locales = locales;
        this.detector = detector;
    }

    render(container) {
        const select = document.createElement('select');
        select.setAttribute('aria-label', 'Select language');
        select.className = 'locale-switcher';

        const localeNames = {
            'en': 'English',
            'es': 'Espanol',
            'fr': 'Francais',
            'de': 'Deutsch',
            'ar': 'العربية',
            'ja': 'Japanese'
        };

        this.locales.forEach(code => {
            const option = document.createElement('option');
            option.value = code;
            option.textContent = localeNames[code] || code;
            if (code === this.i18n.language) {
                option.selected = true;
            }
            select.appendChild(option);
        });

        select.addEventListener('change', (e) => {
            const newLocale = e.target.value;
            this.switchLocale(newLocale);
        });

        container.appendChild(select);
    }

    switchLocale(newLocale) {
        this.detector.persist(newLocale);

        // Update document direction
        const dir = this.i18n.dir(newLocale);
        document.documentElement.lang = newLocale;
        document.documentElement.dir = dir;

        // Reload the page with the new locale in the URL
        const currentPath = window.location.pathname;
        const newPath = currentPath.replace(/^\/(\w{2})/, `/${newLocale}`);
        window.location.href = newPath;
    }
}

Testing Checklist

  • Locale detection works from URL path, cookie, and browser settings
  • All 6 locales load and display correct translations
  • ICU plurals work correctly for each locale (test zero, one, two, few, many)
  • RTL layout (Arabic) mirrors the LTR layout correctly
  • Navigation links include the correct locale prefix
  • Dates format according to locale (en: June 28, 2026; ar: 28 يونيو 2026)
  • Currencies format with correct symbol and placement
  • Numbers use locale-appropriate separators
  • Locale switcher persists preference and redirects correctly
  • All 3 pages (dashboard, profile, settings) render correctly in all locales

Common Mistakes

  1. Not testing all locales during development. Test each of the 6 locales at least once before deployment. A translation syntax error in one locale can break the entire i18n initialization.
  2. Forgetting to update the dir attribute for RTL. Arabic layout without dir="rtl" is unreadable. Always update document.documentElement.dir when switching locales.
  3. Hardcoding currency codes per locale. Use a mapping from locale to currency code. Don't assume all European locales use EUR — the UK uses GBP even in a European context.
  4. Not handling translation loading errors. If a locale file fails to load, fall back to English gracefully. Show an error state rather than a blank page.
  5. Inconsistent URL locale handling. If the URL path is /en/dashboard, all internal links should also use /en/. Don't mix locale prefixes within the same session.

Practice Questions

  1. How does the application detect the user's locale on first visit?
  2. Why is it important to update the dir attribute when switching to Arabic?
  3. How does the ICU pluralization handle Arabic's 6 plural forms?
  4. How do the formatters (date, currency, number) adapt per locale?
  5. How would you add a 7th locale (Portuguese) to this application?

Challenge: Extend the dashboard with these features: a language-specific greeting based on time of day (morning/afternoon/evening), a data table with locale-aware sorting (Arabic sorts differently), export functionality that generates locale-aware CSV files (with correct separators), and a settings page that lets users override their currency and timezone independently of locale.

FAQ

How long does it take to add a new locale?

With this architecture, about 30 minutes: create the translation JSON file (copy English, translate 30-50 keys), add the locale code to the supported list, and test. No code changes needed.

Can I use this project as a starting point for production?

Yes, but add: server-side rendering for initial locale detection (no flash), CDN caching per locale, automated translation linting in CI, and a translation management system (Crowdin, Lokalise) for translators.

How do I handle user-generated content in multiple languages?

Store the content locale alongside the content. Display it in the original language with a translation badge. Use the tag to isolate mixed-direction user content.

Should I use client-side or server-side rendering for i18n?

SSR is better for SEO and initial load (no flash of wrong language). Use client-side i18n for dynamic content updates after the initial render. This project demonstrates client-side; add SSR with Next.js or Nuxt for production.

How do I manage translations with a team?

Use a translation management platform (Crowdin, Lokalise, POEditor). Integrate it with your CI pipeline. Never let translators edit JSON files directly — use a platform with review workflows and version control integration.

Mini Project Complete

You've built a complete multilingual dashboard. Deploy it and test with:

  • 6 different browser language settings
  • Manual locale switching
  • RTL verification with Arabic
  • URL-based locale access (/ar/dashboard)
  • Cookie persistence across sessions
  • Lighthouse performance audit

What's Next

You've completed the i18n tutorial series. You now have a comprehensive understanding of internationalization and localization. Review the Lazy Loading series or explore other frontend architecture topics.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro