Los Core Web Vitals son tres metricas que Google usa para evaluar la experiencia de usuario de una pagina web. No son metricas abstractas: miden cosas que los usuarios realmente sienten.
Google confirmo que los Core Web Vitals son un factor de ranking desde 2021. Un sitio con buenas metricas no va a saltar magicamente al primer puesto, pero ante dos paginas con contenido equivalente, Google favorecera la que ofrezca mejor experiencia.
Para sitios Next.js con output: "export", tenemos una ventaja de base: servimos HTML estatico sin server-side rendering en cada request. Pero eso no garantiza buenas metricas automaticamente. Veamos donde se pierden puntos y como recuperarlos.
El LCP suele ser el cuello de botella mas comun. En un portafolio o blog tipico, el LCP es la imagen hero o el primer bloque de texto grande.
Antes de optimizar, identifica que elemento es tu LCP. En Chrome DevTools, pestaña Performance, graba una carga de pagina y busca el marcador "LCP". Lighthouse tambien lo indica en la seccion de diagnostico.
Si tu LCP es una imagen, el atributo priority de Next.js es critico:
import Image from 'next/image';
export default function Hero() {
return (
<section>
<Image
src="/images/hero-portrait.webp"
alt="Descripcion del hero"
width={600}
height={800}
priority
sizes="(max-width: 768px) 100vw, 50vw"
/>
<h1>Titulo principal</h1>
</section>
);
}
El prop priority hace dos cosas: agrega un <link rel="preload"> en el <head> del documento y elimina el lazy loading por defecto. Sin priority, Next.js aplica loading="lazy" a todas las imagenes, lo que retrasa la carga de la imagen hero porque el navegador no la solicita hasta que esta cerca del viewport.
Solo usa priority en imagenes above the fold. Si lo aplicas a imagenes que estan mas abajo, estaras compitiendo por ancho de banda con recursos mas criticos.
Si cargas fuentes, analytics u otros recursos de dominios externos, establece la conexion antes de que el navegador los descubra:
// app/layout.jsx
export default function RootLayout({ children }) {
return (
<html>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossOrigin="anonymous"
/>
</head>
<body>{children}</body>
</html>
);
}
Cada preconnect ahorra entre 100-300ms al eliminar la negociacion DNS + TCP + TLS antes de que el recurso se necesite.
Con exportacion estatica, Next.js no optimiza imagenes en tiempo de request (no hay servidor para hacerlo). Debes optimizarlas antes del build:
# Convertir a WebP con calidad 80 (buen balance calidad/peso)
cwebp -q 80 hero.png -o hero.webp
# O usar sharp en un script de build
node -e "
const sharp = require('sharp');
sharp('public/images/hero.png')
.webp({ quality: 80 })
.toFile('public/images/hero.webp');
"
Una imagen PNG de 500KB se reduce tipicamente a 80-120KB en WebP. Esa diferencia es directamente tiempo de descarga que impacta el LCP.
Las fuentes web son una de las causas mas comunes de problemas de rendimiento, y generan dos fenomenos visibles:
Ambos causan CLS (por el cambio de dimensiones del texto) y potencialmente afectan LCP (si el LCP es un bloque de texto).
// app/layout.jsx
import { Outfit, Newsreader } from 'next/font/google';
const outfit = Outfit({
subsets: ['latin'],
weight: ['300', '400', '500', '600'],
display: 'swap',
variable: '--font-outfit',
});
const newsreader = Newsreader({
subsets: ['latin'],
weight: ['400', '600'],
style: ['normal', 'italic'],
display: 'swap',
variable: '--font-newsreader',
});
export default function RootLayout({ children }) {
return (
<html className={`${outfit.variable} ${newsreader.variable}`}>
<body className={outfit.className}>
{children}
</body>
</html>
);
}
next/font descarga las fuentes en build time, las almacena como archivos estaticos, y genera CSS con @font-face que apunta a esos archivos locales. El resultado:
preconnect + descarga externa.font-display: swap: El texto se muestra inmediatamente con una fuente del sistema y transiciona a la personalizada cuando esta lista. Esto elimina FOIT.size-adjust automatico: Next.js calcula el ajuste de tamano para que la fuente fallback tenga dimensiones similares a la definitiva, minimizando el layout shift de FOUT.Cada peso (300, 400, 500, 600) y estilo (normal, italic) es un archivo separado. Solo incluye los que realmente usas:
// En vez de incluir todos los pesos disponibles
const outfit = Outfit({
subsets: ['latin'],
weight: ['300', '400', '500', '600', '700', '800', '900'], // NO
});
// Incluye solo los que tu CSS referencia
const outfit = Outfit({
subsets: ['latin'],
weight: ['400', '600'], // SI
});
Cada peso eliminado ahorra entre 15-40KB de transferencia.
El CLS es la metrica que mas frustra a los usuarios y la mas facil de arruinar sin darte cuenta.
Siempre incluye width y height en las imagenes. Next.js Image lo requiere y calcula el aspect ratio para reservar espacio antes de la carga:
// Correcto: el navegador reserva espacio basado en el aspect ratio
<Image src="/foto.webp" width={800} height={600} alt="..." />
// Tambien correcto: usando fill con un contenedor de tamano definido
<div className="relative w-full aspect-video">
<Image src="/foto.webp" fill alt="..." sizes="100vw" />
</div>
Si usas Google AdSense u otra red de anuncios, los slots vacios que se llenan despues de la carga son una fuente enorme de CLS. La solucion es reservar un espacio minimo con CSS:
function AdSlot({ className }) {
return (
<div
className={`min-h-[250px] w-full bg-transparent ${className}`}
aria-hidden="true"
>
<ins
className="adsbygoogle"
style={{ display: 'block' }}
data-ad-client="ca-pub-XXXXXXXXX"
data-ad-slot="YYYYYYY"
data-ad-format="auto"
data-full-width-responsive="true"
/>
</div>
);
}
El min-h-[250px] asegura que aunque el anuncio tarde en cargar (o no cargue en absoluto), el layout no se desplaza.
Las animaciones de entrada (fade in, slide up) pueden causar CLS si mueven elementos de su posicion original. La clave es animar solo propiedades que no afectan el layout:
/* CAUSA CLS: anima margin/height que desplaza otros elementos */
.animate-in {
animation: slideDown 0.5s ease-out;
}
@keyframes slideDown {
from { margin-top: -20px; opacity: 0; }
to { margin-top: 0; opacity: 1; }
}
/* NO CAUSA CLS: transform y opacity no afectan el layout */
.animate-in {
animation: fadeUp 0.5s ease-out;
}
@keyframes fadeUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
transform y opacity son las dos propiedades que el navegador puede animar usando el compositor (GPU), sin recalcular el layout del documento.
Con exportacion estatica, Next.js genera HTML pre-renderizado. Pero si tienes muchos Client Components ("use client"), el navegador debe descargar, parsear y ejecutar ese JavaScript antes de que las interacciones respondan.
Revisa cada componente con "use client" y preguntate: realmente necesita interactividad?
// NO necesita "use client" - es contenido estatico
function ProjectCard({ title, description, tags }) {
return (
<article>
<h3>{title}</h3>
<p>{description}</p>
<ul>
{tags.map(tag => <li key={tag}>{tag}</li>)}
</ul>
</article>
);
}
// SI necesita "use client" - maneja estado
'use client';
function ContactForm() {
const [status, setStatus] = useState('idle');
// ...
}
Cada componente que mantienes como Server Component es JavaScript que el navegador no necesita descargar ni ejecutar.
Si un componente es grande pero no es visible inmediatamente (un modal, una seccion below the fold), usa dynamic import:
import dynamic from 'next/dynamic';
const HeavyChart = dynamic(() => import('./components/HeavyChart'), {
loading: () => <div className="h-64 animate-pulse bg-surface" />,
ssr: true,
});
Esto divide el bundle en chunks que se cargan bajo demanda, reduciendo el JavaScript inicial que bloquea interactividad.
Un patron comun en sitios estaticos es el wrapper de tema oscuro/claro:
'use client';
import { useEffect, useState } from 'react';
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('dark');
useEffect(() => {
const saved = localStorage.getItem('theme');
if (saved) setTheme(saved);
}, []);
return <div data-theme={theme}>{children}</div>;
}
Este patron fuerza que todo children pase por hidratacion del cliente. Una alternativa mas eficiente es usar un script inline en el <head> que se ejecuta antes del render:
// app/layout.jsx (Server Component, sin "use client")
export default function RootLayout({ children }) {
return (
<html>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
var t = localStorage.getItem('theme');
if (t) document.documentElement.classList.add(t);
else if (matchMedia('(prefers-color-scheme: light)').matches)
document.documentElement.classList.add('light');
})();
`,
}}
/>
</head>
<body>{children}</body>
</html>
);
}
Este script se ejecuta sincrono, antes de que el navegador pinte el primer frame. No hay flash de tema incorrecto, no hay Client Component wrapper, y no hay JavaScript adicional que hidratar.
Optimizar sin medir es adivinar. Usa estas herramientas en orden:
Lighthouse en Chrome DevTools (pestaña Lighthouse): Rapido y local. Usa el modo "Navigation" con throttling activado para simular condiciones reales. Ejecuta al menos 3 veces y promedia, porque los resultados varian entre ejecuciones.
PageSpeed Insights (web.dev/measure): Combina datos de laboratorio (Lighthouse) con datos de campo (Chrome UX Report). Los datos de campo son los que Google realmente usa para ranking, pero solo estan disponibles si tu sitio tiene suficiente trafico.
Chrome DevTools Performance: Para diagnostico profundo. Graba una carga de pagina y examina el timeline: donde estan los long tasks, que scripts bloquean el main thread, cuando ocurre el LCP.
Un resumen de las optimizaciones que mayor impacto tienen en sitios Next.js con exportacion estatica:
next/font para fuentes locales (evita requests externos)priority solo a la imagen herotransform y opacity<head># Ejemplo de configuracion Nginx para cache de assets
location /_next/static/ {
expires 365d;
add_header Cache-Control "public, immutable";
}
location /images/ {
expires 30d;
add_header Cache-Control "public";
}
Las Core Web Vitals no son una lista arbitraria de metricas. Representan lo que los usuarios sienten cuando visitan tu sitio: la velocidad con que ven contenido (LCP), la estabilidad visual mientras carga (CLS), y la capacidad de respuesta cuando interactuan (INP).
Con Next.js y exportacion estatica tienes una base solida. Las optimizaciones descritas en este articulo no requieren herramientas complejas ni refactorizaciones masivas: son decisiones puntuales (que prop agregar, que componente mantener en el servidor, como cargar fuentes) que acumulan un impacto significativo en la puntuacion final. Mide, optimiza, y vuelve a medir.