← Back to Blog
🏠 Mi Home Lab DevOps
November 10, 2025

Web Personal con i18n: Selector de Idiomas en Vanilla JS

📋 Contexto

Mi web personal (mytechzone.dev) estaba solo en español. Quería agregarle inglés para llegar a audiencia internacional, pero sin complicarme con frameworks (React, Vue) o librerías pesadas (i18next). Solución: implementar i18n con vanilla JavaScript + localStorage.

🎯 Requisitos

  • ✅ Toggle EN/ES con botones
  • ✅ Persistencia con localStorage
  • ✅ Inglés como idioma por defecto
  • ✅ Sin recargar la página
  • ✅ Sin frameworks ni dependencias
  • ✅ Lightweight (< 5 KB JavaScript)

🛠️ Implementación

Paso 1: HTML Structure

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Kevin Romero | DevOps Engineer</title>
</head>
<body>
    <!-- Language Toggle -->
    <div class="language-toggle">
        <button id="lang-en" class="active">EN</button>
        <button id="lang-es">ES</button>
    </div>

    <!-- Content with data attributes -->
    <header>
        <h1>Kevin Romero</h1>
        <p id="header-subtitle" data-en="DevOps Engineer | SRE | Cloud Infrastructure" 
                                data-es="Ingeniero DevOps | SRE | Infraestructura Cloud">
            DevOps Engineer | SRE | Cloud Infrastructure
        </p>
    </header>

    <section>
        <h2 id="about-title">About</h2>
        <p id="about-text">...</p>
    </section>

    <script src="lang.js"></script>
</body>
</html>
💡 Estrategia: Usar data-* attributes cuando el contenido es corto (headers, labels). Para textos largos, usar un objeto JavaScript translations.

Paso 2: CSS (Language Toggle)

.language-toggle {
    position: absolute;
    top: 2rem;
    right: 2rem;
    display: flex;
    gap: 0.5rem;
    z-index: 100;
}

.language-toggle button {
    background: #1a1a1a;
    color: #888;
    border: 1px solid #333;
    padding: 0.5rem 1rem;
    cursor: pointer;
    border-radius: 4px;
    font-weight: 600;
    transition: all 0.2s;
}

.language-toggle button:hover {
    border-color: #60a5fa;
    color: #60a5fa;
}

.language-toggle button.active {
    background: #60a5fa;
    color: #0a0a0a;
    border-color: #60a5fa;
}

Paso 3: JavaScript (lang.js)

// translations.js
const translations = {
    // IDs mapped to translations
    "header-subtitle": {
        "en": "DevOps Engineer | SRE | Cloud Infrastructure",
        "es": "Ingeniero DevOps | SRE | Infraestructura Cloud"
    },
    "about-title": {
        "en": "About",
        "es": "Acerca de"
    },
    "about-text": {
        "en": "I'm a DevOps Engineer passionate about automation, cloud infrastructure, and continuous improvement. I build reliable systems that scale.",
        "es": "Soy Ingeniero DevOps apasionado por la automatización, infraestructura cloud y mejora continua. Construyo sistemas confiables que escalan."
    },
    "projects-title": {
        "en": "Projects",
        "es": "Proyectos"
    },
    "project-homelab": {
        "en": "Home Lab Server built from old notebook running Debian, Docker, n8n, and monitoring system",
        "es": "Servidor Home Lab construido con notebook antigua ejecutando Debian, Docker, n8n y sistema de monitoreo"
    },
    "contact-title": {
        "en": "Get in Touch",
        "es": "Contacto"
    },
    "footer-text": {
        "en": "Built with passion. Hosted on my home lab.",
        "es": "Construido con pasión. Alojado en mi laboratorio casero."
    }
};

// Set language function
function setLanguage(lang) {
    // Update html lang attribute
    document.documentElement.lang = lang;
    
    // Save to localStorage
    localStorage.setItem('lang', lang);
    
    // Update all elements with translations
    Object.keys(translations).forEach(id => {
        const element = document.getElementById(id);
        if (element && translations[id][lang]) {
            element.textContent = translations[id][lang];
        }
    });
    
    // Update button states
    document.querySelectorAll('.language-toggle button').forEach(btn => {
        btn.classList.remove('active');
    });
    document.getElementById(`lang-${lang}`).classList.add('active');
}

// Event listeners
document.getElementById('lang-en').addEventListener('click', () => setLanguage('en'));
document.getElementById('lang-es').addEventListener('click', () => setLanguage('es'));

// Initialize: Load saved language or default to English
const savedLang = localStorage.getItem('lang') || 'en';
setLanguage(savedLang);

Versión Optimizada (Con data-* attributes)

// lang-optimized.js
const translations = {
    "about-text": {
        "en": "I'm a DevOps Engineer...",
        "es": "Soy Ingeniero DevOps..."
    },
    // Only for long texts
};

function setLanguage(lang) {
    document.documentElement.lang = lang;
    localStorage.setItem('lang', lang);
    
    // Method 1: Update from translations object
    Object.keys(translations).forEach(id => {
        const element = document.getElementById(id);
        if (element && translations[id][lang]) {
            element.textContent = translations[id][lang];
        }
    });
    
    // Method 2: Update from data-* attributes
    document.querySelectorAll('[data-en]').forEach(element => {
        const key = element.id;
        if (element.dataset[lang]) {
            element.textContent = element.dataset[lang];
        }
    });
    
    // Update active button
    document.querySelectorAll('.language-toggle button').forEach(btn => {
        btn.classList.remove('active');
    });
    document.getElementById(`lang-${lang}`).classList.add('active');
}

