Saltar al contenido principal
Principiante CódigoTutorial

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.

Fran Cobos 5 min de lectura 811 palabras

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íaPor qué
ViteBuild ultrarrápido (< 2s), HMR instantáneo, zero config
Tailwind CSSUtility-first, dark mode trivial, no escribo CSS custom
FlowbiteComponentes UI accesibles sobre Tailwind
Vanilla JSSin framework — un portfolio no necesita React
NetlifyDeploy 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:

  1. El texto en español va directo en el HTML
  2. Un atributo data-i18n marca los elementos traducibles
  3. Un diccionario JS mapea texto español → inglés
  4. 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:

  1. Lazy loading de imágenesloading="lazy" en toda imagen below the fold
  2. Font Awesome diferido — Cargado con defer para no bloquear el renderizado
  3. Preconnect a Google Fonts — <link rel="preconnect" href="https://fonts.googleapis.com">
  4. Imágenes WebP — 60-80% menos peso que JPEG/PNG
  5. 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'">

Si usas Google Analytics o AdSense en España, necesitas consentimiento previo. Mi solución:

  1. GA4 y AdSense NO se cargan por defecto
  2. Un banner aparece (sutil, esquina inferior)
  3. Si el usuario acepta → localStorage.setItem('cookie-consent', 'accepted') → se cargan los scripts
  4. 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)

ErrorSolución
Cargar GA4 antes del consentimientoBloquear scripts hasta aceptación
FOUC en dark modeScript inline síncrono en <head>
i18n keys como cookie_textUsar texto español como key
Service Worker cacheando todoExcluir HTML, solo cachear assets
404 genérica feaPá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.

Fran Cobos

Fran Cobos

Desarrollador Full Stack especializado en IA aplicada, automatización y desarrollo web. Escribo sobre herramientas, tutoriales y casos reales para programadores.

¿Necesitas desarrollo a medida?

Apps web, IA, módulos ERP — cuéntame tu proyecto.