Construyendo mi portfolio desde cero: decisiones técnicas y lecciones aprendidas
Últimos Artículos

Construyendo mi portfolio desde cero: decisiones técnicas y lecciones aprendidas

Un recorrido completo por el desarrollo de qazuor.com: stack técnico, arquitectura, features implementadas, problemas reales que encontré y cómo los resolví.

25 min de lectura
#portfolio #astro #react #typescript #arquitectura #performance
Compartir:

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.

// ❌ Prohibido
function processData(data: any) { ... }
// ✅ Correcto
function 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 CSSUtility-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
  • BiomeLinting y formatting (reemplazo de ESLint + Prettier)
  • Vitest + PlaywrightTesting 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áticos

Content 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:

  1. Search Index Generator — Genera índice de búsqueda
  2. Social Blog Data — Exporta metadata para redes sociales
  3. Testimonial Avatars Downloader — Descarga avatares externos a local
  4. Color Interpolation Generator — Genera gradientes entre secciones
  5. 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:

  1. TimelineWrapper (Astro) — Detecta el tema y mapea colores
  2. TimelineContent (React) — Layout scrollable con controles
  3. 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:

AntesDespués
GSAP timelineWeb Animations API
GSAP ScrollTriggerIntersectionObserver + CSS
Lenis smooth scrollscroll-behavior: smooth
Custom scroll hooksscrollIntoView() nativo

Eliminé 1530 líneas de código y agregué 445. El resultado: misma funcionalidad, 34KB menos de JavaScript.

// Antes: hook custom con GSAP
const { ref } = useScrollAnimation({
animation: 'fadeInUp',
duration: 0.8,
});
// Después: CSS + IntersectionObserver
// src/scripts/scroll-reveal.ts
const 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 mismatch
const [theme, setTheme] = useState(localStorage.getItem('theme') || 'dark');
// ✅ Correcto
const [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:

integrations/testimonial-avatars-downloader.ts
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 custom
document.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 evento
document.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.

tests/e2e/accessibility.spec.ts
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 CollectionsType-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

  1. No copies plantillas — Armá algo que refleje cómo pensás
  2. Priorizá performance — Un portfolio lento es una mala primera impresión
  3. Incluí contenido real — Blog posts, proyectos, código que muestre tu trabajo
  4. Hacelo mantenible — Vas a querer actualizarlo. Si es un pain, no lo vas a hacer
  5. 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