// Event listeners
document.getElementById('lang-en').addEventListener('click', () => setLanguage('en'));
document.getElementById('lang-es').addEventListener('click', () => setLanguage('es'));

// Initialize
const savedLang = localStorage.getItem('lang') || 'en';
setLanguage(savedLang);

💡 Mejoras Adicionales

1. Detectar Idioma del Navegador

// Detect browser language
function getBrowserLanguage() {
    const browserLang = navigator.language || navigator.userLanguage;
    
    // Extract language code (e.g., "en-US" → "en")
    const langCode = browserLang.split('-')[0];
    
    // Check if we support this language
    return ['en', 'es'].includes(langCode) ? langCode : 'en';
}

// Initialize with browser language if no saved preference
const savedLang = localStorage.getItem('lang') || getBrowserLanguage();
setLanguage(savedLang);

2. Smooth Transitions

// Add fade effect when changing language
function setLanguage(lang) {
    // ... (previous code)
    
    // Add fade animation
    document.querySelectorAll('[data-en]').forEach(element => {
        element.style.opacity = '0';
        element.style.transition = 'opacity 0.2s';
        
        setTimeout(() => {
            element.textContent = element.dataset[lang];
            element.style.opacity = '1';
        }, 200);
    });
}

// Or use CSS
.translatable {
    transition: opacity 0.2s ease-in-out;
}

.translatable.changing {
    opacity: 0;
}

3. Flag Icons (Opcional)

<div class="language-toggle">
    <button id="lang-en">
        🇬🇧 EN
    </button>
    <button id="lang-es">
        🇪🇸 ES
    </button>
</div>

<!-- Or use SVG flags -->
<button id="lang-en">
    <img src="/flags/en.svg" alt="English"> EN
</button>

📊 Resultado Final

✅ Logros:
• JavaScript: 2.8 KB (minified)
• Sin dependencias externas
• Cambio de idioma instantáneo
• Preferencia persistente (localStorage)
• Funciona offline
• Compatible con todos los navegadores
• SEO-friendly (html[lang] attribute)

🐛 Problemas Comunes y Soluciones

Problema: Contenido "flashea" en idioma incorrecto

Causa: HTML carga con texto default antes de que JavaScript cargue.

Solución: Inline el script de idioma en el <head>

<head>
    <script>
        // Inline critical JS
        (function() {
            const lang = localStorage.getItem('lang') || 'en';
            document.documentElement.lang = lang;
            document.documentElement.classList.add('lang-' + lang);
        })();
    </script>
</head>

Problema: Nuevos elementos dinámicos no se traducen

Solución: Crear función para traducir elementos individuales

function translateElement(element, key) {
    const currentLang = localStorage.getItem('lang') || 'en';
    
    if (translations[key] && translations[key][currentLang]) {
        element.textContent = translations[key][currentLang];
    }
}

// Usage
const newElement = document.createElement('p');
newElement.id = 'dynamic-text';
translateElement(newElement, 'dynamic-text');

Problema: SEO (Google ve solo un idioma)

Solución: Agregar <link rel="alternate">

<head>
    <link rel="alternate" hreflang="en" href="https://mytechzone.dev?lang=en">
    <link rel="alternate" hreflang="es" href="https://mytechzone.dev?lang=es">
    <link rel="alternate" hreflang="x-default" href="https://mytechzone.dev">
</head>

💡 Alternativas Consideradas

  • i18next: 🔴 50 KB, overkill para sitio simple
  • React + react-i18next: 🔴 Framework completo, demasiado pesado
  • Páginas separadas (index-en.html, index-es.html): 🔴 Duplicación, difícil mantenimiento
  • Server-side i18n: 🟡 Mejor para SEO, pero requiere backend
  • Vanilla JS: ✅ Lightweight, suficiente para mi caso

📈 Métricas

Before (Spanish only):
- Visitors: ~60% LatAm, 40% lost (non-Spanish speakers)
- Bounce rate: 45%

After (EN/ES):
- Visitors: +30% (international audience)
- Bounce rate: 32%
- 65% use English, 35% Spanish
- localStorage adoption: 89% (users come back with saved preference)

🚀 Extensiones Futuras

  • 🌐 Agregar más idiomas (pt, fr)
  • 📝 Traducir blog posts (diferentes archivos HTML por idioma)
  • 🔊 Pronunciación de nombre con audio
  • 🤖 Auto-traducción con API (Google Translate API)

📚 Código Completo (GitHub)

Puedes ver la implementación completa en mi GitHub:

💭 Conclusión

No siempre necesitas frameworks complejos. Para un sitio personal estático con i18n, vanilla JavaScript es perfecto: rápido, simple, sin dependencias. La implementación completa me tomó 2 horas y el resultado es un sitio bilingüe profesional.

Si tu caso de uso es más complejo (cientos de strings, múltiples idiomas, CMS), entonces sí considera i18next o frameworks. Pero para proyectos pequeños, KISS (Keep It Simple, Stupid) funciona mejor.