De 77 a 100: Cómo mejoramos el rendimiento de nuestro sitio Next.js (y lo que aprendimos)
Un recorrido honesto por todas las optimizaciones que aplicamos en yudexlabs.com para subir los puntajes de PageSpeed. Fuentes, videos, imágenes, accesibilidad, headers de caché y más — con código real.

Hace unas semanas corrimos PageSpeed Insights sobre nuestro propio sitio y el resultado fue incómodo: 77 en rendimiento mobile, 88 en accesibilidad y 100 en SEO. El desktop estaba en 92, pero el mobile era el problema real — y sabemos que Google pondera móvil primero.
Decidimos atacarlo en serio. No con trucos superficiales, sino entendiendo cada diagnóstico, cada métrica, y aplicando cambios quirúrgicos. En este artículo te contamos exactamente qué hicimos, por qué funcionó, y los errores que cometimos en el camino.
Si tienes un sitio desplegado con Next.js (App Router, Turbopack, React 19), este artículo es para ti.
Por qué importa PageSpeed (y por qué no importa tanto)
Primero, contexto. PageSpeed Insights no mide "si tu sitio se ve bonito" — mide experiencia de usuario real bajo condiciones adversas: dispositivos de gama media, conexiones lentas, CPUs limitadas.
Las métricas que más pesan en rendimiento mobile son:
- LCP (Largest Contentful Paint): cuánto tarda en aparecer el elemento más grande visible. Debe ser < 2.5s.
- TBT (Total Blocking Time): cuánto tiempo el hilo principal está bloqueado. Debe ser < 200ms.
- FCP (First Contentful Paint): primer pixel de contenido en pantalla.
- CLS (Cumulative Layout Shift): cuánto "salta" el layout mientras carga.
Ahora bien — el puntaje exacto no es el objetivo. El objetivo es que usuarios reales carguen tu sitio rápido. PageSpeed es el termómetro, no el paciente.
1. Fuentes: `display: "swap"` es no negociable
El problema: las fuentes custom bloquean el render. Si el navegador está descargando tu tipografía, el texto no aparece — eso destruye FCP y LCP.
La solución: en Next.js, todas las fuentes (tanto next/font/google como localFont) deben declarar display: "swap".
// layout.tsx
const satoshi = localFont({
src: [...],
variable: "--font-satoshi",
weight: "300 900",
display: "swap", // <-- crítico
});
const jetbrainsMono = JetBrains_Mono({
variable: "--font-mono",
subsets: ["latin"],
display: "swap", // <-- también aquí
});
Con swap, el navegador muestra el texto con una fuente de sistema mientras descarga la custom. El usuario ve contenido inmediatamente. Hay un breve flash de cambio tipográfico (FOUT), pero es infinitamente mejor que texto invisible.
Impacto: reducción directa en FCP y LCP.
2. Lazy loading de secciones con `next/dynamic`
El problema: Next.js por defecto incluye todo el JavaScript de todos los componentes en el bundle inicial. Si tu página tiene 10 secciones, el browser descarga y parsea el código de las 10 — aunque el usuario solo vea la primera.
La solución: cargar las secciones below-the-fold de forma diferida.
// page.tsx
import Hero from "@/components/Hero"; // eager
import Proof from "@/components/Proof"; // eager
import Services from "@/components/Services"; // eager
const Projects = dynamic(() => import("@/components/Projects"));
const Testimonials = dynamic(() => import("@/components/Testimonials"));
const FAQ = dynamic(() => import("@/components/FAQ"));
const AboutUs = dynamic(() => import("@/components/AboutUs"));
const Contact = dynamic(() => import("@/components/Contact"));
const Footer = dynamic(() => import("@/components/Footer"));
Las primeras tres secciones cargan de inmediato (son lo que el usuario ve al entrar). El resto se descarga cuando el navegador tiene tiempo libre o cuando el usuario hace scroll hacia allá.
Regla práctica: eager = lo que está above-the-fold. Dynamic = todo lo demás.
Impacto: reducción de bundle inicial, menor TBT, mejor TTI.
3. `next/image` en todo. Sin excepciones.
El problema: las etiquetas <img> nativas no hacen nada por sí solas. No optimizan el formato, no generan variantes responsive, no aplican lazy loading automático.
La solución: usar <Image> de next/image en cada imagen del sitio.
// ❌ antes
<img src="/img/team/ceo-miguel.webp" className="w-full h-full object-cover" />
// ✅ después
import Image from "next/image";
<div className="relative w-full h-full"> {/* necesario para fill */}
<Image
src="/img/team/ceo-miguel.webp"
alt="Miguel, CEO y Founder de Yudex Labs"
fill
sizes="(max-width: 768px) 45vw, (max-width: 1024px) 25vw, 200px"
loading="lazy"
className="object-cover"
/>
</div>
El atributo sizes es el más importante y el más ignorado. Le dice al navegador qué tan ancha será la imagen según el viewport, para que descargue la variante correcta — no la más grande.
En next.config.ts, habilitamos AVIF y WebP:
images: {
formats: ["image/avif", "image/webp"],
deviceSizes: [640, 750, 828, 1080, 1200, 1920],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
},
El navegador moderno descargará AVIF (30-50% más liviano que WebP), los más antiguos caerán a WebP, y los muy viejos a JPEG.
Impacto: reducción masiva en peso de imágenes, mejor LCP.
4. El problema del video en mobile (y cómo lo resolvimos)
Este fue el diagnóstico más costoso. Nuestro hero en mobile tenía un <video> decorativo de 6.9 MB. Incluso con preload="none", el navegador igual inicializa el elemento y puede comenzar a descargar en segundo plano.
Fase 1: remover del DOM en mobile
La solución correcta es no renderizar el elemento en absoluto:
const [isDesktop, setIsDesktop] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(min-width: 768px)");
setIsDesktop(mq.matches);
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
// En el JSX:
{isDesktop && (
<video autoPlay loop muted playsInline preload="metadata">
<source src="/videos/video-bg.mp4" type="video/mp4" />
</video>
)}
hidden de CSS no alcanza — el elemento existe en el DOM y el browser puede fetchearlo. Solo condicionando el render se elimina completamente.
Fase 2: comprimir para mobile
Pero queríamos el video en mobile — solo que optimizado. Usamos ffmpeg para convertirlo:
# WebM VP9 — Chrome, Firefox, Edge
ffmpeg -i original.mp4 \
-c:v libvpx-vp9 -crf 36 -b:v 0 \
-vf "scale=720:-2" -r 25 -an \
voidbg-mobile.webm -y
# H.264 — Safari fallback
ffmpeg -i original.mp4 \
-c:v libx264 -crf 28 -preset slow \
-vf "scale=720:-2" -r 25 -an \
-movflags +faststart \
voidbg-mobile.mp4 -y
Resultado: de 6.9 MB a 321 KB (WebM) y 208 KB (MP4). Una reducción del 97%.
{!isDesktop && (
<video autoPlay loop muted playsInline preload="none"
className="absolute inset-0 w-full h-full object-cover opacity-60">
<source src="/videos/voidbg-mobile.webm" type="video/webm" />
<source src="/videos/voidbg-mobile.mp4" type="video/mp4" />
</video>
)}
El browser elige WebM si puede; cae a MP4 en Safari. Ambos con preload="none" para no cargar hasta que el elemento esté visible.
Parámetros clave:
-crf 36(VP9) /-crf 28(H.264): balance calidad/peso. Más alto = más comprimido.-vf "scale=720:-2": redimensiona a 720px de ancho manteniendo proporción.-r 25: 25fps es suficiente para video decorativo.-an: elimina el audio (innecesario en videos muted).-movflags +faststart: mueve los metadatos al inicio del MP4 para que empiece a reproducirse antes de que termine de bajar.
5. Smooth scroll: CSS > JavaScript
El problema: teníamos un componente <SmoothScroll> basado en Lenis que envolvía toda la app. Lenis intercepta el scroll nativo y lo reescribe en JavaScript — eso añade peso al bundle y puede aumentar el TBT.
La solución: una línea de CSS.
/* globals.css */
html {
scroll-behavior: smooth;
}
Y en los links de navegación, usar scrollIntoView nativo:
const handleNav = (sectionId: string) => {
document.getElementById(sectionId)?.scrollIntoView({ behavior: "smooth" });
};
El resultado es idéntico para el usuario. El bundle es más liviano.
Cuándo sí usar Lenis: si necesitás efectos de scroll complejos (horizontal scroll, scroll hijacking artístico, parallax avanzado). Para navegación standard, CSS es suficiente.
6. Headers de caché: activos estáticos deben ser inmutables
Los activos que no cambian (fuentes, imágenes, SVGs) deben tener headers de caché agresivos. Si un usuario vuelve a tu sitio, el browser los sirve desde caché sin tocar la red.
// next.config.ts
async headers() {
return [
{
source: "/(fonts|img|svg|logo)/:path*",
headers: [{
key: "Cache-Control",
value: "public, max-age=31536000, immutable",
}],
},
{
source: "/videos/:path*",
headers: [{
key: "Cache-Control",
value: "public, max-age=604800, stale-while-revalidate=86400",
}],
},
];
},
immutable le dice al browser "nunca verifiques si este archivo cambió". Si el archivo cambia, Next.js genera un nuevo hash en el nombre — así que el browser siempre baja la versión correcta.
Los videos no son immutable porque podrían actualizarse sin cambiar el path.
7. Accesibilidad: no es solo ético, afecta el puntaje
PageSpeed incluye una auditoría de accesibilidad con peso real. Estos fueron los problemas que encontramos y cómo los resolvimos:
Botones sin nombre accesible:
// ❌ antes — botón con solo un icono, sin texto para lectores de pantalla
<button onClick={toggle}>
<div className="w-3.5 h-[1px] bg-white" />
</button>
// ✅ después
<button
onClick={toggle}
aria-label={isOpen ? `Cerrar ${service.title}` : `Expandir ${service.title}`}
aria-expanded={isOpen}
>
Contraste insuficiente: textos con opacidades bajas como text-white/30 son ilegibles para usuarios con baja visión y fallan WCAG AA. Los subimos a mínimo text-white/55 para textos secundarios sobre fondos oscuros.
Inputs sin labels:
// ❌ antes — placeholder no es un label
<input placeholder="Tu nombre" />
// ✅ después — label visualmente oculto pero accesible
<label htmlFor="booking-name" className="sr-only">Tu nombre</label>
<input id="booking-name" placeholder="Tu nombre" />
Orden de headings: saltar de h2 a h4 confunde a lectores de pantalla y bots. La jerarquía debe ser lineal: h1 → h2 → h3.
Links externos con contexto:
// ✅ indica a lectores de pantalla que abre en nueva pestaña
<a href="..." aria-label="Seguir a Yudex Labs en Instagram (abre en nueva pestaña)">
8. Metadatos base para Open Graph y SEO
Una línea pequeña con impacto grande:
// layout.tsx
export const metadata: Metadata = {
metadataBase: new URL("https://yudexlabs.com"),
// ...resto de metadata
};
Sin metadataBase, Next.js genera URLs relativas para Open Graph (/og-image.png). Las plataformas sociales necesitan URLs absolutas para mostrar previews correctamente.
El checklist completo
Si estás optimizando un sitio Next.js, sigue este orden:
- Fuentes:
display: "swap"en todas. - Imágenes:
next/imageen todo, consizescorrecto. - Formato de imágenes: habilitar AVIF + WebP en
next.config.ts. - Bundle:
next/dynamicpara todo lo que está below-the-fold. - Videos: condicionar render por breakpoint; comprimir con ffmpeg a WebM + MP4.
- Smooth scroll: CSS primero, JS solo si necesitás efectos complejos.
- Caché: headers
immutablepara activos estáticos. - Accesibilidad:
aria-labelen botones de ícono, contraste mínimo 4.5:1, labels en inputs, jerarquía de headings. - Metadata:
metadataBasepara URLs absolutas en OG. - Medir: PageSpeed Insights, Lighthouse en DevTools, y Web Vitals en producción con
@vercel/analytics.
Conclusión
La performance no es un feature — es el resultado de cada decisión de implementación acumulada. Un video mal optimizado, una fuente sin swap, un componente pesado que carga en el arranque... cada uno resta puntos invisibles que el usuario siente como "el sitio tarda".
La buena noticia es que la mayoría de estas optimizaciones son cambios pequeños con impacto desproporcionado. No necesitas reescribir tu app — necesitas auditar lo que ya tenés.
Corre PageSpeed Insights en tu sitio hoy. Revisá el reporte de "Oportunidades" y "Diagnósticos". Y si quieres ayuda aplicando estas mejoras en tu producto, sabes dónde encontrarnos.
Artículos recientes

Hablemos
Tu negocio ya está listo para un agente de IA — solo falta que hablemos.
Respuesta rápida.
Respondemos en menos de 24 horas, siempre.
Sin rodeos.
En la primera llamada ya sabes si la IA es para ti y por dónde empezar.
¿Por dónde quieres empezar?
Elige tu canal.