Después de 741 commits y meses de desarrollo (intercalados con trabajo real), finalmente tengo mi portfolio en producción. No quería hacer “otro sitio de portfolio genérico”, así que me propuse construir algo que sirviera como carta de presentación técnica, hub de contenido, y laboratorio de ideas.
En este post cuento todo: las decisiones técnicas, los problemas que encontré, cómo los resolví, y qué aprendí en el proceso. Es largo, pero si estás pensando en armar tu propio portfolio o querés ver cómo pienso un proyecto de principio a fin, acá está todo.
El stack técnico
Por qué Astro
La decisión más importante fue elegir Astro como framework principal. Un portfolio no necesita server-side rendering dinámico — quiero páginas pre-renderizadas, rápidas, cacheables.
Las razones principales:
- SSG nativo — Páginas estáticas por defecto, sin JavaScript innecesario
- Islands Architecture — Componentes interactivos solo donde los necesito
- Content Collections — Sistema de contenido type-safe integrado, perfecto para blog y proyectos
- Performance por defecto — Astro optimiza agresivamente sin que tenga que pelear contra el framework
Consideré Next.js y Remix, pero ambos están optimizados para aplicaciones dinámicas. Para un sitio mayormente estático con algunas islas interactivas, Astro era la elección obvia.
React para las islas interactivas
Uso React 19 para los componentes que necesitan interactividad: Command Palette, carrusel de testimonios, filtros de proyectos, radar chart de skills, y timeline interactivo.
El truco está en usar las directivas de Astro para controlar cuándo se hidrata cada componente:
<!-- Se hidrata cuando entra al viewport --><TestimonialsCarousel client:visible />
<!-- Se hidrata inmediatamente - para componentes críticos --><CommandPalette client:load />
<!-- Se hidrata cuando el browser está idle --><RadarChart client:idle />TypeScript estricto
TypeScript 5.7 con configuración estricta. Nada de any. Cuando no sé el
tipo, uso unknown con type guards.
// ❌ Prohibidofunction processData(data: any) { ... }
// ✅ Correctofunction processData(data: unknown) { if (isValidData(data)) { // Ahora TypeScript sabe qué es }}El beneficio: puedo refactorizar con confianza. Si cambio un tipo, TypeScript me muestra todos los lugares que rompo.
El resto del stack
- Tailwind CSS — Utility-first, rápido de iterar
- CSS + Web Animations API — Para animaciones (migré desde GSAP, más sobre esto abajo)
- Fuse.js — Búsqueda fuzzy client-side
- Biome — Linting y formatting (reemplazo de ESLint + Prettier)
- Vitest + Playwright — Testing unitario y E2E
Arquitectura del proyecto
Estructura de carpetas
qazuor.com/├── src/│ ├── components/ # 120+ componentes organizados│ │ ├── sections/ # Secciones de página│ │ ├── ui/ # Componentes reutilizables│ │ ├── interactive/ # Componentes React│ │ └── seo/ # JSON-LD y meta tags│ ├── content/ # 108+ archivos de contenido│ │ ├── blog/│ │ ├── projects/│ │ ├── snippets/│ │ └── testimonials/│ ├── integrations/ # Integraciones custom de Astro│ ├── scripts/ # Scripts de animación y comportamiento│ └── styles/ # CSS global y generado└── public/ # Assets estáticosContent Collections
Astro Content Collections me da tipado estático para todo el contenido. Definí 7 colecciones (blog, projects, snippets, css-tricks, tools, useful-links, testimonials).
// src/content/config.ts (simplificado)const blogCollection = defineCollection({ type: 'content', schema: ({ image }) => z.object({ title: z.string(), excerpt: z.string(), publishDate: z.date(), tags: z.array(z.string()), image: image(), series: z .object({ id: z.string(), name: z.string(), part: z.number(), }) .optional(), }),});El beneficio: si me olvido de un campo requerido o pongo un tipo incorrecto, Astro me avisa en build time. No hay posts rotos en producción.
Integraciones custom
Creé 5 integraciones custom de Astro para automatizar tareas en build time:
- Search Index Generator — Genera índice de búsqueda
- Social Blog Data — Exporta metadata para redes sociales
- Testimonial Avatars Downloader — Descarga avatares externos a local
- Color Interpolation Generator — Genera gradientes entre secciones
- Giscus Theme Generator — Genera temas custom para comentarios
Features principales
Internacionalización (i18n)
El sitio soporta inglés y español. Cada página tiene su versión en ambos
idiomas con rutas prefijadas (/en/blog, /es/blog).
Un problema que encontré: inicialmente intenté lazy-load de traducciones, pero en SSG esto no aporta beneficios. Astro pre-bundlea todo en build time, así que el lazy loading solo agregaba complejidad sin ganancia. Aprendí esto después de perder horas en una “optimización” que no optimizaba nada.
Command Palette
Ctrl+K (o Cmd+K en Mac) abre un buscador global con búsqueda fuzzy usando
Fuse.js. Resultados agrupados por tipo, navegación con teclado, y atajos rápidos
para páginas principales.
Timeline interactivo
24 eventos de mi carrera desde 1980 hasta 2025. El componente tiene 3 capas:
- TimelineWrapper (Astro) — Detecta el tema y mapea colores
- TimelineContent (React) — Layout scrollable con controles
- useTimelineAnimation (Hook) — Estado, auto-play, gestos touch
Optimización importante: los iconos del timeline pasaron de 393KB en JS chunks a 2.6KB usando SVG sprites. Una reducción del 99.3%.
Blog con navegación inteligente
- Table of Contents — Sidebar fijo con scroll spy usando IntersectionObserver
- Posts relacionados — Algoritmo de relevancia basado en tags y categoría
- Series — Posts pueden pertenecer a una serie con navegación prev/next
Divisores animados
Entre cada sección hay divisores SVG animados con gradientes de color interpolados. Una integración genera CSS en build time con 5 pasos intermedios para cada transición de color.
Problemas reales y cómo los resolví
Esta es la parte más valiosa del post. Todos estos problemas vienen del historial de git real del proyecto.
La migración de GSAP a CSS nativo
El problema: GSAP + Lenis sumaban ~34KB de JavaScript gzipped. Para un portfolio, era demasiado peso para animaciones que podían hacerse de otra forma.
La solución: En el commit 58ffe5d hice una migración completa:
| Antes | Después |
|---|---|
| GSAP timeline | Web Animations API |
| GSAP ScrollTrigger | IntersectionObserver + CSS |
| Lenis smooth scroll | scroll-behavior: smooth |
| Custom scroll hooks | scrollIntoView() nativo |
Eliminé 1530 líneas de código y agregué 445. El resultado: misma funcionalidad, 34KB menos de JavaScript.
// Antes: hook custom con GSAPconst { ref } = useScrollAnimation({ animation: 'fadeInUp', duration: 0.8,});
// Después: CSS + IntersectionObserver// src/scripts/scroll-reveal.tsconst observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { entry.target.classList.add('animate-visible'); } });});DOM mutations matando la performance mobile
El problema: El score de performance mobile era bajo (~60) por “Avoid large layout shifts” y “Reduce DOM size”.
La causa: Varios componentes usaban JavaScript para animaciones que mutaban el DOM constantemente:
- Typewriter effect
- Rotating roles en el hero
- Trust badges marquee
- Testimonials carousel
La solución: Migré todo a animaciones CSS-only:
/* Antes: JavaScript mutando el DOM cada frame *//* Después: CSS animation */.typewriter-text { overflow: hidden; border-right: 2px solid; white-space: nowrap; animation: typing 3.5s steps(40, end), blink 0.75s step-end infinite;}
@keyframes typing { from { width: 0; } to { width: 100%; }}Commits relevantes: a079b41, 1f4ba25, 33af274, 3c1b720.
Hydration mismatches con React
El problema: Errores de hydration en consola: el servidor renderizaba una cosa y el cliente esperaba otra.
La causa: Componentes que dependían de window o localStorage durante el
render inicial.
La solución: Guards de SSR y valores por defecto seguros:
// ❌ Causa hydration mismatchconst [theme, setTheme] = useState(localStorage.getItem('theme') || 'dark');
// ✅ Correctoconst [theme, setTheme] = useState('dark');useEffect(() => { setTheme(localStorage.getItem('theme') || 'dark');}, []);Commit: 096d590.
LinkedIn 403 en avatares de testimonios
El problema: Los avatares de testimonios venían de LinkedIn, pero en cada build LinkedIn devolvía 403 Forbidden.
La causa: LinkedIn bloquea requests automatizados a sus CDN de imágenes.
La solución: Descargué los avatares una vez y los comiteé al repo. Creé una integración que usa fallback a local cuando el download falla:
async function downloadAvatar(url: string, filename: string) { try { const response = await fetch(url); if (!response.ok) throw new Error('Download failed'); // ... save to local } catch { console.log(`Using git fallback for ${filename}`); // Avatar ya existe en el repo }}Commits: 7a9fd59, 1612f8e.
Touch targets demasiado pequeños
El problema: Lighthouse reportaba “Touch targets are not sized appropriately” para los dots del carousel y timeline.
La solución: Expandí el área de toque a 44px (el mínimo recomendado por WCAG):
.carousel-dot { /* Visual: 8px */ width: 8px; height: 8px;
/* Touch target: 44px con padding */ padding: 18px; margin: -18px;}Commits: b2e5617, a02083a.
View Transitions y scripts que no se re-ejecutaban
El problema: Después de navegar con View Transitions, los scripts no se volvían a ejecutar. Esto rompía callouts del blog, inicialización de componentes, y cualquier script que dependiera del contenido nuevo.
La solución: Un sistema de lifecycle centralizado que dispara eventos custom después del swap:
// El componente de transición dispara un evento customdocument.addEventListener('astro:before-swap', (event) => { event.swap = async () => { await animateOverlayIn(); event.defaultSwap(); document.dispatchEvent(new CustomEvent('qazuor:content-ready')); await animateOverlayOut(); };});
// Los componentes escuchan este eventodocument.addEventListener('qazuor:content-ready', () => { enhanceCallouts();});Este fue un rabbit hole de días. La documentación de Astro no cubre bien este caso.
Optimización de performance
Métricas actuales
Desktop:
- Performance Score: 100
- LCP: 0.7s
- CLS: 0
- FCP: 0.5s
- Speed Index: 0.7s
Mobile:
- Performance Score: 89
- LCP: ~2.0s
- CLS: 0
- Speed Index: 2.0s
Métricas obtenidas de PageSpeed Insights. Los valores pueden variar según las condiciones de red.
Estrategias que funcionaron
Critical CSS inline — ~2KB de estilos críticos del hero en
<style is:inline>.
Font preloading selectivo — Solo 3 fonts críticas (Inter 400/600/700). El resto se carga después.
Image optimization — Sharp para optimización en build, preload de la imagen LCP, lazy loading para below-the-fold.
CSS-only animations — Como mencioné arriba, migrar de JS a CSS eliminó DOM mutations y mejoró el score mobile significativamente.
Lo que NO funcionó
Lazy loading de traducciones en SSG — En Astro SSG todo se resuelve en build time. El lazy loading solo agregó complejidad sin beneficio real.
Demasiados chunks pequeños — Inicialmente tenía chunks muy granulares. Esto causaba más requests HTTP que el beneficio de cacheabilidad.
Testing
Estrategia
- Unit tests (Vitest): Lógica de utilidades, helpers, transformaciones
- Component tests: Componentes React aislados
- E2E tests (Playwright): Flujos completos de usuario
- Accessibility tests: WCAG compliance con axe-core
5 suites E2E principales: Accessibility, Command Palette, Contact Form, Homepage, Services.
test('homepage meets WCAG AA', async ({ page }) => { await page.goto('/en/'); const violations = await new AxeBuilder({ page }).analyze(); expect(violations.violations).toHaveLength(0);});Estadísticas del proyecto
- 741 commits desde septiembre 2025
- 120+ componentes organizados por tipo
- 108+ archivos de contenido (blog, projects, snippets, etc.)
- 5 integraciones custom de Astro
- 2 idiomas con traducciones completas
- ~34KB menos de JS después de remover GSAP/Lenis
Lecciones aprendidas
Lo que haría igual
- Astro para contenido estático — La mejor decisión del proyecto
- TypeScript estricto — Me salvó de incontables bugs
- Content Collections — Type-safe content es un game changer
- CSS-first animations — Debería haber empezado así, no migrar después
Lo que haría diferente
- Empezar con menos features — El scope creep es real
- Definir design system antes — Gasté tiempo rehaciendo componentes
- Mobile-first desde el día 1 — Evité refactorizar después para mobile
- No usar librerías de animación pesadas — La migración de GSAP fue innecesaria si hubiera empezado con CSS
Consejos si estás armando tu portfolio
- No copies plantillas — Armá algo que refleje cómo pensás
- Priorizá performance — Un portfolio lento es una mala primera impresión
- Incluí contenido real — Blog posts, proyectos, código que muestre tu trabajo
- Hacelo mantenible — Vas a querer actualizarlo. Si es un pain, no lo vas a hacer
- Medí todo — Analytics, Lighthouse, Core Web Vitals. Lo que no medís, no mejorás
Próximos pasos
El portfolio está en producción pero sigue evolucionando:
- Agregar más proyectos con case studies detallados
- Implementar newsletter
- Agregar más CSS tricks interactivos
- Experimentar con nuevas animaciones CSS
Cierre
Construir un portfolio desde cero es un proyecto que nunca “termina”. Pero llegar a este punto, con un sitio funcional que representa bien lo que hago, fue un ejercicio valioso.
Si algo te sirve de este post, o tenés preguntas sobre alguna implementación específica, escribime. El código es open source en GitHub, así que también podés explorar el repo directamente.
El mejor portfolio es el que demuestra que podés construir algo real, no solo que sabés copiar tutoriales.
— qazuor