Core Web Vitals (CWV) son un conjunto estandarizado de métricas de experiencia de usuario en el mundo real (Real User Experience) definido por Google. Desde su integración en los algoritmos de clasificación de Page Experience, estos indicadores impactan directamente en el tráfico orgánico y en las tasas de conversión comercial.
En la ingeniería web contemporánea, la medición del rendimiento se divide en dos categorías fundamentales:
Next.js App Router fue diseñado específicamente en torno a la optimización de las Core Web Vitals: los React Server Components eliminan el JavaScript innecesario del bundle del cliente, el streaming SSR reduce el TTFB (Time to First Byte), y componentes como next/image y next/font neutralizan los cambios inesperados de diseño. Sin embargo, una arquitectura deficiente de los componentes cliente puede anular por completo estas ventajas nativas.
El servidor de Next.js envía el primer fragmento HTML con metadatos y enlaces de precarga (preload) en cuestión de milisegundos.
El navegador procesa los enlaces preload para la imagen principal y las fuentes críticas antes de que termine el análisis del documento.
El espacio para imágenes, fuentes y módulos dinámicos se reserva con anticipación mediante aspect-ratio y ajustes de size-adjust.
Los Server Components eliminan el JS superfluo. Las operaciones pesadas en clientes se envuelven en startTransition para evitar Long Tasks.
Los datos empíricos de rendimiento se transmiten de forma asíncrona mediante useReportWebVitals y sendBeacon sin sobrecargar la CPU.
useReportWebVitals#Next.js incluye el hook useReportWebVitals del módulo next/web-vitals, diseñado para capturar métricas de rendimiento en el navegador y enviarlas a tu backend analítico.
'use client';
import { useReportWebVitals, type NextWebVitalsMetric } from 'next/web-vitals';
export function WebVitalsReporter() {
useReportWebVitals((metric: NextWebVitalsMetric) => {
// Objeto estructurado de telemetría
const payload = JSON.stringify({
id: metric.id, // Identificador único de muestra para deduplicación
name: metric.name, // 'LCP' | 'INP' | 'CLS' | 'FCP' | 'TTFB'
value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value),
rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
delta: Math.round(metric.delta),
navigationType: metric.navigationType, // 'navigate' | 'reload' | 'back-forward'
});
const endpoint = '/api/analytics/vitals';
// navigator.sendBeacon garantiza la entrega incluso al cerrar la pestaña
if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
navigator.sendBeacon(endpoint, payload);
} else {
fetch(endpoint, {
body: payload,
method: 'POST',
keepalive: true,
headers: { 'Content-Type': 'application/json' },
});
}
});
return null;
}Al desplegar en la plataforma Vercel, la infraestructura manual de recopilación de telemetría puede delegarse en @vercel/speed-insights/next. Se integra en el layout.tsx raíz con una sola línea:
import { SpeedInsights } from '@vercel/speed-insights/next';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="es">
<body>
{children}
{/* Supervisa automáticamente LCP, INP y CLS desglosado por rutas y geografías */}
<SpeedInsights />
</body>
</html>
);
}El elemento LCP situado en la parte superior de la página (como un banner hero o un encabezado H1) debe transmitirse directamente dentro del payload inicial de HTML generado por el servidor, sin depender de la descarga de bundles de cliente ni de cadenas de peticiones secundarias.
import Image from 'next/image';
interface HeroBannerProps {
title: string;
imageSrc: string;
}
export function HeroBanner({ title, imageSrc }: HeroBannerProps) {
return (
<section className="relative w-full h-[400px] md:h-[560px] overflow-hidden rounded-2xl bg-bg-surface">
<Image
src={imageSrc}
alt={title}
fill
sizes="(max-width: 768px) 100vw, 1200px"
// priority inyecta una etiqueta <link rel="preload"> en el <head> del documento HTML
priority
className="object-cover"
quality={80}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent flex items-end p-8">
<h1 className="text-3xl md:text-5xl font-extrabold text-white tracking-tight">
{title}
</h1>
</div>
</section>
);
}Al filtrar un catálogo de 5.000 artículos, el usuario escribe en un campo de búsqueda. El hilo principal del navegador se bloquea durante 180 ms debido a un bucle síncrono de JavaScript. ¿Por qué esto degrada el INP y cómo lo soluciona React 19?
startTransition)#Cuando un componente cliente ejecuta cálculos pesados de filtrado en respuesta a la escritura de texto, una actualización de estado síncrona genera una Long Task (> 50 ms), congelando la pantalla.
'use client';
import React, { useState, useTransition } from 'react';
interface SearchFilterProps {
items: string[];
}
export function SearchFilter({ items }: SearchFilterProps) {
const [query, setQuery] = useState('');
const [filteredList, setFilteredList] = useState(items);
const [isPending, startTransition] = useTransition();
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
// 1. Actualización urgente: el texto del input se actualiza al instante (Input Delay = 0)
setQuery(value);
// 2. Transición no urgente: el filtrado computacional se ejecuta en segundo plano en React 19
startTransition(() => {
const filtered = items.filter((item) =>
item.toLowerCase().includes(value.toLowerCase())
);
setFilteredList(filtered);
});
};
return (
<div className="space-y-4">
<input
type="text"
value={query}
onChange={handleSearch}
placeholder="Buscar lecciones..."
className="w-full px-4 py-2 rounded-lg border border-border bg-bg-surface text-text-primary"
/>
{isPending && <div className="text-xs text-text-muted animate-pulse">Actualizando resultados...</div>}
<ul className="divide-y divide-border">
{filteredList.map((item, idx) => (
<li key={idx} className="py-2 text-sm text-text-primary">{item}</li>
))}
</ul>
</div>
);
}Los elementos dinámicos de la interfaz (anuncios, widgets, listas cargadas de forma asíncrona) deben reservar dimensiones geométricas explícitas en el DOM antes de que concluya la petición de red.
import React from 'react';
interface DynamicSlotProps {
children?: React.ReactNode;
isLoading: boolean;
}
export function DynamicSlot({ children, isLoading }: DynamicSlotProps) {
// Las propiedades fijas aspect-ratio y min-height garantizan un CLS nulo
return (
<div className="w-full min-h-[250px] aspect-video md:aspect-[21/9] rounded-xl border border-border bg-bg-surface overflow-hidden relative">
{isLoading ? (
<div className="absolute inset-0 animate-pulse bg-bg-elevated flex items-center justify-center">
<span className="text-xs text-text-muted font-mono">Cargando módulo de contenido...</span>
</div>
) : (
children
)}
</div>
);
}Un equipo de desarrollo envuelve su banner hero principal en next/dynamic({ ssr: false }) y obtiene la URL de la imagen dentro de un useEffect() en el cliente. ¿Por qué el LCP de la página cae en la 'zona roja' (> 4.0 segundos)?
Ejecutar un await db.query() o un fetch() no cacheado en app/layout.tsx sin fronteras de Suspense bloquea el streaming de HTML en Next.js. El navegador espera la respuesta del servidor durante segundos, empeorando de forma crítica el TTFB y el LCP.
Solución: Traslada las consultas dinámicas a componentes anidados envueltos en <Suspense fallback={<HeaderSkeleton />}>, o utiliza Partial Prerendering (PPR) para emitir la estructura estática de inmediato.
Ejecutar filtrados complejos de grandes volúmenes de datos directamente en controladores de eventos onChange o clics bloquea el hilo principal durante 100–300 ms. El navegador no puede renderizar el siguiente fotograma, provocando caídas críticas en INP.
Solución: Envuelve las transiciones de estado no urgentes con startTransition de React 19: startTransition(() => setFilteredItems(...)).
Insertar anuncios, widgets o listas de datos variables en el DOM sin reservar su espacio previamente desplaza el contenido adyacente hacia abajo en el momento en que se completa la carga.
Solución: Emplea la propiedad CSS aspect-ratio o clases de altura fija (min-h-[250px]) junto con esqueletos de carga.
Cargar la URL del banner principal mediante useEffect obliga al navegador a descargar primero los bundles de JavaScript del cliente, hidratar los componentes y realizar peticiones adicionales antes de iniciar la descarga de la imagen.
Solución: Renderiza las imágenes críticas de cabecera directamente en Server Components utilizando <Image src="..." priority ... />.
Aplicar división de código (next/dynamic({ ssr: false })) al banner principal retrasa su aparición hasta que se descargue e hidrate dicho fragmento independiente.
Solución: Nunca utilices carga diferida en componentes que contengan el elemento LCP principal.
Integrar bibliotecas externas con la etiqueta <script> habitual sin atributos defer o async detiene el análisis del árbol HTML.
Solución: Utiliza siempre next/script configurado con las estrategias strategy="afterInteractive" o strategy="lazyOnload".