Cómo Hice mi Portfolio con Vite, Tailwind y Netlify
Cómo construí mi portfolio con Vite, Tailwind CSS y Netlify desde cero. Arquitectura, errores reales, dark mode, i18n y cómo saqué 98+ en Lighthouse.
Tabla de contenidos
Este es el “making of” de mi propio portfolio. Llevo iterándolo más de un año y aquí comparto las decisiones técnicas, trucos y errores que cometí para que tú no los repitas.
<video src=“/videos/porfolio.mp4” controls muted playsinline class=“w-full rounded-xl my-6 shadow-lg” style=“max-height: 480px; object-fit: cover;”
Tu navegador no soporta vídeo HTML5.
El stack (y por qué lo elegí)
| Tecnología | Por qué |
|---|---|
| Vite | Build ultrarrápido (< 2s), HMR instantáneo, zero config |
| Tailwind CSS | Utility-first, dark mode trivial, no escribo CSS custom |
| Flowbite | Componentes UI accesibles sobre Tailwind |
| Vanilla JS | Sin framework — un portfolio no necesita React |
| Netlify | Deploy automático con git push, SSL gratis, CDN global |
¿Por qué no React/Next.js?
Un portfolio es contenido estático. No necesita estado global, routing SPA, ni hidratación. Con Vanilla JS + Vite tengo:
- Lighthouse Performance 98+ sin esfuerzo
- 0 KB de framework en el bundle
- Build en 1.8 segundos
Si necesitara un blog dinámico, usaría Astro (spoiler: es lo que usa este blog).
Dark mode con localStorage (sin FOUC)
El truco más importante: prevenir el “flash” de tema incorrecto al cargar.
<!-- En el <head>, ANTES de cualquier CSS -->
<script>
if (localStorage.getItem('color-theme') === 'dark' ||
(!('color-theme' in localStorage) &&
matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
</script>
Este script es inline y síncrono — se ejecuta antes de que el navegador pinte nada. Así el usuario nunca ve un flash blanco si tiene modo oscuro configurado.
i18n sin librerías (ES/EN)
No necesitas i18next ni react-intl para un sitio bilingüe sencillo. Mi enfoque:
- El texto en español va directo en el HTML
- Un atributo
data-i18nmarca los elementos traducibles - Un diccionario JS mapea texto español → inglés
- Una función recorre los elementos y reemplaza el texto
const en = {
'Sobre mí': 'About me',
'Proyectos': 'Projects',
'Contacto': 'Contact',
// ...
};
function applyTranslations(lang) {
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
el.textContent = lang === 'en' ? (en[key] || key) : key;
});
}
Ventaja: Cero dependencias, funciona con cualquier framework (o sin ninguno), el HTML es legible en español.
Estructura de archivos
├── index.html ← Todo el portfolio en un solo HTML
├── assets/
│ ├── css/style.css ← Tailwind + custom styles
│ └── js/
│ ├── main.js ← Entry point (imports)
│ ├── i18n.js ← Sistema de traducción
│ ├── themeToggle.js
│ ├── techSkills.js ← Skills renderizadas dinámicamente
│ └── ...
├── public/
│ ├── images/ ← Imágenes estáticas
│ ├── robots.txt ← SEO
│ └── sw.js ← Service Worker (PWA)
└── blog/ ← Astro (este blog)
Performance: cómo conseguí 98+ en Lighthouse
Las 5 cosas que más impacto tuvieron:
- Lazy loading de imágenes —
loading="lazy"en toda imagen below the fold - Font Awesome diferido — Cargado con
deferpara no bloquear el renderizado - Preconnect a Google Fonts —
<link rel="preconnect" href="https://fonts.googleapis.com"> - Imágenes WebP — 60-80% menos peso que JPEG/PNG
- Service Worker — Cachea assets estáticos para visitas repetidas
<!-- Mal: bloquea el render -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/all.min.css">
<!-- Bien: no bloquea -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/all.min.css" media="print" onload="this.media='all'">
Cookie consent (RGPD)
Si usas Google Analytics o AdSense en España, necesitas consentimiento previo. Mi solución:
- GA4 y AdSense NO se cargan por defecto
- Un banner aparece (sutil, esquina inferior)
- Si el usuario acepta →
localStorage.setItem('cookie-consent', 'accepted')→ se cargan los scripts - Si rechaza → no se carga nada analítico
function loadAnalytics() {
if (localStorage.getItem('cookie-consent') !== 'accepted') return;
// Inyectar GA4 dinámicamente
const s = document.createElement('script');
s.src = 'https://www.googletagmanager.com/gtag/js?id=G-XXXXX';
s.async = true;
document.head.appendChild(s);
}
Errores Comunes al Crear un Portfolio con Vite (y Soluciones)
| Error | Solución |
|---|---|
| Cargar GA4 antes del consentimiento | Bloquear scripts hasta aceptación |
| FOUC en dark mode | Script inline síncrono en <head> |
i18n keys como cookie_text | Usar texto español como key |
| Service Worker cacheando todo | Excluir HTML, solo cachear assets |
| 404 genérica fea | Página 404 custom con Netlify |
Deploy en Netlify
# netlify.toml
[build]
command = "npm run build"
publish = "dist"
[[headers]]
for = "/*.html"
[headers.values]
Cache-Control = "public, max-age=0, must-revalidate"
[[headers]]
for = "/assets/*"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"
Truco clave: HTML con max-age=0 (siempre revalida) y assets con hash en el nombre con max-age=1 año (inmutables). Así el usuario siempre ve la última versión.
Si estás empezando en el mundo del desarrollo, no te pierdas mi guía para estudiantes de DAW, DAM, SMR y ASIR donde explico qué aprender y cómo destacar.
¿Te ha servido? Puedes ver el resultado final en mi portfolio y si quieres algo similar para ti, cuéntame tu proyecto.