← Volver al blog
·10 min de lectura

Optimizar Core Web Vitals en sitios Next.js con exportacion estatica

Next.jsPerformance

Que son los Core Web Vitals y por que importan

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.

  • Largest Contentful Paint (LCP): Mide cuanto tarda en renderizarse el elemento mas grande visible en el viewport. Puede ser una imagen hero, un bloque de texto grande, o un video. El umbral bueno es menos de 2.5 segundos.
  • Interaction to Next Paint (INP): Reemplazo de First Input Delay desde marzo 2024. Mide la latencia entre una interaccion del usuario (click, tap, tecla) y la siguiente actualizacion visual del navegador. El umbral bueno es menos de 200 milisegundos.
  • Cumulative Layout Shift (CLS): Mide cuanto se mueven los elementos visibles durante la carga. Esos momentos donde vas a hacer click en un boton y de repente se desplaza porque cargo un banner arriba. El umbral bueno es menos de 0.1.

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.

LCP: El elemento mas grande manda

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.

Diagnosticar el LCP

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.

Priorizar la imagen hero

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.

Preconnect a origenes externos

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.

Formato de imagen WebP/AVIF

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.

Fuentes: El enemigo silencioso del rendimiento

Las fuentes web son una de las causas mas comunes de problemas de rendimiento, y generan dos fenomenos visibles:

  • FOUT (Flash of Unstyled Text): El texto se muestra con la fuente del sistema y luego "salta" a la fuente personalizada.
  • FOIT (Flash of Invisible Text): El texto es invisible hasta que la fuente carga.

Ambos causan CLS (por el cambio de dimensiones del texto) y potencialmente afectan LCP (si el LCP es un bloque de texto).

next/font elimina estos problemas

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

  1. Cero requests a Google Fonts en runtime: Las fuentes se sirven desde tu propio dominio, eliminando el preconnect + descarga externa.
  2. font-display: swap: El texto se muestra inmediatamente con una fuente del sistema y transiciona a la personalizada cuando esta lista. Esto elimina FOIT.
  3. 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.

Reducir el peso de las fuentes

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.

CLS: Evitar movimientos inesperados

El CLS es la metrica que mas frustra a los usuarios y la mas facil de arruinar sin darte cuenta.

Reservar espacio para imagenes

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>

El problema de los anuncios dinamicos

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.

Animaciones que causan CLS

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.

INP: Minimizar JavaScript en el cliente

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.

Auditar Client Components

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.

Dynamic imports para componentes pesados

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.

Evitar hidratacion innecesaria

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.

Medir y verificar

Optimizar sin medir es adivinar. Usa estas herramientas en orden:

  1. 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.

  2. 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.

  3. 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.

Checklist rapido para Lighthouse 90+

Un resumen de las optimizaciones que mayor impacto tienen en sitios Next.js con exportacion estatica:

  • Usar next/font para fuentes locales (evita requests externos)
  • Aplicar priority solo a la imagen hero
  • Servir imagenes en WebP, con dimensiones explicitas
  • Mantener la mayoria de componentes como Server Components
  • Reservar espacio para contenido dinamico (ads, embeds)
  • Animar solo con transform y opacity
  • Implementar el tema con un script inline en el <head>
  • Cachear archivos estaticos en Nginx con headers agresivos
# 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";
}

Conclusion

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.