Pasé Mi Web de React a Astro: Estos Son los Resultados de Rendimiento (Lighthouse)
Migré una web de React/Next.js a Astro y medí antes y después con Lighthouse. Los números hablan: de 62 a 98 en Performance. Cómo lo hice, qué problemas tuve y si merece la pena.
Tabla de contenidos
Tenía una web personal hecha con Next.js 14 (App Router, React Server Components, el stack más moderno del momento). Funcionaba bien, se veía bien. Pero cada vez que abría Lighthouse, los números me daban vergüenza:
Performance: 62
Accessibility: 89
Best Practices: 95
SEO: 91
62 en Performance para un blog con 15 páginas. Un blog. Sin base de datos en tiempo real, sin interactividad compleja, sin nada que justifique esa puntuación.
El problema era claro: estaba enviando 187KB de JavaScript al navegador para una web que era básicamente texto e imágenes.
Los números: antes (React) vs después (Astro)
| Métrica | React/Next.js | Astro | Mejora |
|---|---|---|---|
| Lighthouse Performance | 62 | 98 | +58% |
| JavaScript enviado | 187 KB | 12 KB | -93.6% |
| First Contentful Paint | 2.1s | 0.6s | -71% |
| Largest Contentful Paint | 3.8s | 1.1s | -71% |
| Time to Interactive | 4.2s | 0.8s | -81% |
| Cumulative Layout Shift | 0.12 | 0.01 | -92% |
| Total Blocking Time | 890ms | 40ms | -95% |
| Tamaño HTML (home) | 42 KB | 18 KB | -57% |
No es que Astro sea mágico. Es que React estaba enviando el framework completo, el runtime de React, el runtime de Next.js, los chunks de páginas pre-cargadas, y el JavaScript de hidratación… para una web que no necesitaba nada de eso.
¿Por qué mi web React era lenta?
Abrí el Network tab y analicé el JavaScript:
react-dom.production.min.js: 42 KB
next/dist/chunks/framework: 38 KB
next/dist/chunks/main: 28 KB
next/dist/chunks/pages/_app: 15 KB
next/dist/chunks/webpack: 8 KB
Página actual + componentes: 56 KB
─────────────────────────────────────
Total: 187 KB
De esos 187 KB, 130 KB eran el framework (React + Next.js runtime). Solo 56 KB eran mi código real. Y la mayor parte de mi código eran componentes que renderizaban HTML estático — no tenían estado, no tenían efectos, no tenían interactividad.
React necesita ese runtime para la hidratación: recorre todo el DOM, adjunta los event listeners, y sincroniza el HTML del servidor con el virtual DOM del cliente. Para un blog, eso es como usar un camión de 18 ruedas para ir a comprar el pan.
Cómo hice la migración (paso a paso)
Paso 1: Crear el proyecto Astro
npm create astro@latest blog-astro
# Elegí: Empty, TypeScript Strict, Install dependencies
Paso 2: Instalar las integraciones necesarias
npx astro add tailwind
npx astro add mdx # Si usas MDX, si es .md puro no hace falta
npx astro add sitemap
Paso 3: Convertir páginas de JSX a .astro
El cambio más grande fue la sintaxis. Astro usa .astro files que mezclan frontmatter (lógica del servidor) con HTML:
// ❌ Antes (Next.js) — page.tsx
import { getAllPosts } from '@/lib/posts';
import { PostCard } from '@/components/PostCard';
export default async function BlogPage() {
const posts = await getAllPosts();
return (
<main className="max-w-3xl mx-auto px-4 py-12">
<h1 className="text-4xl font-bold">Blog</h1>
<div className="mt-8 space-y-6">
{posts.map(post => (
<PostCard key={post.slug} post={post} />
))}
</div>
</main>
);
}
---
// ✅ Después (Astro) — index.astro
import { getCollection } from 'astro:content';
import BlogLayout from '../layouts/BlogLayout.astro';
import PostCard from '../components/PostCard.astro';
const posts = await getCollection('posts');
const sortedPosts = posts.sort((a, b) =>
new Date(b.data.date).getTime() - new Date(a.data.date).getTime()
);
---
<BlogLayout title="Blog">
<h1 class="text-4xl font-bold">Blog</h1>
<div class="mt-8 space-y-6">
{sortedPosts.map(post => (
<PostCard post={post} />
))}
</div>
</BlogLayout>
Los cambios principales:
className→class- La lógica va en el frontmatter (
---) - No hay
export default function— el HTML va directamente keyno es necesario en.astro(no hay virtual DOM)- Content Collections reemplazan el fetch de markdown manual
Paso 4: Convertir componentes
Componentes sin interactividad → .astro:
---
// components/PostCard.astro
const { post } = Astro.props;
const url = `/blog/${post.id.replace(/\.md$/, '')}/`;
---
<article class="border-b border-gray-200 dark:border-gray-800 pb-6">
<a href={url} class="group">
<h2 class="text-xl font-semibold group-hover:text-blue-600 transition-colors">
{post.data.title}
</h2>
<p class="mt-2 text-gray-600 dark:text-gray-400">
{post.data.description}
</p>
<time class="mt-2 text-sm text-gray-500">
{new Date(post.data.date).toLocaleDateString('es-ES')}
</time>
</a>
</article>
0 JavaScript enviado al navegador. Este componente se renderiza en build time como HTML puro.
Componentes CON interactividad → mantuve React con client: directive:
---
// En una página .astro
import SearchBar from '../components/SearchBar.tsx';
---
<!-- Solo este componente envía JavaScript -->
<SearchBar client:load />
Paso 5: Content Collections para los posts
// src/content.config.ts
import { defineCollection, z } from 'astro:content';
const posts = defineCollection({
schema: z.object({
title: z.string(),
description: z.string(),
date: z.coerce.date(),
tags: z.array(z.string()).default([]),
image: z.string().optional(),
}),
});
export const collections = { posts };
Los markdowns van en src/content/posts/ y Astro los valida contra el schema automáticamente.
Los problemas que tuve
Problema 1: No existe useRouter() en Astro
En React hacía:
const router = useRouter();
router.push('/blog');
En Astro, los links son <a href> normales. Sin client-side routing. Cada clic es una navegación completa del navegador.
“¿Eso no es más lento?” — pensé. Pero no. Con HTML pre-renderizado, la navegación del navegador es más rápida que el client-side routing de Next.js porque no hay JavaScript que parsear y ejecutar. El HTML llega y se pinta. Punto.
Para las pocas transiciones que quería animar, usé la View Transitions API de Astro:
---
// En el layout
import { ViewTransitions } from 'astro:transitions';
---
<head>
<ViewTransitions />
</head>
Una línea y tienes transiciones animadas entre páginas. Sin Framer Motion, sin AnimatePresence, sin 32KB de JavaScript.
Problema 2: El search necesitaba JavaScript
Mi barra de búsqueda era un componente React con estado. En Astro lo mantuve como isla de React:
<SearchBar client:idle />
client:idle carga el JavaScript cuando el navegador está idle. El usuario ve la página inmediatamente, y cuando el navegador no tiene nada que hacer, carga el search. Sin bloquear el render.
Problema 3: Dark mode con FOUC
Astro genera HTML estático, así que si pones la clase dark en <html> con JavaScript del cliente, hay un flash de tema claro antes de que el script se ejecute.
Solución: script inline en el <head> que se ejecuta antes del render:
<head>
<script is:inline>
if (localStorage.getItem('color-theme') === 'dark' ||
(!localStorage.getItem('color-theme') &&
window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
</script>
</head>
is:inline en Astro evita que el script se bundlee y lo ejecuta inmediatamente.
¿Merece la pena migrar?
Sí, si tu web es principalmente contenido (blog, docs, portfolio, landing page). Los números hablan solos:
- 98 en Lighthouse vs 62
- 12 KB de JS vs 187 KB
- 0.6s FCP vs 2.1s
No, si tu web es una app interactiva (dashboard, SaaS con mucho estado, editor visual). Astro no está diseñado para apps con mucha interactividad — para eso sigue con Next.js o Remix.
La zona gris: Webs que son 80% contenido + 20% interactividad (un blog con un buscador, un portfolio con un formulario de contacto). Ahí Astro con islas de React es perfecto — HTML estático para todo, React solo para los 2-3 componentes que lo necesitan.
Mi portfolio actual usa exactamente eso: Astro para todo el contenido estático, y las pocas piezas interactivas son vanilla JS con is:inline. Zero framework runtime en el navegador.
Artículos relacionados
- Cómo hice mi portfolio con Vite y Tailwind — el portfolio que migré
- Deploy gratis con GitHub Actions + Netlify — cómo despliego Astro
- Cheat sheet de CSS Grid y Flexbox — los layouts que uso en Astro sin JavaScript