Pasamos este sitio de Next.js 16 a Astro 7 porque es un sitio de contenido: casi todas sus páginas ya eran estáticas, pero cada una cargaba un runtime de cliente que apenas usaba. El JavaScript por página bajó de 215,6 KB a 13,5 KB con gzip, cerca de un 94 %. Eso sí, la migración fue también un rediseño completo, y el nuevo video de portada hizo que la home pesara más de tres veces más en móvil.
También introdujo dos regresiones que solo vimos al medir: un Largest Contentful Paint más lento y la pérdida de la detección de idioma. Corregimos ambas. Aquí contamos lo que cambió el framework, lo que cambió el rediseño, lo que rompimos y los casos en los que seguiríamos eligiendo Next.js. Next.js le sirvió bien a este sitio, y lo seguimos ofreciendo, porque la capa frontal adecuada depende de cada proyecto.
Por qué lo hicimos
La forma del sitio ya no aprovechaba las fortalezas del framework. Al revisar el código anterior encontramos 35 archivos que generaban sus páginas de forma estática con generateStaticParams. Ninguno usaba force-dynamic ni revalidate. Solo dos piezas se ejecutaban en cada petición: la ruta de contacto y un proxy.ts que resolvía el idioma con next-intl.
La interactividad era moderada. framer-motion se importaba en dos archivos. React estaba ahí para 28 archivos de componentes de cliente, repartidos entre navegación, formularios, providers y secciones animadas.
Ya construimos sitios de contenido con Astro. Astro es la capa frontal oficial de Wordex Agentic para sitios de contenido, y con él migramos el sitio web de Buena Vida desde WordPress. Tener nuestro propio sitio sobre Astro significa convivir con lo que recomendamos, como contamos en ingeniería.
El rediseño ya estaba decidido. Si íbamos a reconstruir todas las páginas, cambiar de framework nunca iba a salir más barato.
Seguir en Next.js con un export estático no era un cambio directo. Ese modo no admite Proxy, redirecciones ni Route Handlers que lean la petición, y el sitio usaba los tres.
Qué cambió y por qué
Esta comparación no aísla el framework. Las dos versiones se diferencian en framework, diseño, movimiento, medios, fuentes y textos. Para tener una cifra que sí lo aísle, hicimos un control: la misma página estática, sin componentes de cliente ni islas, en cada framework y con las versiones que usamos. Astro envió 0 bytes de JavaScript. Next.js 16.1.1 con React 19.2.3 envió 129,5 KB con gzip, un piso equivalente al 60 % de lo que cargaba el sitio anterior.
| Mediana de laboratorio en móvil | Sitio en Next.js | Sitio en Astro | Causa principal |
|---|---|---|---|
| JavaScript, gzip (todas las páginas) | 215,6 KB | 13,5 KB | Framework y código propio |
| JavaScript, decodificado (todas las páginas) | 682,6 KB | 46,0 KB | Framework y código propio |
| HTML, gzip, página de contenido | 32,0 KB | 8,3 KB | Framework y textos |
| Total Blocking Time, home | 129 ms | 0 ms | Framework y movimiento |
| Trabajo del hilo principal, home | 3,00 s | 1,33 s | Mixta |
| Peso de la página, servicio | 388 KB | 158 KB | Mixta |
| Peso de la página, home | 421 KB | 1.427 KB | Rediseño: video |
| Largest Contentful Paint, home | 1,06 s | 0,90 s | Tras la corrección de abajo |
| Lighthouse Performance, home | 99 | 100 | Empate práctico |
Las condiciones están en la sección «Cómo medimos». Aquí, 1 KB son 1.000 bytes.
Lo que cambió el framework
Astro elimina por defecto el JavaScript de cliente y te pide activarlo donde haga falta, un enfoque que viene de la arquitectura de islas que describió Jason Miller y que él atribuye a Katie Sylor-Miller. El único script que nos queda es nuestro propio motor de movimiento.
Next.js hace más en el cliente, y lo hace a propósito. Su documentación explica que el RSC Payload y el JavaScript reconcilian e hidratan la página cuando llega el HTML. En nuestra antigua home, los scripts en línea sumaban 119,1 KB sin comprimir; ahora son 2,8 KB.
Ese trabajo tiene una contrapartida real. Next.js precarga y guarda en caché las rutas para navegar más rápido. Renunciamos a ello a cambio de cargas completas de página. No medimos la velocidad al pasar de una página a otra, donde Next.js bien podría ganar.
Lo que cambió el rediseño
El video de portada pesa 1,2 MB en móvil y 3,7 MB en escritorio, y llevó la home de escritorio de 542 KB a 3,9 MB. Sin él, la home pesa 268 KB, menos que antes. Lo tratamos como un coste deliberado: no está en otras páginas y no se carga si el visitante tiene activados el movimiento reducido o Save-Data.
También son del rediseño el script de movimiento (46,0 KB) y unas fuentes más pesadas (de 62 KB a 90 KB).
Lo que sigue peor
- El peso de la home, como acabamos de ver, y la página de Wordex Agentic, donde una captura grande del producto fija el LCP móvil en 2,55 s en Lighthouse y 2,13 s con limitación real. Aún no hemos optimizado esa página, así que el resultado de LCP de la siguiente sección se refiere a la home.
- Los paquetes de producción subieron de 162 a 262. El disco bajó de 714 MB a 248 MB y las dependencias directas de 28 a 9, pero parte de eso son herramientas que eliminó el rediseño. En los controles, Astro arrastra 260 paquetes en el lockfile frente a 52 de Next.js, en 146 MB frente a 300 MB.
Las puntuaciones no decidieron nada de esto. Lighthouse Performance se mantuvo en de 97 a 100 en ambos sitios, en localhost y con limitación simulada, y su documentación aconseja leerlas como una distribución. Los builds bajaron de una mediana de 11,7 s a 4,4 s, aunque la cifra de Next.js incluye su comprobación de tipos y la de Astro no.
La regresión de LCP que introdujimos
La primera medición mostró que el LCP de la home en móvil pasaba de 1,06 s a 2,40 s. Sospechamos del video. Al bloquearlo la cifra siguió en 2,40 s, así que dejamos de suponer y trazamos la página con limitación real de red y CPU.
La traza contó otra historia. El elemento LCP era el párrafo bajo el titular, que se pintaba a 1.624 ms frente a 992 ms en el sitio anterior, con el primer pintado en torno a 800 ms. Ese párrafo estaba dentro de un bloque con un fundido retrasado:
.hero__side { animation: fadeUp 1s var(--ease) .55s both; }
Chromium excluye del LCP los elementos con opacidad 0, así que un fundido retrasa la métrica exactamente el tiempo que tu texto más grande permanece oculto, y el lector espera con ella. La imagen de portada no lo arregla: la misma guía excluye los elementos que cubren todo el viewport.
Mantuvimos el movimiento y quitamos la ocultación. El párrafo ahora se acomoda desde el primer pintado, y solo la llamada a la acción aparece con fundido:
.hero__lead { animation: settle 1s var(--ease) both; }
.hero__cta { animation: fadeUp 1s var(--ease) .55s both; }
@keyframes settle { from { transform: translateY(14px); } }
@keyframes fadeUp { from { opacity: 0; transform: translateY(14px); } }
También dimos a las pantallas de hasta 810 px un póster de 960 px, el mismo ancho del video que se sirve ahí. Pesa 53 KB frente a 108 KB.
| Home en móvil, mediana de 5 | Antes de corregir | Después |
|---|---|---|
| Limitación real, LCP observado | 1.624 ms | 784 ms |
| Lighthouse, LCP simulado | 2.403 ms | 903 ms |
| Lighthouse Performance | 98 | 100 |
Lighthouse exageró el problema en unos 780 ms. Su simulación señaló el logo de la cabecera como elemento LCP y estimó a partir de ahí. Cuando el párrafo pasó a pintarse primero, su estimación y la traza se acercaron. No podemos explicar del todo esa diferencia, así que tomamos la traza como evidencia y la puntuación como la alarma que nos hizo buscar.
La lección que nos llevamos: anima tu texto más grande con transform, nunca con un opacity retrasado. Deja los fundidos para lo que el lector no necesita primero. Y cuando se mueva una cifra de laboratorio, trázala antes de creerte una causa, incluida tu primera suposición.
Detección de idioma: perdida y recuperada
El proxy anterior llevaba de / a /es a quien enviaba Accept-Language: es. Una raíz estática no negocia, así que la migración perdió ese comportamiento sin que nada fallara. Solo lo notamos al comparar los dos sitios durante la medición. Nuestra comprobación de URL no podía detectarlo, porque todas seguían respondiendo.
Lo recuperamos sin función, con rutas declarativas en la salida del build:
const LOCALE_COOKIE = 'cx_lang';
// First language is Spanish at full quality: es, es-MX or es;q=1, but not en,es or es;q=0.8.
const ACCEPT_LANGUAGE_PREFERS_ES = '^\\s*(?:[eE][sS])(?:-[a-zA-Z0-9]+)*\\s*(?:;\\s*[qQ]=1(?:\\.0{0,3})?\\s*)?(?:,|$)';
const VARY = 'Accept-Language, Cookie, Sec-Fetch-Site';
export function localeRoutes() {
return [
{ src: '^/$', headers: { Vary: VARY }, continue: true },
{
src: '^/$',
status: 302,
headers: { Location: '/es', Vary: VARY },
has: [{ type: 'header', key: 'accept-language', value: { re: ACCEPT_LANGUAGE_PREFERS_ES } }],
missing: [
{ type: 'cookie', key: LOCALE_COOKIE, value: 'en' },
{ type: 'header', key: 'sec-fetch-site', value: 'same-origin' },
],
},
];
}
Las concesiones que aceptamos:
- Un 302, no un 301. La respuesta depende del visitante, así que no debe recordarse como permanente.
Varyen las dos respuestas. El mismo valor debe aparecer en todas las respuestas de una URL, por eso también lo declara la página en inglés. Listar estas cabeceras puede dividir las cachés compartidas en más entradas. No hemos medido ese efecto.- Una regla estrecha a propósito. El español debe ir primero y con calidad completa, así que
en-US,en;q=0.9,es;q=0.5sigue en inglés. Un raroes;q=0.8en solitario también sigue en inglés. Una expresión regular no puede comparar valores de calidad, y el inglés es la respuesta segura. - Una cookie, la primera del sitio. El selector de idioma guarda
cx_langdurante un año, para que a quien elige inglés nunca se le devuelva al español. Contiene solo esa elección, y nuestro aviso de privacidad ya lo dice. Los clics desde dentro del sitio también quedan exentos gracias aSec-Fetch-Site, lo que cubre a los visitantes sin JavaScript.
Un rastreador que no envía Accept-Language recibe inglés, y las etiquetas canonical y hreflang no cambiaron. Confirmamos el comportamiento en producción sobre las rutas desplegadas: es-MX y es-ES reciben el 302, parámetros como ?utm_source=x lo sobreviven, y una cabecera con inglés primero, la ausencia de cabecera, cx_lang=en y las peticiones same-origin reciben un 200.
Cómo hicimos la migración
- Congelar primero las URL. Guardamos en un archivo de texto las 102 URL del sitemap del sitio en Next.js y otras dos de un artículo antiguo. Ese archivo pasó a ser el contrato.
- Escribir el mapa de redirecciones. Ocho productos retirados redirigen con un 301 a
/productsen los dos idiomas. La opciónredirectsde Astro usa 301 por defecto, y los adaptadores compatibles escriben las reglas en la configuración del hosting. Google interpreta una redirección permanente como señal de que el destino debe ser el canónico. - Mantener las URL de cada idioma. El inglés sigue en la raíz y el español bajo
/es, con el enrutado i18n de Astro yprefixDefaultLocale: false, la misma forma que nos dabaas-neededen next-intl. - Pasar el contenido a colecciones. Un módulo de 600 líneas sobre gray-matter y remark dio paso a colecciones de contenido con esquemas Zod. Si a una página le falta un campo en cualquiera de los dos idiomas, el build falla.
- Portar el endpoint de contacto escribiendo antes las pruebas, y rehacer el movimiento sin React.
- Exigir una verificación a cada cambio.
npm run release:checkejecuta tipos, pruebas, build y comparación de URL.
Cómo verificamos que no se perdiera ninguna URL
La comprobación lee la salida del build, de modo que prueba lo que recibirá la plataforma. Cada URL antigua obtiene exactamente un resultado:
export function checkUrl(url, { isPage, redirects }) {
if (isPage(url)) return { kind: 'page' };
const redirect = redirects.get(url);
if (!redirect) return { kind: 'problem', reason: 'no page, no redirect' };
if (redirect.status !== 301) return { kind: 'problem', reason: `status ${redirect.status}` };
const destination = redirect.headers.Location;
if (redirects.has(destination)) return { kind: 'problem', reason: `chain through ${destination}` };
if (!isPage(destination)) return { kind: 'problem', reason: `destination ${destination} is not a page` };
return { kind: 'redirect' };
}
Resultado: 104 URL antiguas, 86 páginas, 18 redirecciones únicas y 0 problemas. Otro paso falla si el nuevo sitemap incluye una redirección o una página inexistente.
La función de contacto
Astro prerrenderiza todo por defecto. Un solo endpoint se sale de esa regla, y para eso necesita un adaptador. Esta es la ruta completa:
import type { APIRoute } from 'astro';
import { Resend } from 'resend';
import { createContactHandler, type ContactEnv, type SendMail } from '@/lib/contact';
export const prerender = false;
const env = process.env as ContactEnv;
const send: SendMail = async (mail) => {
const { error } = await new Resend(env.RESEND_API_KEY).emails.send(mail);
return { error };
};
const handle = createContactHandler({ env, send });
export const POST: APIRoute = ({ request }) => handle(request);
La comprobación de origen, el límite de peticiones en memoria, el honeypot, la validación con Zod y el escapado de HTML se vinieron con nosotros, a un manejador independiente del framework y del proveedor de correo, y eso permitió probar cada protección. El límite sigue siendo orientativo y por instancia serverless, como antes.
Movimiento: un script que se ejecuta una vez
El nuevo motor de movimiento es un solo script clásico diferido de unas 900 líneas, con navegación normal entre páginas. Cada clic carga un documento nuevo, así que el navegador descarta listeners, observers y frames de animación. El script no tiene código de limpieza porque nunca sobrevive a su página. Además, solo quita cosas y las devuelve animadas: si falla, la página entera sigue visible.
En una app React con enrutado de cliente, el documento sobrevive a la navegación, y por eso cada efecto necesita una limpieza que deshaga lo que hizo. Nuestro antiguo provider de scroll suave hacía justo eso:
useEffect(() => {
const query = window.matchMedia(REDUCED_MOTION);
let lenis: Lenis | null = null;
let frame = 0;
const raf = (time: number) => {
lenis?.raf(time);
frame = requestAnimationFrame(raf);
};
const start = () => {
if (lenis || query.matches) return;
lenis = new Lenis({ lerp: 0.1, smoothWheel: true });
frame = requestAnimationFrame(raf);
};
const stop = () => {
cancelAnimationFrame(frame);
lenis?.destroy();
lenis = null;
};
const onChange = () => (query.matches ? stop() : start());
start();
query.addEventListener("change", onChange);
return () => {
query.removeEventListener("change", onChange);
stop();
};
}, []);
Ese código es correcto, pero portar un motor de este tamaño habría obligado a escribir mucho más como él.
Tropiezos que te puedes ahorrar
El preset de framework de Vercel afecta a todo el proyecto. Cambiarlo también habría alterado el build de producción, que seguía en Next.js, mientras trabajábamos en una rama. Un vercel.json lo sobrescribe por despliegue, de modo que solo la rama que lleva el archivo se construye como Astro:
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"framework": "astro",
"buildCommand": "astro build"
}
Los previews pueden enviar correo a personas reales. Cada push a una rama crea un preview con el formulario funcionando. Nuestro endpoint decide el destinatario según el entorno. Producción usa el buzón real. En cualquier otro entorno solo puede llegar a una dirección de prueba:
export function recipientFor(env: ContactEnv): string | null {
if (env.VERCEL_ENV === 'production') {
return env.CONTACT_TO_EMAIL || env.NEXT_PUBLIC_CONTACT_EMAIL || FALLBACK_RECIPIENT;
}
return env.CONTACT_PREVIEW_TO_EMAIL || null;
}
Cuando devuelve null, el manejador no envía nada y responde { ok: true, demo: true }, de modo que el formulario indica que es una demo.
robots.txt no sabe en qué dominio está. Un archivo estático sirve las mismas reglas en cualquier host, y el noindex automático de Vercel cubre los previews, no el despliegue de producción vigente. Una pequeña integración de build añade X-Robots-Tag: noindex para los hosts *.vercel.app.
Los navegadores de prueba no son teléfonos. En nuestro entorno, el WebKit de Playwright se caía con cualquier fuente de video, incluso en una página de prueba vacía. Ese build y Firefox headless tampoco tenían decodificador H.264. Por eso la portada comprueba canPlayType antes de cargar el archivo, y esa alternativa también protege a los navegadores reales. El límite queda claro: esas pruebas validaron el póster, no la reproducción, y hubo que comprobar a mano un iPhone real.
Cuándo Next.js sigue siendo mejor opción
Volveríamos a elegir Next.js para:
- Productos tipo aplicación, con pantallas que comparten estado de cliente y una navegación que debe sentirse continua.
- Páginas autenticadas y por usuario, donde casi todas las respuestas dependen de la sesión.
- Mucha lógica de servidor junto a la interfaz, con Server Functions, Proxy y Cache Components como un único sistema.
- Equipos con una inversión fuerte en React, cuyos componentes, pruebas y contrataciones giran en torno a él. Astro admite islas de React, pero una aplicación casi toda interactiva repartida en islas va a contracorriente.
Un marco de decisión reutilizable
Hazte estas preguntas en orden, mirando tus páginas reales y no tus preferencias:
- ¿Qué parte de tus páginas cambia por petición o por usuario? Si es mucha, quédate en un framework de aplicación centrado en servidor. Si es casi nada, sigue.
- ¿Qué necesita conservar estado de cliente entre páginas? Un reproductor, un carrito o un editor persistentes apuntan a enrutado de cliente. Los widgets aislados apuntan a islas.
- ¿Qué hace tu framework en el borde sin que lo notes? Enumera reglas de proxy, negociación de idioma y redirecciones, y decide qué reconstruir. A nosotros se nos pasó la detección de idioma en la primera vuelta.
- ¿Cuál es el piso de tu framework? Haz el control de una página que describimos. Lleva minutos y muestra lo que pagas antes de tu propio código.
- ¿Puedes separar la migración de un rediseño? Si quieres cifras atribuibles, migra primero y rediseña después. Nosotros hicimos las dos cosas a la vez, así que las nuestras solo se pueden atribuir en parte.
Cómo medimos
Medido el 2 de noviembre de 2026 a partir de los commits 2988c6d (Next.js 16.1.1, React 19.2.3) y 74a590b (Astro 7.4.4), cada uno desde una copia limpia.
- Máquina: AMD Ryzen 7 8700F, 16 GB de RAM, Ubuntu 26.04 sobre WSL2, Node 24.19.0.
- Servidor: localhost por HTTP/1.1 con gzip;
next startpara Next.js y un pequeño servidor estático para Astro. Sin CDN, así que el borde de Vercel no está incluido. - Lighthouse: 13.6.0 en Chrome for Testing 156, limitación simulada, presets de móvil y escritorio, cinco ejecuciones alternas por página y mediana.
- Trazas: el mismo perfil móvil aplicado como limitación real en Chromium (150 ms de latencia, 1,6 Mbps, CPU 4x), cinco ejecuciones, leyendo las entradas LCP de
PerformanceObserver. - Páginas: home,
/services/ai-automationy/insights/elixir-rust-vector-tile-engine, además de/es,/products/wordex-agenticy/contact. Su contenido difiere entre versiones. - Builds: cinco ejecuciones en frío de cada uno, mediana del tiempo real.
- Sin medir: usuarios reales, redes reales e INP, porque los datos de campo siguen pendientes (los datos de laboratorio y de campo miden cosas distintas) y el sitio salió en vivo el 2 de noviembre de 2026. Tampoco la navegación entre páginas, el borde de Vercel, el efecto de
Varyen la caché ni el mismo diseño en ambos frameworks. El enrutado de idioma se comprobó en producción por petición; las demás cifras son solo de laboratorio.
¿Estás valorando un cambio parecido?
Si tienes un sitio de contenido en Next.js, o un WordPress del que te gustaría salir, tráenos tus páginas y lo que deben seguir haciendo. Revisamos contigo qué conservaría el cambio, cuánto costaría y si merece la pena. También puedes pedir una demostración de Wordex Agentic.
