Módulo 20: Rendimiento
Objetivos del módulo
- Saber qué miden las Core Web Vitals (LCP, INP, CLS) y las métricas que las acompañan (FCP, TTFB), con sus umbrales, y qué decisiones de Next.js afectan a cada una
- Medir en producción y en el laboratorio:
useReportWebVitalsenviando las métricas a tu propio servidor, y Playwright con la red y la CPU limitadas - Mejorar el LCP con
next/imageypreload, y evitar el CLS que provocan los fallbacks de<Suspense>mal dimensionados - Averiguar qué JavaScript descarga cada ruta y por qué, con el analizador de paquetes de Turbopack (
next analyze) y su exportación a JSON - Usar
next/dynamicsabiendo lo que de verdad ahorra (y lo que no), y mover trabajo a Server Components para que no viaje al navegador - Activar el React Compiler en Next.js, en su versión Babel y en la nativa de Rust, y medir su efecto en el INP y en el tiempo de build
- Aprovechar la caché en disco de Turbopack en desarrollo y en
build - Saber qué cambia en todo esto si sigues en Next.js 15
💡 Consejo: Todas las cifras de este módulo son reales: se han obtenido con
next buildynext starten Next.js 16.4.3, midiendo con Chromium de Playwright 1.64 con la red limitada a 1,6 Mbit/s y 150 ms de latencia y la CPU cuatro veces más lenta. Los datos salen de la base SQLite con Drizzle del módulo 16, y el informe lento, de la API EMPRESA conRETARDO=1000. Tus números serán otros (dependen de tu equipo), pero las diferencias entre una versión y otra del código se mantienen. Las diferencias con Next.js 15 se han comprobado en Next.js 15.5.28. Usa siempre el último parche de Next.js 16.4.
📘 Concepto: La regla de oro de este módulo: primero se mide, después se optimiza, y después se vuelve a medir. Verás varios casos en los que la optimización “evidente” no cambia nada, o empeora otra métrica. Sin medir, no lo habrías sabido.
1. Qué significa que una web sea rápida
“Mi web va lenta” no es algo que se pueda arreglar: hay que decir qué va lento. ¿Tarda en aparecer algo en pantalla? ¿Aparece pronto, pero la foto principal llega tarde? ¿Se ve todo, pero al pulsar un botón la página se congela? ¿Los elementos saltan mientras lees? Cada una de esas sensaciones tiene su métrica, su causa y su arreglo.
Google agrupó esas sensaciones en un conjunto de métricas llamadas Web Vitals. Tres de ellas son las Core Web Vitals (las “vitales básicas”), las que Google usa como señal de calidad en su buscador y las que verás en Search Console y en PageSpeed Insights:
Figura 1. Las métricas que entrega useReportWebVitals y sus umbrales. Los valores se han leído del código de la biblioteca web-vitals que incluye Next.js 16.4 ([2500,4e3], [200,500], [.1,.25], [1800,3e3], [800,1800]).
| Métrica | Pregunta que responde | Qué la empeora en una app Next.js |
|---|---|---|
| LCP (Largest Contentful Paint) | ¿Cuándo veo lo importante? | Imágenes enormes o descubiertas tarde, datos lentos sin streaming, CSS y JS que compiten con la imagen |
| INP (Interaction to Next Paint) | ¿Responde cuando la toco? | Renders costosos en Client Components, demasiado JS ejecutándose a la vez |
| CLS (Cumulative Layout Shift) | ¿Se queda quieta? | Imágenes sin dimensiones, fallbacks de <Suspense> con otro alto que el contenido, fuentes que cambian de tamaño |
| FCP (First Contentful Paint) | ¿Cuándo aparece algo? | CSS y fuentes que bloquean el pintado, TTFB alto |
| TTFB (Time to First Byte) | ¿Cuándo empieza a responder el servidor? | Datos sin caché fuera de <Suspense>, servidor lejano, renderizado lento |
Tres ideas para leer bien la tabla:
- Los umbrales se aplican al percentil 75. Una web “aprueba” el LCP si el 75 % de las visitas reales lo tienen en 2,5 s o menos. Que en tu portátil con fibra salga 400 ms no dice nada de lo que ve un usuario con un móvil de gama baja en el tren.
- INP es la peor interacción (casi): mide el tiempo desde que el usuario pulsa o teclea hasta que el navegador pinta la respuesta, y se queda con la interacción más lenta de la visita (descartando alguna si hay muchísimas). Un solo
onChangelento lo estropea. - CLS no tiene unidades. Suma cuánto se desplazan los elementos visibles (fracción de la pantalla afectada por distancia recorrida). Los desplazamientos que ocurren justo después de que el usuario pulse algo no cuentan: si abres un acordeón y el contenido baja, es lo esperado.
1.1. Laboratorio frente a campo
Hay dos formas de obtener estas cifras, y las dos hacen falta:
| Datos de laboratorio | Datos de campo (RUM, Real User Monitoring) | |
|---|---|---|
| Quién mide | Tú, con Lighthouse, DevTools o Playwright | El navegador de cada visitante |
| Condiciones | Controladas y repetibles (red y CPU simuladas) | Las reales: todos los móviles, todas las redes |
| Para qué sirve | Comparar dos versiones del código, depurar | Saber qué viven tus usuarios, priorizar |
| INP | Solo si simulas interacciones | Sí, con las interacciones reales |
| En este módulo | Apartado 4, con Playwright | Apartado 3, con useReportWebVitals |
⚠️ Importante: Mide siempre una compilación de producción (
next buildynext start).next devno minimiza el código, compila cada ruta la primera vez que la visitas, añade las herramientas de desarrollo y ejecuta los componentes dos veces en modo estricto. Las cifras denext devno sirven para nada de lo que hay en este módulo.
2. Qué hace Next.js por ti sin que lo pidas
Antes de optimizar nada, conviene saber de qué parte ya se encarga el framework, para no repetirlo ni estropearlo:
| Optimización automática | Qué hace | Dónde la viste |
|---|---|---|
| Server Components | Su código no llega al navegador: solo su HTML y su payload RSC | Módulo 05 |
| División de código por ruta | Cada página descarga solo los Client Components que usa | Apartado 7 de este módulo |
| Prerenderizado y shell estático | El HTML de las partes estáticas se genera en el build y se sirve al instante | Módulos 02 y 09 |
Streaming con <Suspense> | El navegador recibe y pinta el armazón sin esperar a los datos lentos | Módulo 08 |
Precarga de rutas en <Link> | Al ver un enlace, se piden por adelantado los segmentos de la ruta | Módulos 03 y 15 |
next/image | Redimensiona, convierte a WebP/AVIF, carga diferida y reserva el hueco | Módulo 07 |
next/font | Sirve la fuente desde tu dominio y ajusta una fuente de reserva del mismo tamaño | Módulo 06 |
| Compresión | next start comprime las respuestas con gzip |
Lo que no puede hacer por ti: decidir qué componente es de cliente y cuál de servidor, elegir bibliotecas ligeras, escribir componentes que no hagan trabajo de más al renderizar, ni dimensionar bien un fallback. De eso trata el resto del módulo.
3. Medir en el campo: useReportWebVitals
Next.js incluye la biblioteca web-vitals de Google y la expone con un hook, useReportWebVitals de next/web-vitals. Le pasas una función, y Next la llama con cada métrica cuando está disponible.
3.1. El componente que recoge las métricas
Como es un hook, solo funciona en un Client Component. La documentación recomienda aislarlo en un componente propio para que la frontera de cliente sea lo más pequeña posible:
// app/_componentes/web-vitals.tsx
'use client'
import { useReportWebVitals } from 'next/web-vitals'
type Metrica = Parameters<Parameters<typeof useReportWebVitals>[0]>[0]
// Fuera del componente: la referencia no cambia entre renders,
// así que Next no vuelve a entregar las métricas ya enviadas
function enviarMetrica(metrica: Metrica) {
const cuerpo = JSON.stringify({
nombre: metrica.name,
valor: Math.round(metrica.name === 'CLS' ? metrica.value * 1000 : metrica.value),
nota: metrica.rating,
id: metrica.id,
navegacion: metrica.navigationType,
ruta: window.location.pathname,
})
// sendBeacon sobrevive al cierre de la pestaña; fetch con keepalive como respaldo
if (!navigator.sendBeacon?.('/api/vitals', cuerpo)) {
fetch('/api/vitals', { method: 'POST', body: cuerpo, keepalive: true })
}
// Solo para las pruebas automáticas
const w = window as typeof window & { __metricas?: unknown[] }
;(w.__metricas ??= []).push(JSON.parse(cuerpo))
}
export function WebVitals() {
useReportWebVitals(enviarMetrica)
return null
}
Y se monta una sola vez, en el layout raíz:
// app/layout.tsx
import type { Metadata } from 'next'
import { Geist } from 'next/font/google'
import Link from 'next/link'
import { WebVitals } from './_componentes/web-vitals'
import './globals.css'
const geist = Geist({ variable: '--font-geist-sans', subsets: ['latin'] })
export const metadata: Metadata = {
title: { default: 'EMPRESA', template: '%s | EMPRESA' },
description: 'Tienda y gestión de EMPRESA',
}
export default function RootLayout({ children }: LayoutProps<'/'>) {
return (
<html lang="es" className={`${geist.variable} h-full antialiased`}>
<body className="min-h-full flex flex-col font-sans">
<WebVitals />
<nav className="flex gap-4 bg-indigo-900 px-6 py-3 text-sm text-white">
<Link href="/">Inicio</Link>
<Link href="/cwv/optimizada">Portada</Link>
<Link href="/gestion/ventas/dinamica">Ventas</Link>
<Link href="/gestion/nominas">Nóminas</Link>
</nav>
<main className="flex-1 p-6">{children}</main>
</body>
</html>
)
}
Detalles que importan:
Parameters<Parameters<typeof useReportWebVitals>[0]>[0]saca el tipo de la métrica del propio hook. Next no exporta un tipoMetricpúblico desdenext/web-vitals, y así no dependes de rutas internas.- La función está fuera del componente. La documentación lo advierte: si la referencia cambia, Next vuelve a llamarla con todas las métricas acumuladas, y las enviarías duplicadas. Una flecha definida dentro de
WebVitalssería una función nueva en cada render. - CLS se multiplica por 1000 antes de redondear: si no, un 0,047 se convierte en 0. Es lo mismo que hace la documentación con Google Analytics, que solo admite enteros.
navigator.sendBeacones la forma correcta de enviar analítica: el navegador garantiza el envío aunque el usuario cierre la pestaña, y no retrasa la navegación. Devuelvefalsesi no puede encolar el envío; entonces usamosfetchconkeepalive: true, que tiene la misma garantía.ratingya viene calculado (good,needs-improvement,poor) con los umbrales de la figura 1.
3.2. El Route Handler que las recibe
// app/api/vitals/route.ts
type MetricaRecibida = {
nombre: string
valor: number
nota: string
navegacion: string
ruta: string
}
export async function POST(request: Request) {
// sendBeacon envía text/plain: leemos el texto y lo convertimos
const metrica = JSON.parse(await request.text()) as MetricaRecibida
console.log(
`[vitals] ${metrica.ruta} ${metrica.nombre}=${metrica.valor} (${metrica.nota}, ${metrica.navegacion})`,
)
// En producción: guardarlo en la base de datos o reenviarlo a tu servicio de analítica
return new Response(null, { status: 204 })
}
sendBeacon con una cadena envía Content-Type: text/plain, así que request.json() funcionaría, pero leer el texto deja claro que no confiamos en la cabecera. En una aplicación real validarías el cuerpo con Zod (módulo 11) y lo guardarías en una tabla de la base de datos, o lo reenviarías a un servicio de analítica.
Este es el registro real de next start mientras Playwright visitaba las páginas del laboratorio:
[vitals] / FCP=1028 (good, navigate)
[vitals] / TTFB=154 (good, navigate)
[vitals] /ayuda/servidor FCP=344 (good, navigate)
[vitals] /ayuda/servidor TTFB=49 (good, navigate)
[vitals] /gestion/ventas/estatica FCP=192 (good, navigate)
[vitals] /gestion/ventas/estatica TTFB=36 (good, navigate)
[vitals] /gestion/ventas/dinamica FCP=448 (good, navigate)
[vitals] /gestion/ventas/dinamica TTFB=15 (good, navigate)
3.3. Cuándo llega cada métrica
Fíjate en que en ese registro solo aparecen FCP y TTFB. No es un error: cada métrica se entrega cuando su valor es definitivo:
| Métrica | Cuándo la entrega web-vitals |
|---|---|
| TTFB, FCP | En cuanto se producen, al cargar |
| LCP | Cuando el usuario interactúa por primera vez (tecla, clic) o la página pasa a segundo plano: hasta entonces podría aparecer un elemento más grande |
| CLS, INP | Cuando la página pasa a segundo plano (cambio de pestaña, cierre, navegación fuera): hasta entonces puede empeorar |
Por eso el envío tiene que sobrevivir al cierre de la pestaña, y por eso sendBeacon. En las pruebas automáticas del apartado 4, el script pulsa una tecla y simula que la pestaña se oculta para forzar la entrega. Con eso, las métricas que recibe el servidor son estas (una visita a la portada optimizada):
useReportWebVitals: FCP=2072 TTFB=38 LCP=7460 INP=40 CLS=0
El INP=40 es la pulsación de tecla del propio script.
💡 Consejo: La copia de
web-vitalsque incluye Next.js 16.4 conoce las navegaciones suaves del App Router (navigationType: 'soft-navigation'y los camposnavigationIdynavigationURLque describe la documentación deuseReportWebVitals). La de Next.js 15.5 no incluye esos campos. Si sigues en 15, cada métrica tienename,value,rating,idynavigationType, que es todo lo que usa el código de arriba.
3.4. Y si ya tengo Vercel o Google Analytics
useReportWebVitals es la pieza de bajo nivel. Si despliegas en Vercel, su paquete de analítica recoge lo mismo sin escribir nada. Si usas Google Analytics, la documentación de useReportWebVitals trae un ejemplo con window.gtag. Lo que has escrito aquí te sirve para cualquier otro destino: tu propia base de datos, un servicio de observabilidad o un simple fichero de registro.
⚠️ Importante: No envíes cada métrica a una API externa desde el navegador sin pensarlo: son cinco o más peticiones por visita. Con
sendBeacona tu propio Route Handler no bloqueas nada, y desde ahí puedes agrupar, muestrear (guardar solo el 10 % de las visitas) o descartar.
4. Medir en el laboratorio con Playwright
Lighthouse (en la pestaña Lighthouse de Chrome DevTools) es la forma más rápida de obtener un informe. Pero para comparar dos versiones del código necesitas algo repetible, que se pueda lanzar desde la terminal y que dé siempre las mismas condiciones. Playwright ya está en la pila del curso (lo usarás a fondo en el módulo 21) y, a través del protocolo de DevTools, permite limitar la red y la CPU.
4.1. El script de medición
// scripts/medir-cwv.mjs
// Uso: node scripts/medir-cwv.mjs http://127.0.0.1:3001 /ruta1 /ruta2 ...
import { chromium } from '@playwright/test'
const [base, ...rutas] = process.argv.slice(2)
const REPETICIONES = 3
const navegador = await chromium.launch()
// Se ejecuta en la página antes que cualquier otro script
function observadores() {
window.__cwv = { lcp: 0, cls: 0, lcpElemento: '' }
new PerformanceObserver((lista) => {
for (const e of lista.getEntries()) {
window.__cwv.lcp = e.startTime
window.__cwv.lcpElemento = e.element ? e.element.tagName + (e.element.textContent ? ': ' + e.element.textContent.slice(0, 30) : '') : '?'
}
}).observe({ type: 'largest-contentful-paint', buffered: true })
new PerformanceObserver((lista) => {
for (const e of lista.getEntries()) if (!e.hadRecentInput) window.__cwv.cls += e.value
}).observe({ type: 'layout-shift', buffered: true })
}
async function medir(ruta) {
const contexto = await navegador.newContext({ viewport: { width: 1280, height: 800 } })
const pagina = await contexto.newPage()
const cdp = await contexto.newCDPSession(pagina)
// Red "4G lenta": 150 ms de latencia y 1,6 Mbit/s de bajada
await cdp.send('Network.enable')
await cdp.send('Network.emulateNetworkConditions', {
offline: false,
latency: 150,
downloadThroughput: (1.6 * 1024 * 1024) / 8,
uploadThroughput: (750 * 1024) / 8,
})
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 })
await pagina.addInitScript(observadores)
await pagina.goto(base + ruta, { waitUntil: 'load' })
await pagina.waitForTimeout(3000)
const r = await pagina.evaluate(() => {
const nav = performance.getEntriesByType('navigation')[0]
const fcp = performance.getEntriesByName('first-contentful-paint')[0]
return {
ttfb: nav.responseStart,
fcp: fcp ? fcp.startTime : 0,
lcp: window.__cwv.lcp,
cls: window.__cwv.cls,
lcpElemento: window.__cwv.lcpElemento,
}
})
// Una tecla cierra la medición del LCP; después simulamos que el usuario
// cambia de pestaña, y web-vitals envía LCP y CLS
await pagina.keyboard.press('Shift')
await pagina.evaluate(() => {
Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true })
document.dispatchEvent(new Event('visibilitychange'))
})
await pagina.waitForTimeout(300)
r.reportado = await pagina.evaluate(() => (window.__metricas ?? []).map((m) => `${m.nombre}=${m.valor}`).join(' '))
await contexto.close()
return r
}
const mediana = (v) => v.sort((a, b) => a - b)[Math.floor(v.length / 2)]
for (const ruta of rutas) {
await fetch(base + ruta) // calentamiento: la primera petición no cuenta
const datos = []
for (let i = 0; i < REPETICIONES; i++) datos.push(await medir(ruta))
const m = (k) => mediana(datos.map((d) => d[k]))
console.log(
`${ruta.padEnd(22)} TTFB ${m('ttfb').toFixed(0)} ms | FCP ${m('fcp').toFixed(0)} ms | LCP ${m('lcp').toFixed(0)} ms (${datos[0].lcpElemento}) | CLS ${m('cls').toFixed(3)}`,
)
console.log(`${''.padEnd(22)} useReportWebVitals: ${datos[0].reportado}`)
}
await navegador.close()
Cómo funciona:
PerformanceObserveres la API del navegador de la que bebeweb-vitals. Contype: 'largest-contentful-paint'recibe cada candidato a LCP (el último es el bueno); contype: 'layout-shift'recibe cada desplazamiento, y sumamos los que no siguen a una acción del usuario (hadRecentInput).buffered: trueentrega también lo que ocurrió antes de crear el observador.addInitScriptinyecta los observadores antes de que la página ejecute nada, así no se pierde ningún evento.newCDPSessionabre el protocolo de DevTools de Chromium.Network.emulateNetworkConditionsyEmulation.setCPUThrottlingRateson lo mismo que eliges en la pestaña Network y Performance de DevTools.- Tres repeticiones y la mediana: una sola medición es ruido. La primera petición a una ruta (que puede llenar cachés del servidor) se descarta con un
fetchprevio.
Lo lanzas contra tu compilación de producción:
npx next build
npx next start -p 3001
# en otra terminal
node scripts/medir-cwv.mjs http://127.0.0.1:3001 /cwv/sin-optimizar /cwv/optimizada /informe /informe/esqueleto
Los apartados 5 y 6 explican lo que mide en cada una de esas rutas.
5. LCP: la imagen principal
5.1. La portada sin optimizar
La portada de la tienda EMPRESA abre con una foto grande (public/portada.jpg, 1920 × 900 píxeles, 888 KB en JPEG) y debajo el catálogo, que sale de la base de datos con 'use cache' (función listarCatalogo del módulo 16):
// app/cwv/_componentes/lista-catalogo.tsx
import { listarCatalogo } from '@/lib/db/catalogo'
const euros = new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' })
export async function ListaCatalogo() {
const productos = await listarCatalogo()
return (
<ul className="grid grid-cols-2 gap-3 md:grid-cols-4">
{productos.map((p) => (
<li key={p.productoId} className="rounded border border-indigo-200 p-3">
<p className="text-xs text-gray-500">{p.categoria}</p>
<p className="font-semibold">{p.nombre}</p>
<p>{euros.format(p.precio)}</p>
</li>
))}
</ul>
)
}
La primera versión usa una etiqueta <img> normal, como haría cualquiera que viene de HTML:
// app/cwv/sin-optimizar/page.tsx
import { ListaCatalogo } from '../_componentes/lista-catalogo'
export const metadata = { title: 'Portada sin optimizar' }
export default function PortadaSinOptimizar() {
return (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/portada.jpg" alt="Tienda EMPRESA" className="w-full" />
<h1 className="my-4 text-2xl font-bold">Novedades de la tienda</h1>
<ListaCatalogo />
</>
)
}
La regla @next/next/no-img-element de ESLint protesta, con razón; aquí la desactivamos a propósito para tener con qué comparar.
5.2. La portada con next/image
// app/cwv/optimizada/page.tsx
import Image from 'next/image'
import portada from '@/public/portada.jpg'
import { ListaCatalogo } from '../_componentes/lista-catalogo'
export const metadata = { title: 'Portada optimizada' }
export default function PortadaOptimizada() {
return (
<>
<Image
src={portada}
alt="Tienda EMPRESA"
preload
placeholder="blur"
sizes="(max-width: 1280px) 100vw, 1280px"
className="h-auto w-full"
/>
<h1 className="my-4 text-2xl font-bold">Novedades de la tienda</h1>
<ListaCatalogo />
</>
)
}
Cuatro decisiones, cada una con su efecto:
- Importación estática (
import portada from '@/public/portada.jpg'): Next lee el ancho y el alto en el build, así que el<img>sale conwidthyheighty el navegador reserva el hueco antes de descargar nada. Además genera elblurDataURLparaplaceholder="blur". sizes: le dice al navegador qué ancho ocupará la imagen, para que elija delsrcsetla versión más pequeña que sirva. Sinsizes, Next genera unsrcsetpensado para pantallas de densidad 1x y 2x del ancho original.preload: añade un<link rel="preload">en el<head>para que la descarga empiece en cuanto llega el HTML, sin esperar a que el navegador encuentre el<img>. Es para la imagen que será el LCP, y para ninguna más.- El optimizador de imágenes (
/_next/image) convierte a WebP (o AVIF, si lo activas) y redimensiona. Con una ventana de 1280 píxeles, el navegador elige delsrcsetla versión de 1920 de ancho; pedida concurlal servidor del laboratorio:
w=1920 image/webp 101977 bytes 1,053986s <- primera petición: el optimizador la genera
w=1920 image/webp 101978 bytes 0,009098s <- segunda: sale de .next/cache/images
De 888 KB de JPEG a 100 KB de WebP: casi nueve veces menos que descargar, sin tocar la foto original.
5.3. Lo que dice la medición
| Ruta | TTFB | FCP | LCP | CLS |
|---|---|---|---|---|
/cwv/sin-optimizar (<img>) | 10 ms | 1264 ms | 5780 ms (poor) | 0,000 |
/cwv/optimizada (next/image + preload) | 17 ms | 2072 ms | 2128 ms (good) | 0,000 |
Medianas de tres cargas con la red y la CPU limitadas (apartado 4).
El LCP baja de 5,8 s a 2,1 s, de “malo” a “bueno”: con 1,6 Mbit/s, descargar 888 KB de JPEG tarda más de cuatro segundos, y la versión redimensionada en WebP es mucho más ligera. Pero fíjate en dos cosas que no son evidentes:
- El FCP empeora (de 1264 a 2072 ms). El
preloadhace que la imagen empiece a descargarse a la vez que el CSS y el JavaScript, y con un ancho de banda tan pequeño compiten. El primer texto tarda más en aparecer, pero lo importante (la foto) llega casi tres segundos antes. Es un intercambio razonable en una portada; en una página donde la imagen no sea lo principal, no lo sería. Por esopreloadsolo va en una imagen. - El CLS es 0 en las dos. El
<img>sin dimensiones no provocó desplazamientos en esta medición, porque el navegador leyó la cabecera del JPEG (donde vienen el ancho y el alto) antes del primer pintado: los primeros bytes de la imagen llegaron antes de que el CSS dejara pintar. En otras condiciones (una imagen en otro dominio, más abajo en la página o cargada después) sí desplaza el contenido. Que no lo hayas visto en una medición no significa que no pase en el campo:next/imagelo evita siempre.
⚠️ Importante: En una de las tres cargas,
useReportWebVitalsinformó deLCP=7460. Era la primera petición de esa imagen al optimizador:next startgenera la versión WebP la primera vez que se pide y la guarda en.next/cache/images. Las siguientes salen de la caché. Si despliegas en varios servidores sin compartir esa carpeta, cada uno paga ese primer acceso. Lo verás en el módulo 23.
5.4. priority ahora es preload 🔄 16.0
🔄 Cambia en Next.js 16.0. En Next.js 15 la propiedad para precargar la imagen del LCP es
priority; en 16 se llamapreloadypriorityqueda obsoleta. Si usas Next.js 15: escribe<Image priority ... />, que hace lo mismo (añade el<link rel="preload">y quita la carga diferida).
La documentación de la 16 añade un matiz: si la imagen que será el LCP cambia según el tamaño de pantalla (una para móvil y otra para escritorio), no uses preload, porque precargarías las dos; usa fetchPriority="high" o loading="eager" en la que corresponda.
6. CLS: lo que hace saltar la página
6.1. El fallback de <Suspense> también ocupa sitio
En Next.js el CLS tiene una causa propia que no existe en una SPA: el streaming. El servidor envía el armazón con el fallback de cada <Suspense>, y cuando llegan los datos lo sustituye por el contenido. Si el fallback y el contenido no miden lo mismo, todo lo que hay debajo salta.
El informe de plantilla pide sus datos a la API EMPRESA, que en el laboratorio tarda un segundo:
// app/informe/_componentes/tabla-departamentos.tsx
const API = process.env.API_EMPRESA ?? 'http://localhost:3000/api'
type Estadisticas = {
porDepartamento: { deptId: number; nombre: string; empleados: number; masaSalarial: number }[]
}
// Sin caché: cada petición pregunta a la API, que tarda 1 s
export async function TablaDepartamentos() {
const res = await fetch(`${API}/estadisticas`, { cache: 'no-store' })
const datos = (await res.json()) as Estadisticas
return (
<table className="w-full text-left text-lg">
<thead>
<tr className="h-10">
<th>Departamento</th>
<th>Empleados</th>
<th>Masa salarial</th>
</tr>
</thead>
<tbody>
{datos.porDepartamento.map((d) => (
<tr key={d.deptId} className="h-10">
<td>{d.nombre}</td>
<td>{d.empleados}</td>
<td>{d.masaSalarial.toLocaleString('es-ES')} €</td>
</tr>
))}
</tbody>
</table>
)
}
// Mismo alto que la tabla: cabecera + 7 departamentos, 40 px cada fila
export function EsqueletoTabla() {
return (
<div aria-hidden className="space-y-0">
{Array.from({ length: 8 }, (_, i) => (
<div key={i} className="flex h-10 items-center">
<div className="h-4 w-full animate-pulse rounded bg-gray-200" />
</div>
))}
</div>
)
}
export function NotasInforme() {
return (
<section className="mt-6 text-lg">
<h2 className="text-xl font-semibold">Notas</h2>
<p>La masa salarial incluye el salario base sin comisiones. Los datos se actualizan cada noche.</p>
<p>Para cualquier corrección, contacta con el departamento de Contabilidad.</p>
</section>
)
}
Primera versión, con un fallback de una línea:
// app/informe/page.tsx
import { Suspense } from 'react'
import { NotasInforme, TablaDepartamentos } from './_componentes/tabla-departamentos'
export const metadata = { title: 'Informe' }
export default function Informe() {
return (
<>
<h1 className="mb-4 text-2xl font-bold">Informe de plantilla</h1>
{/* El fallback mide una línea; la tabla, ocho: las notas saltan hacia abajo */}
<Suspense fallback={<p className="text-sm text-gray-500">Calculando informe...</p>}>
<TablaDepartamentos />
</Suspense>
<NotasInforme />
</>
)
}

Figura 2. Primer segundo: el *fallback ocupa una línea y las notas están arriba.*

Figura 3. Llegan los datos: la tabla mide ocho filas y las notas bajan unos 300 píxeles. Eso es un desplazamiento de diseño.
Segunda versión, con un esqueleto que reserva el mismo alto:
// app/informe/esqueleto/page.tsx
import { Suspense } from 'react'
import { EsqueletoTabla, NotasInforme, TablaDepartamentos } from '../_componentes/tabla-departamentos'
export const metadata = { title: 'Informe (con esqueleto)' }
export default function InformeConEsqueleto() {
return (
<>
<h1 className="mb-4 text-2xl font-bold">Informe de plantilla</h1>
{/* El esqueleto reserva el mismo alto que la tabla: nada se mueve */}
<Suspense fallback={<EsqueletoTabla />}>
<TablaDepartamentos />
</Suspense>
<NotasInforme />
</>
)
}
| Ruta | TTFB | FCP | LCP | CLS |
|---|---|---|---|---|
/informe (fallback de una línea) | 13 ms | 1064 ms | 1064 ms | 0,047 |
/informe/esqueleto (mismo alto) | 36 ms | 1160 ms | 1160 ms | 0,000 |
El CLS de 0,047 todavía está por debajo del umbral de 0,1, pero es una sola tabla en una página casi vacía. Con dos o tres límites de <Suspense> así en una página real (un panel de mando, por ejemplo) se pasa con facilidad. La regla es sencilla: el fallback debe medir lo mismo que lo que sustituye. Si no sabes cuánto medirá (una lista de longitud variable), reserva un alto mínimo razonable con min-h-*, o coloca el bloque al final, donde no empuje nada.
📘 Concepto: Fíjate también en el TTFB: 13 ms, aunque la API tarda un segundo. Es el streaming con Cache Components del módulo 08: la página es
◐ (Partial Prerender), el armazón se generó en el build y se envía al instante, y la tabla llega después por la misma respuesta. Sin<Suspense>, Next.js 16 no compilaría esta página, y en Next.js 15 el TTFB sería de más de un segundo.
6.2. Las fuentes
El otro gran causante de CLS son las fuentes web. Mientras la fuente se descarga, el navegador pinta el texto con una de reserva (Arial, por ejemplo); cuando llega la buena, el texto cambia de ancho y alto, y los párrafos se recolocan. next/font lo resuelve generando una fuente de reserva con las métricas ajustadas a la buena. Este es el CSS que genera para Geist en el laboratorio:
/* Extraído de .next/static/chunks/*.css (formateado) */
@font-face {
font-family: Geist;
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url(../media/4f6667dd39116621-s.3p2_lha1f2xer.woff2) format("woff2");
unicode-range: U+460-52F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* ... una regla @font-face por subconjunto de caracteres ... */
@font-face {
font-family: Geist Fallback;
src: local(Arial);
ascent-override: 95.94%;
descent-override: 28.16%;
line-gap-override: 0.0%;
size-adjust: 104.76%;
}
.PZ6jEG_variable { --font-geist-sans: "Geist", "Geist Fallback"; }
size-adjust, ascent-override y descent-override deforman Arial para que ocupe lo mismo que Geist. Cuando Geist llega, el texto cambia de aspecto pero no de tamaño: no hay desplazamiento. Lo viste en detalle en el módulo 06.
Además, next/font hace que la fuente empiece a descargarse cuanto antes. En Next.js 16.4 con Cache Components no lo hace con una etiqueta <link rel="preload"> en el HTML, sino con una cabecera HTTP Link en la respuesta, que el navegador lee antes de recibir el HTML:
curl -sI http://127.0.0.1:3001/ | grep -i "^link"
link: </_next/static/media/605ce01b664af129-s.p.0wgildi0cnwt9.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"
Solo se precarga el subconjunto latin que pediste en subsets. Si cargas varias fuentes o varios pesos estáticos, cada uno añade una precarga que compite con la imagen del LCP: usa fuentes variables (como Geist) y el menor número de familias posible.
6.3. Otras causas frecuentes
| Causa | Arreglo |
|---|---|
<img> o <video> sin width/height | next/image con importación estática, o width/height explícitos, o aspect-ratio en CSS |
Fallback de <Suspense> o loading.tsx con otro alto | Esqueletos del mismo tamaño |
| Banner de cookies o aviso que se inserta arriba al hidratar | Renderizarlo en el servidor, o colocarlo en posición fija (fixed) para que no empuje |
Componente con ssr: false (apartado 9) sin loading | Un loading con el mismo alto |
| Anuncios o iframes de terceros | Reservar el hueco con un contenedor de tamaño fijo |
7. El JavaScript que llega al navegador
LCP y CLS dependen sobre todo de imágenes, datos y diseño. INP depende del JavaScript: cuánto hay que descargar, analizar y ejecutar antes de que la página responda, y cuánto trabajo hace cada interacción. El primer paso es saber cuánto JavaScript descarga cada ruta.
7.1. Medirlo con Playwright
Este script visita cada ruta, suma los ficheros JavaScript que descarga y detecta qué bibliotecas van dentro buscando una cadena que solo aparece en su código:
// scripts/medir-js.mjs
// Uso: node scripts/medir-js.mjs http://127.0.0.1:3001 /ruta1 "/ruta2|Texto del botón" ...
import { chromium } from '@playwright/test'
const [base, ...rutas] = process.argv.slice(2)
const navegador = await chromium.launch()
async function medir(ruta, accion) {
const contexto = await navegador.newContext()
const pagina = await contexto.newPage()
const scripts = []
// Cancelamos la precarga de otras rutas (los <Link> visibles): solo queremos el JS de esta
await pagina.route('**/*', (r) => {
const h = r.request().headers()
return h['next-router-prefetch'] || h['next-router-segment-prefetch'] ? r.abort() : r.continue()
})
pagina.on('requestfinished', async (req) => {
if (req.resourceType() !== 'script') return
const res = await req.response()
const cuerpo = await res.body()
const tam = await req.sizes()
const texto = cuerpo.toString()
// Huellas: una cadena que solo aparece en el código de cada biblioteca
const contiene = [
texto.includes('borderSkipped') && 'chart.js',
texto.includes('input parameter is undefined or null') && 'marked',
].filter(Boolean)
scripts.push({ url: req.url(), comprimido: tam.responseBodySize, real: cuerpo.length, contiene })
})
await pagina.goto(base + ruta, { waitUntil: 'networkidle' })
const inicial = [...scripts]
let tras = null
if (accion) {
await pagina.getByRole('button', { name: accion }).click()
await pagina.waitForLoadState('networkidle')
await pagina.waitForTimeout(500)
tras = scripts.slice(inicial.length)
}
await contexto.close()
const suma = (l) => ({
ficheros: l.length,
kbComprimido: +(l.reduce((a, s) => a + s.comprimido, 0) / 1024).toFixed(1),
kbReal: +(l.reduce((a, s) => a + s.real, 0) / 1024).toFixed(1),
libs: l.flatMap((s) => s.contiene.map((c) => `${c} ${(s.comprimido / 1024).toFixed(1)} KB gzip`)).join(', ') || '-',
})
return { ruta, inicial: suma(inicial), trasAccion: tras && suma(tras) }
}
for (const r of rutas) {
const [ruta, accion] = r.split('|')
const res = await medir(ruta, accion)
console.log(
`${res.ruta.padEnd(30)} inicial: ${res.inicial.ficheros} ficheros, ${res.inicial.kbComprimido} KB gzip, ${res.inicial.kbReal} KB [${res.inicial.libs}]` +
(res.trasAccion ? ` | tras pulsar: +${res.trasAccion.ficheros} ficheros, +${res.trasAccion.kbComprimido} KB gzip, +${res.trasAccion.kbReal} KB [${res.trasAccion.libs}]` : ''),
)
}
await navegador.close()
⚠️ Importante: La primera versión de este script no cancelaba la precarga, y la página de inicio salía con 13 ficheros y 231 KB comprimidos: incluía el JavaScript de las rutas enlazadas en el menú, que
<Link>precarga en cuanto el enlace es visible. Con la precarga cancelada, la misma página descarga 6 ficheros y 139,5 KB. Al medir “lo que cuesta una ruta”, separa lo que pide la ruta de lo que se adelanta para las siguientes; las dos cosas son útiles, pero no son lo mismo.
7.2. El punto de partida: lo que pesa una página vacía
/ inicial: 6 ficheros, 139.5 KB gzip, 465.7 KB [-]
Unos 140 KB comprimidos (466 KB sin comprimir) es lo que pesa cualquier página del App Router antes de que añadas un solo Client Component: React y React DOM, el router de Next, el cliente RSC y el código de <Link>. No puedes quitarlo; es el precio de tener navegación sin recargas, precarga y Server Components. Lo que sí controlas es lo que añades encima.
7.3. Server Component o Client Component: 14 KB de diferencia
La página de ayuda de la tienda está escrita en Markdown y se convierte a HTML con la biblioteca marked. Hay dos formas de hacerlo:
// app/ayuda/servidor/page.tsx
import { marked } from 'marked'
import { GUIA_COMPRA } from '@/lib/guia'
export const metadata = { title: 'Ayuda (servidor)' }
// Server Component: marked se ejecuta en el servidor (o en el build)
// y el navegador solo recibe el HTML resultante
export default async function AyudaServidor() {
const html = await marked.parse(GUIA_COMPRA)
return <article className="space-y-3" dangerouslySetInnerHTML={{ __html: html }} />
}
// app/ayuda/cliente/guia-cliente.tsx
'use client'
import { marked } from 'marked'
import { GUIA_COMPRA } from '@/lib/guia'
// Client Component: marked viaja al navegador aunque el resultado sea HTML fijo
export function GuiaCliente() {
const html = marked.parse(GUIA_COMPRA, { async: false })
return <article className="space-y-3" dangerouslySetInnerHTML={{ __html: html }} />
}
/ayuda/servidor inicial: 6 ficheros, 139.5 KB gzip, 465.7 KB [-]
/ayuda/cliente inicial: 7 ficheros, 153.6 KB gzip, 512.1 KB [marked 14.1 KB gzip]
La versión de servidor pesa exactamente lo mismo que una página vacía: marked no existe para el navegador. La de cliente añade un fichero con marked (14,1 KB comprimidos, 46 KB sin comprimir), que además el navegador tiene que ejecutar para producir el mismo HTML que el servidor ya había enviado.
Es el ejemplo de la documentación de Next (“Heavy client workloads”): bibliotecas que solo transforman datos en interfaz (Markdown, resaltado de código, formateo de fechas complejo, generación de PDF) no tienen nada que hacer en el navegador si el resultado no depende de la interacción. Cada 'use client' que pones arriba de un fichero arrastra al navegador todo lo que ese fichero importa.
⚠️ Importante:
dangerouslySetInnerHTMLcon Markdown que escribe un usuario es una puerta abierta al XSS:markedno limpia el HTML. Aquí el texto es una constante del propio código. Si el contenido viene de usuarios, límpialo con una biblioteca como DOMPurify (en el servidor, conisomorphic-dompurify) antes de insertarlo. Lo verás en el módulo 22.
8. El analizador de paquetes de Turbopack 🆕 16.1
🆕 Desde Next.js 16.1. No existe en Next.js 15. Si usas Next.js 15: compila con webpack (
next buildsin--turbopack) y usa el plugin@next/bundle-analyzer, que genera tres informes HTML en.next/analyze/(apartado 8.5).
Medir con Playwright te dice cuánto pesa una ruta. Para saber qué hay dentro y por qué está ahí, Next.js 16.1 añadió un analizador integrado en Turbopack. A diferencia de los analizadores clásicos, que leen los ficheros finales, este trabaja con el grafo de módulos de Turbopack: sabe qué ruta importa qué módulo, si es de servidor o de cliente, y por qué cadena de import ha llegado hasta ahí.
8.1. Lanzarlo
npx next analyze
next analyze hace un análisis completo de la aplicación con Turbopack (no genera una compilación que puedas desplegar: tu .next de producción no se toca) y abre un servidor web con la interfaz, por defecto en el puerto 4000 (--port para cambiarlo).
Con --output no abre el servidor y deja el resultado en disco. Esta es la salida real en el laboratorio:
✓ Running next.config.ts took 100ms
Analyzing a production build...
✓ Analyze completed in 24.2s. Results written to C:\...\empresa\.next\diagnostics\analyze.
To explore the analyze results interactively, run `next analyze` without `--output`.
Sin --output, al terminar muestra Bundle analyzer available at http://localhost:4000 y la primera pantalla es la lista de rutas, ordenadas de más a menos pesada:

Figura 4. La lista de rutas del analizador en el laboratorio.
Las dos primeras pesan 1,15 MB porque el analizador cuenta todos los recursos de la ruta, y en ellas está la foto de portada importada con import portada from '@/public/portada.jpg' (888 KB). No es JavaScript, y el navegador nunca descarga ese JPEG original: descarga la versión WebP de 100 KB que genera next/image. Por eso conviene filtrar por tipo antes de sacar conclusiones.
Al elegir una ruta aparece el mapa de rectángulos (treemap):

Figura 5. /gestion/ventas/estatica en el analizador. Cada rectángulo es un módulo y su área es su tamaño: chart.js ocupa casi una cuarta parte de todo el JavaScript de cliente de la ruta.
En la barra superior puedes:
- Elegir la instantánea (“Latest” o una con nombre, apartado 8.2) y la ruta.
- Cambiar entre tabla y mapa (Table / Treemap).
- Cambiar entre cliente y servidor: lo que importa para el rendimiento en el navegador es el lado cliente. El lado servidor afecta al tiempo de arranque en frío (importante en serverless).
- Filtrar por tipo de fichero: JavaScript, CSS, otros recursos.
- “Initial load only”: ocultar lo que está detrás de una frontera asíncrona (un
import()o unnext/dynamicbajo demanda). Con el interruptor apagado, los módulos asíncronos aparecen rayados. - Buscar un fichero por nombre.
- Pasar el ratón o pulsar un módulo para ver su tamaño y su cadena de importación: qué fichero tuyo lo importó, y a través de qué otros módulos. Es la pregunta que siempre te haces ante un paquete grande: “¿quién ha metido esto aquí?”.
💡 Consejo: En el mapa verás
polyfill-nomodule.js(38,5 KB). Es el conjunto de polyfills para navegadores antiguos y se carga con<script nomodule>: los navegadores que Next.js 16 admite (Chrome 111+, Safari 16.4+…) lo ignoran y no lo descargan. Por eso la medición con Playwright no lo cuenta.
8.2. Guardar el análisis y compararlo 🆕 16.4
🆕 Desde Next.js 16.4.
next analyze exporty los snapshots con nombre no existen en 15 ni en 16.0 a 16.3. Si usas Next.js 15: guarda los HTML de@next/bundle-analyzerde cada versión y compáralos a mano; si usas 16.1 a 16.3, copia la carpeta.next/diagnostics/analyzecon otro nombre antes de volver a analizar.
Para comparar antes y después de un cambio, no hace falta tener la interfaz abierta:
# Analiza y guarda una instantánea con nombre, sin abrir el servidor
npx next analyze --output --snapshot antes
# Exporta esa instantánea como JSON Lines (una línea por objeto), sin volver a analizar
npx next analyze export --snapshot antes > antes.jsonl
Los datos quedan en .next/diagnostics/analyze. El formato JSON Lines está pensado para scripts (o agentes de IA: la documentación propone la skill next-bundle-optimizer).
En el laboratorio, la exportación de toda la aplicación (23 rutas) ocupa 8,5 MB y 25 364 líneas. Cada línea es un objeto JSON con un campo type:
type | Cuántas | Qué describe |
|---|---|---|
meta | 1 | Versión del formato, nombre de la instantánea, número de rutas |
route | 23 | Cada ruta y sus módulos de entrada |
module | 1831 | Cada módulo del grafo y sus dependencias (sync, async) |
group | 318 | Grupos de ficheros que se cargan juntos (arranque, carga asíncrona…) |
output | 5213 | Cada fichero generado y qué módulos contiene, por ruta |
part | 17 978 | El trozo de cada módulo dentro de cada fichero, con su tamaño |
Las líneas part son las que tienen los tamaños:
{"type":"part","route":"/gestion/ventas/bajo-demanda","filename":"[output]/.next/server/chunks/ssr/211y_chart_js_dist_chart_11kyfip3n00nw.js","source_path":"[project]/node_modules/chart.js/dist/chunks/index.js","size":28948,"compressed_size":11417}
Con eso es fácil escribir un script que sume los tamaños por paquete para una ruta y compare dos instantáneas. Lo usarás en el apartado siguiente:
// scripts/comparar-analisis.mjs
// Uso: node scripts/comparar-analisis.mjs antes.jsonl despues.jsonl /gestion/ventas/estatica
import { readFileSync } from 'node:fs'
const [ficheroA, ficheroB, ruta] = process.argv.slice(2)
// Suma el tamaño de cada paquete en el JavaScript de cliente de una ruta
function porPaquete(fichero) {
const totales = new Map()
for (const linea of readFileSync(fichero, 'utf8').split('\n')) {
if (!linea.startsWith('{"type":"part"')) continue
const parte = JSON.parse(linea)
if (parte.route !== ruta || !parte.filename.includes('/static/chunks/')) continue
if (!parte.filename.endsWith('.js')) continue
// "node_modules/chart.js/dist/..." -> "chart.js"; código propio -> "(app)"
const m = parte.source_path.match(/node_modules\/((?:@[^/]+\/)?[^/]+)/)
const paquete = m ? m[1] : '(app)'
const t = totales.get(paquete) ?? { tam: 0, gzip: 0 }
t.tam += parte.size
t.gzip += parte.compressed_size
totales.set(paquete, t)
}
return totales
}
const a = porPaquete(ficheroA)
const b = porPaquete(ficheroB)
const kb = (n) => (n / 1024).toFixed(1).padStart(7)
console.log(`Ruta ${ruta} (cliente, KB)`)
console.log('paquete'.padEnd(22), 'antes', ' gzip', ' después', ' gzip')
for (const p of [...new Set([...a.keys(), ...b.keys()])].sort()) {
const x = a.get(p) ?? { tam: 0, gzip: 0 }
const y = b.get(p) ?? { tam: 0, gzip: 0 }
if (x.tam === y.tam) continue // solo lo que cambia
console.log(p.padEnd(22), kb(x.tam), kb(x.gzip), kb(y.tam), kb(y.gzip))
}
const total = (m) => [...m.values()].reduce((s, t) => ({ tam: s.tam + t.tam, gzip: s.gzip + t.gzip }), { tam: 0, gzip: 0 })
const ta = total(a)
const tb = total(b)
console.log('TOTAL'.padEnd(22), kb(ta.tam), kb(ta.gzip), kb(tb.tam), kb(tb.gzip))
⚠️ Importante:
compressed_sizees el tamaño de cada trozo comprimido por separado. Comprimir muchos trozos pequeños por separado da más bytes que comprimir el fichero entero, así que los totales del analizador salen más altos que lo que mide la red: para/gestion/ventas/estatica, 281,7 KB “gzip” según el analizador frente a 207 KB transferidos según Playwright. Además, el analizador suma también los ficheros que la ruta carga de forma asíncrona. Usa sus cifras para comparar (antes y después, un paquete con otro), no como el peso real de la página.
8.3. Caso real: el gráfico de ventas
La zona de gestión tiene una página con la facturación mensual en un gráfico de barras, hecho con la biblioteca Chart.js. La consulta, en la capa de datos:
// lib/db/ventas.ts
import 'server-only'
import { asc, ne, sql } from 'drizzle-orm'
import { cacheLife, cacheTag } from 'next/cache'
import { db } from '.'
import { empleados, lineasPedido, pedidos } from '@/db/schema'
export type VentaMensual = { mes: string; total: number }
// Facturación por mes (sin pedidos cancelados)
export async function ventasPorMes(): Promise<VentaMensual[]> {
'use cache'
cacheTag('pedidos')
cacheLife('hours')
const mes = sql<string>`substr(${pedidos.fechaPedido}, 1, 7)`
return db
.select({
mes,
total: sql<number>`round(sum(${lineasPedido.cantidad} * ${lineasPedido.precioUnitario} * (1 - ${lineasPedido.descuento} / 100.0)), 2)`,
})
.from(pedidos)
.innerJoin(lineasPedido, sql`${lineasPedido.pedidoId} = ${pedidos.pedidoId}`)
.where(ne(pedidos.estado, 'CANCELADO'))
.groupBy(mes)
.orderBy(asc(mes))
}
export type EmpleadoNomina = { empId: number; nombre: string; salario: number; comision: number | null }
export async function empleadosParaNominas(): Promise<EmpleadoNomina[]> {
'use cache'
cacheTag('empleados')
cacheLife('hours')
return db
.select({
empId: empleados.empId,
nombre: sql<string>`${empleados.nombre} || ' ' || ${empleados.apellidos}`,
salario: empleados.salario,
comision: empleados.comision,
})
.from(empleados)
.orderBy(asc(empleados.empId))
}
El componente del gráfico, en su primera versión, importa chart.js/auto, que es lo que enseñan casi todos los tutoriales:
// app/gestion/ventas/_componentes/grafico-ventas.tsx (primera versión)
'use client'
import { useEffect, useRef } from 'react'
import Chart from 'chart.js/auto'
import type { VentaMensual } from '@/lib/db/ventas'
export default function GraficoVentas({ ventas }: { ventas: VentaMensual[] }) {
const lienzo = useRef<HTMLCanvasElement>(null)
useEffect(() => {
if (!lienzo.current) return
const grafico = new Chart(lienzo.current, {
type: 'bar',
data: {
labels: ventas.map((v) => v.mes),
datasets: [{ label: 'Facturación (€)', data: ventas.map((v) => v.total), backgroundColor: '#3f51b5' }],
},
options: { maintainAspectRatio: false, animation: false },
})
// Al desmontar, liberamos el canvas
return () => grafico.destroy()
}, [ventas])
return (
<div className="h-80">
<canvas ref={lienzo} aria-label="Facturación por mes" />
</div>
)
}
Y la página (un Server Component) le pasa los datos:
// app/gestion/ventas/estatica/page.tsx
import { ventasPorMes } from '@/lib/db/ventas'
import GraficoVentas from '../_componentes/grafico-ventas'
export const metadata = { title: 'Ventas (import estático)' }
export default async function VentasEstatica() {
const ventas = await ventasPorMes()
return (
<>
<h1 className="mb-4 text-2xl font-bold">Ventas por mes</h1>
<GraficoVentas ventas={ventas} />
</>
)
}

Figura 6. El gráfico de ventas de la zona de gestión, con los pedidos de la base de datos EMPRESA.
La medición con Playwright:
/gestion/ventas/estatica inicial: 8 ficheros, 207 KB gzip, 662.7 KB [chart.js 66.9 KB gzip]
Chart.js añade 66,9 KB comprimidos: casi la mitad de lo que pesa React con el router. chart.js/auto registra todos los tipos de gráfico (líneas, tartas, radar, burbujas…), escalas y complementos, aunque solo uses barras.
La solución que propone la propia documentación de Chart.js es registrar solo lo que usas:
// app/gestion/ventas/_componentes/grafico-ventas.tsx (versión final)
'use client'
import { useEffect, useRef } from 'react'
import { BarController, BarElement, CategoryScale, Chart, LinearScale, Tooltip } from 'chart.js'
import type { VentaMensual } from '@/lib/db/ventas'
// Solo registramos lo que usa un gráfico de barras
Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip)
export default function GraficoVentas({ ventas }: { ventas: VentaMensual[] }) {
const lienzo = useRef<HTMLCanvasElement>(null)
useEffect(() => {
if (!lienzo.current) return
const grafico = new Chart(lienzo.current, {
type: 'bar',
data: {
labels: ventas.map((v) => v.mes),
datasets: [{ label: 'Facturación (€)', data: ventas.map((v) => v.total), backgroundColor: '#3f51b5' }],
},
options: { maintainAspectRatio: false, animation: false },
})
return () => grafico.destroy()
}, [ventas])
return (
<div className="h-80">
<canvas ref={lienzo} aria-label="Facturación por mes" />
</div>
)
}
El flujo completo, tal como se hizo en el laboratorio:
npx next analyze --output --snapshot antes
npx next analyze export --snapshot antes > antes.jsonl
# ... cambiar grafico-ventas.tsx al registro selectivo ...
npx next analyze --output --snapshot despues
npx next analyze export --snapshot despues > despues.jsonl
node scripts/comparar-analisis.mjs antes.jsonl despues.jsonl /gestion/ventas/estatica
Ruta /gestion/ventas/estatica (cliente, KB)
paquete antes gzip después gzip
(app) 12.2 5.7 12.1 5.7
@kurkle/color 7.1 3.5 7.1 3.5
chart.js 189.3 63.9 134.6 47.0
TOTAL 771.4 281.7 716.6 264.8
Chart.js pasa de 189,3 KB a 134,6 KB sin comprimir: un 29 % menos, solo por cambiar la importación. @kurkle/color (la biblioteca de colores de la que depende Chart.js) se queda igual; aparece en la lista porque su tamaño varía en unos bytes. Medido en la red con Playwright, después de recompilar:
/gestion/ventas/estatica inicial: 7 ficheros, 189.8 KB gzip, 608 KB [chart.js 50.3 KB gzip]
El fichero de Chart.js baja de 66,9 a 50,3 KB comprimidos, y la página de 207 a 189,8 KB. Ahorrar 17 KB no es espectacular; lo importante es el método: analizar, cambiar una cosa y volver a medir.
8.4. Una trampa: el tree shaking es de toda la aplicación
En el laboratorio, mientras existían a la vez una ruta con chart.js/auto y otra con el registro selectivo, las dos pesaban lo mismo:
/gestion/ventas/estatica inicial: 8 ficheros, 207 KB gzip, 662.7 KB [chart.js 66.9 KB gzip]
/gestion/ventas/ligera inicial: 8 ficheros, 207 KB gzip, 662.7 KB [chart.js 66.9 KB gzip]
No es un fallo de la medición. Turbopack mete el código de chart.js en un fragmento (chunk) compartido por todas las rutas que lo usan, para que el navegador lo descargue una sola vez aunque visite las dos. Y el tree shaking (eliminar las exportaciones que nadie usa) se decide para ese fragmento con todo el proyecto a la vista: como una ruta usaba todo chart.js, el fragmento lo incluía todo, también para la ruta “ligera”.
Consecuencia práctica: una sola importación descuidada en cualquier parte del proyecto anula el ahorro en todas. Por eso conviene tener un único componente de gráfico (como en la versión final) y comprobar con el analizador que nadie importa chart.js/auto, lodash entero o el paquete completo de iconos por su cuenta.
8.5. Si usas webpack o Next.js 15: @next/bundle-analyzer
@next/bundle-analyzer es el analizador clásico, un plugin de webpack. Sigue funcionando en Next.js 16 si compilas con --webpack, y es la herramienta que tienes en Next.js 15:
// next.config.ts
import type { NextConfig } from 'next'
import bundleAnalyzer from '@next/bundle-analyzer'
const conAnalizador = bundleAnalyzer({ enabled: process.env.ANALYZE === 'true' })
const nextConfig: NextConfig = {
// tu configuración
}
export default conAnalizador(nextConfig)
ANALYZE=true npx next build # Next.js 15 (webpack es el predeterminado en build)
ANALYZE=true npx next build --webpack # Next.js 16
En Next.js 15.5.28 el build termina con estas líneas, y deja los informes en .next/analyze:
Webpack Bundle Analyzer saved report to C:\...\empresa\.next\analyze\nodejs.html
No bundles were parsed. Analyzer will show only original module sizes from stats file.
Webpack Bundle Analyzer saved report to C:\...\empresa\.next\analyze\edge.html
Webpack Bundle Analyzer saved report to C:\...\empresa\.next\analyze\client.html
✓ Compiled successfully in 83s
client.html es el que te interesa: el mapa de los ficheros que descarga el navegador. nodejs.html y edge.html muestran el código de servidor. El aviso “No bundles were parsed” es normal en el informe de edge cuando no tienes código en ese entorno.
⚠️ Importante: El plugin se engancha a webpack, así que solo genera informes cuando compilas con webpack: en Next.js 16,
next build --webpack; en Next.js 15,next buildsin--turbopack. En Next.js 15 tampoco existenext analyze: la orden se interpreta comonextcon un directorio llamadoanalyzey falla con “Invalid project directory provided, no such directory: …\analyze”.
8.6. next build ya no muestra tamaños 🔄 16.0
🔄 Cambia en Next.js 16.0. En Next.js 15,
next buildmuestra una tabla conSizeyFirst Load JSde cada ruta; en 16 esas columnas desaparecen. Si usas Next.js 15: tienes esas cifras en la salida del build, pero tómalas como orientativas; para saber qué hay dentro, usa@next/bundle-analyzer.
La guía de actualización a la 16 explica el motivo: esas cifras eran inexactas con Server Components, y Turbopack y webpack no se ponían de acuerdo en cómo contar el JavaScript de los Client Components. Recomienda medir lo que se descarga de verdad, como has hecho con Playwright. Esta es la salida real de Next.js 15.5.28 compilando con Turbopack:
▲ Next.js 15.5.28 (Turbopack)
- Experiments (use with caution):
✓ reactCompiler
✓ Compiled successfully in 44s
Route (app) Size First Load JS
┌ ○ / 4.51 kB 118 kB
└ ○ /_not-found 0 B 114 kB
+ First Load JS shared by all 116 kB
├ chunks/49ce98cb55b891f3.js 21.1 kB
└ chunks/df4dd37e8532e724.js 75.4 kB
└ other shared chunks (total) 19.7 kB
Y la del mismo proyecto, sin cambiar una línea, compilado con webpack (el predeterminado de next build en 15):
✓ Compiled successfully in 83s
Route (app) Size First Load JS
┌ ○ / 4.4 kB 107 kB
+ First Load JS shared by all 103 kB
118 KB con Turbopack y 107 KB con webpack para la misma página: es justo la discrepancia de la que habla la guía de actualización.
Y la de Next.js 16.4 para el laboratorio de este módulo, sin columnas de tamaño:
Route (app) Revalidate Expire
┌ ○ /
├ ○ /_not-found
├ ƒ /api/vitals
├ ○ /ayuda/cliente
├ ○ /ayuda/servidor
├ ○ /cwv/optimizada 1h 1d
├ ○ /cwv/sin-optimizar 1h 1d
├ ○ /gestion/nominas 1h 1d
├ ○ /gestion/ventas/bajo-demanda 1h 1d
├ ○ /gestion/ventas/dinamica 1h 1d
├ ○ /gestion/ventas/dinamica-servidor 1h 1d
├ ○ /gestion/ventas/estatica 1h 1d
└ ◐ /informe
9. Cargar código cuando hace falta: next/dynamic
Ya sabes cuánto pesa Chart.js. La siguiente pregunta es cuándo se descarga. next/dynamic es la combinación de React.lazy() y <Suspense> que viste en el Curso de React, con dos opciones propias de Next: loading y ssr.
9.1. Tres formas de cargar el mismo gráfico
A. Importación estática (la del apartado 8.3): el gráfico va en el JavaScript de la página.
B. next/dynamic con ssr: false: el gráfico va en un fichero aparte que se pide después de cargar la página. Mientras, se ve el componente loading.
// app/gestion/ventas/_componentes/grafico-diferido.tsx
'use client'
import dynamic from 'next/dynamic'
import type { VentaMensual } from '@/lib/db/ventas'
// ssr: false solo se admite dentro de un Client Component
const GraficoVentas = dynamic(() => import('./grafico-ventas'), {
ssr: false,
loading: () => <div className="flex h-80 items-center justify-center bg-gray-100">Cargando gráfico...</div>,
})
export function GraficoDiferido({ ventas }: { ventas: VentaMensual[] }) {
return <GraficoVentas ventas={ventas} />
}
// app/gestion/ventas/dinamica/page.tsx
import { ventasPorMes } from '@/lib/db/ventas'
import { GraficoDiferido } from '../_componentes/grafico-diferido'
export const metadata = { title: 'Ventas (next/dynamic)' }
export default async function VentasDinamica() {
const ventas = await ventasPorMes()
return (
<>
<h1 className="mb-4 text-2xl font-bold">Ventas por mes</h1>
<GraficoDiferido ventas={ventas} />
</>
)
}
C. next/dynamic bajo demanda: el gráfico solo se pinta cuando el usuario pulsa un botón, así que su código solo se descarga entonces.
// app/gestion/ventas/_componentes/grafico-bajo-demanda.tsx
'use client'
import { useState } from 'react'
import dynamic from 'next/dynamic'
import type { VentaMensual } from '@/lib/db/ventas'
const GraficoVentas = dynamic(() => import('./grafico-ventas'), {
loading: () => <div className="flex h-80 items-center justify-center bg-gray-100">Cargando gráfico...</div>,
})
export function GraficoBajoDemanda({ ventas }: { ventas: VentaMensual[] }) {
const [visible, setVisible] = useState(false)
return visible ? (
<GraficoVentas ventas={ventas} />
) : (
<button
type="button"
onClick={() => setVisible(true)}
className="rounded bg-indigo-700 px-4 py-2 text-white"
>
Ver gráfico de ventas
</button>
)
}
La página de C es igual que la de B cambiando el componente.
9.2. Lo que mide cada una
Medido con la primera versión del gráfico (chart.js/auto), para que las cifras se comparen con las del apartado 8.3:
/gestion/ventas/estatica inicial: 8 ficheros, 207 KB gzip, 662.7 KB [chart.js 66.9 KB gzip]
/gestion/ventas/dinamica inicial: 9 ficheros, 208.9 KB gzip, 667.7 KB [chart.js 66.9 KB gzip]
/gestion/ventas/bajo-demanda inicial: 7 ficheros, 141.4 KB gzip, 470.8 KB [-] | tras pulsar: +2 ficheros, +67.6 KB gzip, +197 KB [chart.js 66.9 KB gzip]
Figura 7. next/dynamic decide cuándo se descarga el código, no cuánto pesa.
Esta es la lección más importante del apartado, y la que más se malinterpreta:
next/dynamicconssr: falseno ahorra ni un byte en la carga. El gráfico se va a pintar en cuanto la página cargue, así que su código se descarga igual, en un fichero aparte (un fichero más: 9 en lugar de 8, y 1,9 KB más por la sobrecarga del fichero extra). Lo que gana es que ese fichero no forma parte de lo que hay que descargar y ejecutar antes de hidratar la página: el resto de la interfaz (menú, botones) responde antes, y el gráfico aparece un momento después en el hueco reservado porloading.- Solo ahorras bytes si el componente no se pinta hasta que hace falta. En la variante C, la carga inicial pesa 141,4 KB, casi lo mismo que una página vacía, y los 67,6 KB del gráfico solo los descarga quien pulsa el botón. Si la mitad de los usuarios nunca mira el gráfico, la mitad nunca paga su coste.
¿Cuándo usar cada una?
| Situación | Elección |
|---|---|
| Componente ligero o visible siempre arriba (es parte del LCP) | Importación estática |
| Componente pesado, visible al cargar, que no hace falta para interactuar con el resto | dynamic (con o sin ssr: false) |
Componente que usa window, document o APIs del navegador al importarse | dynamic con ssr: false |
| Modal, pestaña secundaria, editor que se abre con un botón, gráfico bajo un desplegable | dynamic + estado (bajo demanda) |
| Biblioteca que solo se usa en un manejador de eventos | await import('biblioteca') dentro del manejador |
9.3. Las reglas de next/dynamic
ssr: false solo en Client Components. Si lo escribes en un Server Component, Next da un error; la documentación recoge su texto: “ssr: false is not allowed with next/dynamic in Server Components. Please move it into a Client Component.” Por eso existe grafico-diferido.tsx: un Client Component mínimo que envuelve el dynamic.
dynamic en un Server Component no divide el código del cliente. La documentación lo advierte (“automatic code splitting is currently not supported”) y la medición lo confirma:
// app/gestion/ventas/dinamica-servidor/page.tsx
import dynamic from 'next/dynamic'
import { ventasPorMes } from '@/lib/db/ventas'
// dynamic() en un Server Component: el gráfico sigue siendo un Client Component
const GraficoVentas = dynamic(() => import('../_componentes/grafico-ventas'))
export const metadata = { title: 'Ventas (dynamic en el servidor)' }
export default async function VentasDinamicaServidor() {
const ventas = await ventasPorMes()
return (
<>
<h1 className="mb-4 text-2xl font-bold">Ventas por mes</h1>
<GraficoVentas ventas={ventas} />
</>
)
}
/gestion/ventas/dinamica-servidor inicial: 8 ficheros, 207.9 KB gzip, 665.8 KB [chart.js 66.9 KB gzip]
Prácticamente igual que la importación estática. El dynamic que hace algo está en el Client Component.
La ruta de import() debe ser literal, y el dynamic() debe estar en el nivel superior del módulo, no dentro de un componente: el empaquetador necesita ver la cadena en el código para crear el fichero aparte. dynamic(() => import(\./graficos/${tipo}`))` no funciona como esperas.
Exportaciones con nombre: import() devuelve el módulo entero; elige la exportación con .then:
const Contador = dynamic(() => import('./contador').then((m) => m.Contador))
Reserva el hueco en loading. El loading de los ejemplos mide h-80, lo mismo que el gráfico: si no, al aparecer el gráfico empujaría lo de debajo y tendrías el CLS del apartado 6.
9.4. import() para bibliotecas, no componentes
Cuando lo pesado no es un componente sino una biblioteca que solo se usa al hacer algo (exportar a Excel, validar un formato raro, buscar con fuzzy search), no necesitas next/dynamic: basta con import() dentro del manejador. El código se descarga la primera vez que el usuario hace clic, y las siguientes veces sale de la caché del módulo:
'use client'
export function BotonExportar({ filas }: { filas: unknown[] }) {
async function exportar() {
// Solo se descarga si alguien pulsa el botón
const { marked } = await import('marked')
const html = await marked.parse('# Informe\n\n' + filas.length + ' filas')
console.log(html)
}
return <button onClick={exportar}>Exportar</button>
}
10. El React Compiler en Next.js 🔄 16.0
🔄 Cambia en Next.js 16.0. En Next.js 15 el React Compiler se activa con
experimental.reactCompiler; en 16 pasa a ser estable y se configura conreactCompileren la raíz de la configuración. Si usas Next.js 15: instalababel-plugin-react-compilery escribeexperimental: { reactCompiler: true }ennext.config.ts; el resultado es el mismo.
El módulo 14 del Curso de React (apartado 8, “React Compiler 1.0”) explica qué es el React Compiler, cómo memoriza automáticamente lo que antes hacías con useMemo, useCallback y memo, y qué reglas de React debe cumplir tu código para que funcione. Aquí te centras en lo que es propio de Next.js: cómo se activa, las dos implementaciones disponibles y lo que se gana de verdad, medido.
📘 Concepto: El compilador solo afecta a los Client Components. Un Server Component se ejecuta una vez por petición y no se vuelve a renderizar en el navegador: no hay nada que memorizar. En una aplicación del App Router, donde la mayoría del código es de servidor, el compilador trabaja sobre la parte interactiva.
10.1. Un componente con trabajo de más
La zona de gestión tiene un histórico de nóminas: una tabla con una fila por empleado y mes (24 empleados × 60 meses = 1440 filas), y un campo de texto para escribir una nota del informe que no tiene nada que ver con la tabla:
// app/gestion/nominas/panel-nominas.tsx
'use client'
import { useState } from 'react'
import type { EmpleadoNomina } from '@/lib/db/ventas'
const euros = new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' })
type FilaNomina = { clave: string; mes: string; nombre: string; bruto: number; irpf: number; neto: number }
// Cálculo deliberadamente costoso: una fila por empleado y mes
function calcularNominas(empleados: EmpleadoNomina[], meses: number): FilaNomina[] {
const filas: FilaNomina[] = []
for (let m = 0; m < meses; m++) {
const fecha = new Date(2026, 9 - m, 1)
const mes = `${fecha.getFullYear()}-${String(fecha.getMonth() + 1).padStart(2, '0')}`
for (const e of empleados) {
const bruto = e.salario + (e.comision ?? 0)
const irpf = bruto * (bruto > 3000 ? 0.24 : 0.15)
filas.push({ clave: `${mes}-${e.empId}`, mes, nombre: e.nombre, bruto, irpf, neto: bruto - irpf })
}
}
return filas
}
function TablaNominas({ empleados, meses }: { empleados: EmpleadoNomina[]; meses: number }) {
const filas = calcularNominas(empleados, meses)
return (
<table className="w-full text-sm">
<thead>
<tr className="text-left">
<th>Mes</th>
<th>Empleado</th>
<th>Bruto</th>
<th>IRPF</th>
<th>Neto</th>
</tr>
</thead>
<tbody>
{filas.map((f) => (
<tr key={f.clave}>
<td>{f.mes}</td>
<td>{f.nombre}</td>
<td>{euros.format(f.bruto)}</td>
<td>{euros.format(f.irpf)}</td>
<td>{euros.format(f.neto)}</td>
</tr>
))}
</tbody>
</table>
)
}
export function PanelNominas({ empleados }: { empleados: EmpleadoNomina[] }) {
const [nota, setNota] = useState('')
const [meses, setMeses] = useState(60)
return (
<>
<div className="mb-4 flex gap-6">
<label>
Nota del informe{' '}
<input
value={nota}
onChange={(e) => setNota(e.target.value)}
className="rounded border px-2 py-1"
/>
</label>
<label>
Meses{' '}
<select value={meses} onChange={(e) => setMeses(Number(e.target.value))} className="border">
<option value={12}>12</option>
<option value={60}>60</option>
</select>
</label>
</div>
<p className="mb-2 text-sm text-gray-500">Nota: {nota || '(vacía)'}</p>
<TablaNominas empleados={empleados} meses={meses} />
</>
)
}
// app/gestion/nominas/page.tsx
import { empleadosParaNominas } from '@/lib/db/ventas'
import { PanelNominas } from './panel-nominas'
export const metadata = { title: 'Nóminas' }
export default async function Nominas() {
const empleados = await empleadosParaNominas()
return (
<>
<h1 className="mb-4 text-2xl font-bold">Histórico de nóminas</h1>
<PanelNominas empleados={empleados} />
</>
)
}

Figura 8. El histórico de nóminas. Cada tecla en «Nota del informe» cambia el estado de PanelNominas.
Cada tecla en el campo de la nota llama a setNota, React vuelve a ejecutar PanelNominas y, con él, TablaNominas: recalcula las 1440 filas, llama 4320 veces a euros.format y compara 1440 filas con las anteriores para descubrir que no ha cambiado nada.
Figura 9. El compilador guarda el elemento <TablaNominas> en una caché del componente y lo reutiliza mientras empleados y meses no cambien.
10.2. Activarlo: la versión Babel
npm install -D babel-plugin-react-compiler
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
reactCompiler: true,
}
export default nextConfig
El compilador oficial está escrito como plugin de Babel. Turbopack no usa Babel (usa SWC, escrito en Rust), así que Next.js hace algo intermedio: SWC analiza cada fichero y solo pasa por Babel los que tienen JSX o hooks. Los demás (utilidades, capa de datos, configuración) no pagan el coste. Aun así, la documentación avisa de que el build será algo más lento.
Si activas la opción sin instalar el paquete, el build se detiene enseguida:
Error: Turbopack build failed with 1 error:
Error: Failed to resolve package babel-plugin-react-compiler while attempting to resolve React Compiler
React compiler is enabled in next.config.ts. We attempted to resolve React Compiler relative to the next package.
Is babel-plugin-react-compiler installed in your node_modules directory?
Fíjate en “relative to the next package”: Turbopack busca el plugin junto al paquete next, no junto a tu next.config.ts. En un proyecto normal es lo mismo, pero en un monorepo donde next está instalado en la raíz y el plugin en el paquete de la aplicación (o al revés), falla con este mensaje aunque el paquete esté instalado. En el laboratorio de este módulo pasó exactamente eso, y se resolvió instalando next y el plugin en la misma carpeta.
10.3. La versión nativa en Rust 🆕 16.3
🆕 Desde Next.js 16.3. No existe en Next.js 15. Si usas Next.js 15: usa la versión Babel con
experimental.reactCompiler; no hay versión nativa.
Next.js 16.3 incluye, como experimento, una traducción del compilador a Rust que se ejecuta dentro de Turbopack, sin Babel y sin instalar nada:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
reactCompiler: true,
experimental: {
// Usa la versión en Rust en lugar del plugin de Babel
turbopackRustReactCompiler: true,
},
}
export default nextConfig
turbopackRustReactCompiler no activa el compilador por sí sola: elige qué implementación usa reactCompiler. Solo funciona con Turbopack (con --webpack da error). Al compilar, Next lo anuncia como experimento:
- Experiments (use with caution):
✓ turbopackRustReactCompiler
10.4. Lo que mide cada versión
Las tres versiones del laboratorio se compilaron en frío (borrando antes la carpeta de salida y su caché) y se sirvieron una detrás de otra en las mismas condiciones. El script de medición teclea “revisar irpf” (12 teclas, una cada 300 ms) con la CPU cuatro veces más lenta, y registra la duración de cada pulsación con la Event Timing API, que es de donde sale el INP:
// scripts/medir-inp.mjs
// Uso: node scripts/medir-inp.mjs http://127.0.0.1:3001 [/gestion/nominas]
import { chromium } from '@playwright/test'
const base = process.argv[2]
const ruta = process.argv[3] ?? '/gestion/nominas'
const navegador = await chromium.launch()
function observador() {
window.__teclas = new Map()
new PerformanceObserver((lista) => {
for (const e of lista.getEntries()) {
// keydown: el momento en que el usuario pulsa la tecla
if (e.name !== 'keydown' || !e.interactionId) continue
window.__teclas.set(e.interactionId, Math.max(window.__teclas.get(e.interactionId) ?? 0, e.duration))
}
}).observe({ type: 'event', durationThreshold: 16, buffered: true })
}
const mediana = (v) => [...v].sort((a, b) => a - b)[Math.floor(v.length / 2)]
const TEXTO = 'revisar irpf'
for (let vuelta = 0; vuelta < 3; vuelta++) {
const contexto = await navegador.newContext()
const pagina = await contexto.newPage()
const cdp = await contexto.newCDPSession(pagina)
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 })
await pagina.addInitScript(observador)
await pagina.goto(base + ruta, { waitUntil: 'networkidle' })
await pagina.waitForTimeout(Number(process.env.ESPERA ?? 3000)) // que termine la hidratación
await pagina.getByLabel('Nota del informe').focus()
await pagina.keyboard.type(TEXTO, { delay: 300 })
await pagina.getByText('Nota: ' + TEXTO).waitFor()
await pagina.waitForTimeout(500)
const medidas = await pagina.evaluate(() => [...window.__teclas.values()])
// Las teclas por debajo de 16 ms no las registra el navegador: cuentan como 16
const todas = [...medidas, ...Array(Math.max(0, TEXTO.length - medidas.length)).fill(16)]
await pagina.evaluate(() => {
Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true })
document.dispatchEvent(new Event('visibilitychange'))
})
await pagina.waitForTimeout(300)
const inp = await pagina.evaluate(() => (window.__metricas ?? []).filter((m) => m.nombre === 'INP').map((m) => `${m.valor} (${m.nota})`).join(' '))
console.log(`${TEXTO.length} teclas | mediana ${mediana(todas)} ms | peor ${Math.max(...todas)} ms | INP web-vitals: ${inp}`)
await contexto.close()
}
await navegador.close()
Resultado, tres cargas de cada versión, empezando a teclear 3 segundos después de cargar:
== base (vuelta 1)
12 teclas | mediana 424 ms | peor 1792 ms | INP web-vitals: 1792 (poor)
12 teclas | mediana 376 ms | peor 1896 ms | INP web-vitals: 1896 (poor)
12 teclas | mediana 376 ms | peor 1960 ms | INP web-vitals: 1960 (poor)
== babel (vuelta 1)
12 teclas | mediana 112 ms | peor 1088 ms | INP web-vitals: 1088 (poor)
12 teclas | mediana 144 ms | peor 1496 ms | INP web-vitals: 1496 (poor)
12 teclas | mediana 104 ms | peor 1304 ms | INP web-vitals: 1304 (poor)
== rust (vuelta 1)
12 teclas | mediana 120 ms | peor 1392 ms | INP web-vitals: 1392 (poor)
12 teclas | mediana 112 ms | peor 1680 ms | INP web-vitals: 1680 (poor)
12 teclas | mediana 120 ms | peor 1736 ms | INP web-vitals: 1736 (poor)
La mediana por tecla baja de unos 400 ms a unos 115 ms con cualquiera de los dos compiladores: cada tecla ya no recalcula la tabla. Pero el INP (la peor tecla) sigue por encima de un segundo en las tres versiones. ¿Por qué?
La peor tecla es siempre la primera, y no por la tabla: a los 3 segundos de cargar, con la CPU lenta, React todavía está hidratando las 1440 filas que llegaron en el HTML. La primera pulsación tiene que esperar a que termine. Para comprobarlo, la misma medición empezando a teclear a los 12 segundos:
== base, espera 12 s
12 teclas | mediana 304 ms | peor 632 ms | INP web-vitals: 632 (poor)
12 teclas | mediana 240 ms | peor 488 ms | INP web-vitals: 488 (needs-improvement)
12 teclas | mediana 288 ms | peor 408 ms | INP web-vitals: 408 (needs-improvement)
== babel, espera 12 s
12 teclas | mediana 104 ms | peor 344 ms | INP web-vitals: 344 (needs-improvement)
12 teclas | mediana 104 ms | peor 336 ms | INP web-vitals: 336 (needs-improvement)
12 teclas | mediana 96 ms | peor 224 ms | INP web-vitals: 224 (needs-improvement)
Resumen de todo lo medido:
| Sin compilador | reactCompiler (Babel) | turbopackRustReactCompiler | |
|---|---|---|---|
| Mediana por tecla (empezando a los 3 s) | 376-424 ms | 104-144 ms | 112-120 ms |
| INP (empezando a los 3 s) | 1792-1960 ms | 1088-1496 ms | 1392-1736 ms |
| INP (empezando a los 12 s) | 408-632 ms | 224-344 ms | |
| Paso “Compiled successfully” del build en frío | 33,4 s | 42 s | 35,0 s |
JS del cliente, total de .next/static/chunks | 950 779 bytes | 956 606 bytes | 956 506 bytes |
Fichero de panel-nominas.tsx | 1896 bytes (875 gzip) | 3004 bytes (1458 gzip) | 3000 bytes (1448 gzip) |
Ficheros con memo_cache_sentinel | 1 | 10 | 10 |
Lo que dice la tabla:
- El compilador divide por tres o cuatro el coste de cada tecla, y deja el INP “en reposo” (12 s) en torno a 300 ms. No llega a “bueno” (200 ms) con la CPU cuatro veces más lenta, porque React sigue teniendo que actualizar el
<input>y el párrafo, y el navegador repintar una página con 1440 filas. - No arregla el INP de la primera interacción, que depende de la hidratación. Contra eso, la herramienta es que la tabla no sea un Client Component: si el filtro de meses fuera un parámetro de la URL (
searchParams, módulo 18), la tabla sería un Server Component, llegaría como HTML y no habría que hidratarla. El compilador optimiza renders; no hace que haya menos que hidratar. - Las dos implementaciones dan el mismo resultado en el navegador: mismo tamaño de código (unos bytes de diferencia) y la misma memorización (el marcador
memo_cache_sentinelque usa el código compilado aparece en los mismos 10 ficheros). Difieren en el build: la versión Babel añadió casi 9 segundos al paso de compilación; la de Rust, 1,6 segundos. - El código crece: unos 6 KB sin comprimir en total, y el fichero del panel pasa de 875 a unos 1450 bytes comprimidos. Es la caché por componente (
$[0],$[1]…) y las comparaciones que el compilador inserta. Es un precio muy pequeño, pero existe.
💡 Consejo: El marcador
memo_cache_sentineles una forma rápida de comprobar si el compilador está actuando:grep -l memo_cache_sentinel .next/static/chunks/*.js. Sin compilador aparece en un solo fichero (el propio React); con él, en cada fichero que contiene componentes compilados.
⚠️ Importante: Los tiempos de build del laboratorio tienen bastante ruido (el equipo estaba compilando otros proyectos a la vez), así que toma los 9 segundos de Babel como orden de magnitud, no como cifra exacta. Lo que sí se repite en todas las mediciones es el orden: sin compilador ≈ Rust < Babel.
10.5. Modo anotación y exclusiones
Si activas el compilador en un proyecto grande, quizá prefieras hacerlo componente a componente. Con compilationMode: 'annotation' solo se compilan los componentes y hooks que empiezan con la directiva 'use memo':
// next.config.ts
const nextConfig: NextConfig = {
reactCompiler: {
compilationMode: 'annotation',
},
}
function TablaNominas({ empleados, meses }: { empleados: EmpleadoNomina[]; meses: number }) {
'use memo'
const filas = calcularNominas(empleados, meses)
// ...
}
Y al revés: con el compilador activo para todo, 'use no memo' excluye un componente concreto (por ejemplo, uno que integra una biblioteca que muta objetos y que el compilador rompe). Es una salida de emergencia temporal, no una forma de trabajar.
La documentación de Next añade una opción, environment.enablePreserveExistingMemoizationGuarantees, que decide si el compilador respeta los useMemo, useCallback y memo que ya tuvieras escritos. Su valor por defecto depende de la implementación y la versión del compilador, así que, si te importa, fíjalo explícitamente.
10.6. Lo que el compilador no arregla
- No reduce el JavaScript. Al contrario, lo aumenta un poco (apartado 10.4).
- No acelera el primer render. La primera vez hay que calcularlo todo igual; el compilador ahorra en los siguientes renders.
- No arregla un cálculo que de verdad depende del cambio. Si cambias
mesesde 60 a 12, la tabla se recalcula con o sin compilador, porque sus datos han cambiado. - No sustituye a poner el estado donde debe estar. Si la nota viviera en su propio componente, separado de la tabla,
TablaNominasno se volvería a ejecutar ni sin compilador. El ejercicio 6 te lo pide.
11. Turbopack y la caché en disco
Turbopack es el empaquetador por defecto de Next.js 16 en next dev y en next build (lo viste en el módulo 01). Para el rendimiento de tu aplicación no cambia gran cosa respecto a webpack: los dos dividen el código por rutas, minimizan y eliminan código muerto. Donde se nota es en tu tiempo: arranque del servidor de desarrollo, recarga al guardar y duración del build.
11.1. Caché en disco para desarrollo 🆕 16.1
🆕 Desde Next.js 16.1. En Next.js 15 la caché persistente de Turbopack era un experimento solo en las versiones canary: cada
next devempieza de cero. Si usas Next.js 15: no hay alternativa en la versión estable; mantén el servidor de desarrollo abierto en lugar de reiniciarlo.
Desde 16.1, next dev guarda el trabajo de Turbopack en .next/dev/cache/turbopack. Al reiniciar el servidor, reutiliza lo compilado en la sesión anterior.
En el laboratorio, borrando .next/dev y arrancando next dev dos veces seguidas. El registro de next dev separa en cada petición el tiempo de Next (compilar la ruta y prepararla) del de tu código (consultas y render):
# Primer arranque, sin caché
✓ Ready in 2.1s
GET / 200 in 4.8s (next.js: 3.5s, application-code: 1321ms)
GET /gestion/ventas/estatica 200 in 2.2s (next.js: 462ms, application-code: 1775ms)
GET /gestion/nominas 200 in 3.5s (next.js: 577ms, application-code: 3.0s)
# Segundo arranque, con la caché de la sesión anterior
✓ Ready in 3.2s
GET / 200 in 2.7s (next.js: 1581ms, application-code: 1143ms)
GET /gestion/ventas/estatica 200 in 1866ms (next.js: 303ms, application-code: 1563ms)
GET /gestion/nominas 200 in 1545ms (next.js: 69ms, application-code: 1477ms)
La parte de Next en la primera visita a cada ruta baja de 3,5 s a 1,6 s en la portada y de 577 ms a 69 ms en nóminas: lo que ya se compiló en la sesión anterior no se vuelve a compilar. application-code no cambia (es tu código, que consulta la base de datos y renderiza en cada petición de desarrollo). El “Ready in” no mejora, porque el servidor arranca sin compilar nada; la compilación se hace al pedir cada ruta.
En un proyecto pequeño como este la diferencia son segundos. En una aplicación con cientos de rutas y dependencias pesadas, la primera visita a cada página tras reiniciar el servidor es donde más se nota.
11.2. Caché en disco para build 🔄 16.3
🔄 Cambia en Next.js 16.3. Desde 16.3,
next buildguarda su trabajo en.next/cache/turbopacky el siguiente build parte de él; antes, cada build empezaba de cero. En Next.js 15 no existe. Si usas Next.js 15: no hay alternativa; cadanext build --turbopackcompila todo desde cero.
Las dos opciones se controlan en next.config.ts y están activadas por defecto:
const nextConfig: NextConfig = {
experimental: {
turbopackFileSystemCacheForDev: true,
turbopackFileSystemCacheForBuild: true,
},
}
La caché solo sirve si .next/cache sobrevive entre builds. En tu equipo, sí. En un contenedor Docker o en un servidor de integración continua que parte de una carpeta limpia, no, salvo que la guardes explícitamente (lo verás en el módulo 23). Si tu entorno nunca la conserva, la documentación recomienda turbopackFileSystemCacheForBuild: false para no perder tiempo escribiéndola.
En el laboratorio, los tiempos del paso “Compiled successfully” fueron:
| Build | Paso “Compiled successfully” |
|---|---|
Borrando antes .next (en frío) | 23,8 s |
| Segundo build sin cambiar nada | 9,8 s |
La compilación con Turbopack baja a menos de la mitad. Ten en cuenta que es una caché de Turbopack: acelera el paso de compilación, no la comprobación de tipos ni el prerenderizado de las páginas, que vienen después.
12. Otras palancas de configuración
Hay más opciones en next.config.ts relacionadas con el rendimiento. No hacen falta en la mayoría de proyectos, pero conviene saber que existen:
| Opción | Para qué | Cuándo |
|---|---|---|
experimental.optimizePackageImports | Importa solo los módulos que usas de paquetes con cientos de exportaciones (iconos, utilidades) | Next ya lo aplica a una lista de paquetes conocidos (por ejemplo lucide-react, date-fns, lodash-es); añade los tuyos si el analizador muestra el paquete entero |
serverExternalPackages | Deja un paquete fuera del empaquetado del servidor y lo carga de node_modules | Paquetes con binarios nativos o que no se dejan empaquetar |
images.qualities, images.formats | Calidades y formatos que genera el optimizador | Si necesitas AVIF o una calidad distinta de 75 (módulo 07) |
compress: false | Desactiva el gzip de next start | Cuando un proxy (Nginx, la CDN) ya comprime |
Y recuerda las palancas que ya conoces de módulos anteriores, que son las que más influyen en el TTFB y el LCP:
- Cache Components (módulo 09): todo lo que se pueda cachear con
'use cache'entra en el armazón estático y se sirve al instante. <Suspense>bien colocado (módulo 08): lo lento no retrasa lo rápido.- Precarga y navegación instantánea (módulo 15): la siguiente página ya está en el navegador cuando el usuario pulsa.
13. Caso práctico: auditoría de la zona de gestión
Juntando todo, este es el proceso que seguirías con la aplicación EMPRESA antes de una entrega.
Paso 1. Datos de campo. Monta WebVitals en el layout raíz y el Route Handler /api/vitals. Guarda las métricas en una tabla (ruta, nombre, valor, nota, fecha) y, tras unos días, calcula el percentil 75 por ruta. Eso te dice dónde mirar.
Paso 2. Reproducir en el laboratorio. Para cada ruta con métricas “mejorables” o “malas”, lanza medir-cwv.mjs y medir-js.mjs contra next build && next start.
Paso 3. Buscar la causa según la métrica:
| Métrica mala | Primer sitio donde mirar |
|---|---|
| LCP | ¿Qué elemento es el LCP? (el script lo dice). Si es una imagen: next/image, sizes, preload. Si es texto: ¿espera a datos sin caché? ¿Hay CSS o fuentes bloqueando? |
| CLS | Fallbacks de <Suspense> y loading de dynamic; imágenes sin dimensiones; contenido insertado al hidratar |
| INP | next analyze: ¿qué bibliotecas pesadas carga la ruta? Después, el componente que responde a la interacción: ¿cuánto se vuelve a renderizar? |
| TTFB | Datos sin caché fuera de <Suspense>, consultas lentas a la base de datos, región del servidor |
Paso 4. Aplicar un cambio y volver a medir. Uno cada vez. En este módulo has visto cómo un cambio razonable no cambia nada (ssr: false en bytes, registro selectivo de Chart.js mientras quede un chart.js/auto), o mejora una métrica y empeora otra (preload con el FCP).
En el laboratorio, la auditoría terminó así:
| Ruta | Problema detectado | Cambio | Antes | Después |
|---|---|---|---|---|
| Portada | LCP 5,8 s: JPEG de 888 KB con <img> | next/image con sizes y preload | LCP 5780 ms | LCP 2128 ms |
| Informe | CLS por fallback de una línea | Esqueleto del mismo alto | CLS 0,047 | CLS 0 |
| Ayuda | marked en el cliente | Server Component | 153,6 KB gzip | 139,5 KB gzip |
| Ventas | Chart.js completo en la carga | Bajo demanda con dynamic | 207 KB gzip | 141,4 KB gzip (+67,6 KB al pulsar) |
| Nóminas | Cada tecla recalcula 1440 filas | React Compiler | Mediana por tecla 240-304 ms, INP 408-632 ms | Mediana 96-104 ms, INP 224-344 ms |
14. Errores frecuentes
| Síntoma | Causa | Solución |
|---|---|---|
| Las métricas salen buenísimas en tu equipo y malas en Search Console | Mides en next dev, o sin limitar red ni CPU | next build && next start, y limita red y CPU (apartado 4) |
useReportWebVitals envía cada métrica varias veces | La función que le pasas se crea en cada render | Defínela fuera del componente (o con useCallback) |
| Nunca llegan LCP, CLS ni INP al servidor | Se entregan al ocultar la página, y fetch normal se cancela al cerrar | navigator.sendBeacon o fetch con keepalive: true |
| CLS siempre 0 en el servidor | Math.round(0.047) es 0 | Multiplica CLS por 1000 antes de redondear |
`ssr: false` is not allowed with `next/dynamic` in Server Components | dynamic(..., { ssr: false }) en un fichero sin 'use client' | Muévelo a un Client Component que envuelva al dinámico |
next/dynamic no reduce el JS de la carga | El componente se pinta al cargar: se descarga igual | Cárgalo bajo demanda, o asume que solo ganas en hidratación |
| El registro selectivo de una biblioteca no reduce nada | Otra parte del proyecto importa la biblioteca entera | Busca en el analizador la cadena de importación del paquete |
| Saltos de diseño al llegar los datos | Fallback de <Suspense> más pequeño que el contenido | Esqueleto del mismo alto, o min-h-* |
Failed to resolve package babel-plugin-react-compiler | reactCompiler: true sin el paquete instalado | npm install -D babel-plugin-react-compiler, o la versión en Rust (16.3) |
Next 16 no muestra First Load JS | Se eliminó en 16.0 | next analyze o medir con Playwright |
| La primera visita a una imagen es lentísima | El optimizador genera la versión WebP la primera vez | Normal; se guarda en .next/cache/images. Consérvala entre despliegues |
15. Ejercicios
Ejercicio 1: Las métricas en una tabla
Modifica el Route Handler /api/vitals para que, además de escribir en la consola, guarde cada métrica en una tabla metricas de la base de datos SQLite (columnas id, ruta, nombre, valor, nota, fecha). Valida el cuerpo con Zod antes de guardarlo y responde 400 si no es válido.
Ver solución
Añade la tabla al esquema del módulo 16 y genera la migración con drizzle-kit:
// db/schema.ts (añadir)
export const metricas = sqliteTable('metricas', {
id: integer('id').primaryKey({ autoIncrement: true }),
ruta: text('ruta').notNull(),
nombre: text('nombre').notNull(),
valor: real('valor').notNull(),
nota: text('nota').notNull(),
fecha: text('fecha').notNull().default(sql`(datetime('now'))`),
})
// app/api/vitals/route.ts
import { z } from 'zod'
import { db } from '@/lib/db'
import { metricas } from '@/db/schema'
const Metrica = z.object({
nombre: z.enum(['TTFB', 'FCP', 'LCP', 'CLS', 'INP']),
valor: z.number().nonnegative(),
nota: z.enum(['good', 'needs-improvement', 'poor']),
ruta: z.string().startsWith('/').max(200),
})
export async function POST(request: Request) {
let datos: unknown
try {
datos = JSON.parse(await request.text())
} catch {
return new Response('JSON no válido', { status: 400 })
}
const resultado = Metrica.safeParse(datos)
if (!resultado.success) return new Response('Métrica no válida', { status: 400 })
const { ruta, nombre, valor, nota } = resultado.data
await db.insert(metricas).values({ ruta, nombre, valor, nota })
return new Response(null, { status: 204 })
}
z.enum con los cinco nombres descarta las métricas que no te interesan (y lo que envíe alguien que llama a tu API a mano: es un endpoint público). El max(200) evita que te llenen la tabla de rutas gigantes. En producción, además, limitarías la frecuencia por IP.
Ejercicio 2: Percentil 75 por ruta
Con la tabla del ejercicio 1, escribe una función de la capa de datos p75PorRuta(nombre) que devuelva, para una métrica, el percentil 75 de cada ruta. Muéstralo en una página /gestion/rendimiento con la nota (bueno, mejorable, malo) de cada valor.
Ver solución
SQLite no tiene una función de percentil, pero se puede calcular en TypeScript tras traer los valores ordenados. Con pocas métricas es suficiente:
// lib/db/metricas.ts
import 'server-only'
import { asc, eq } from 'drizzle-orm'
import { db } from '.'
import { metricas } from '@/db/schema'
const UMBRALES: Record<string, [number, number]> = {
LCP: [2500, 4000],
INP: [200, 500],
CLS: [100, 250], // multiplicado por 1000 al enviarlo
FCP: [1800, 3000],
TTFB: [800, 1800],
}
export async function p75PorRuta(nombre: string) {
const filas = await db
.select({ ruta: metricas.ruta, valor: metricas.valor })
.from(metricas)
.where(eq(metricas.nombre, nombre))
.orderBy(asc(metricas.ruta), asc(metricas.valor))
const porRuta = Map.groupBy(filas, (f) => f.ruta)
const [bueno, malo] = UMBRALES[nombre]
return [...porRuta].map(([ruta, valores]) => {
const p75 = valores[Math.ceil(valores.length * 0.75) - 1].valor
const nota = p75 <= bueno ? 'bueno' : p75 <= malo ? 'mejorable' : 'malo'
return { ruta, p75, muestras: valores.length, nota }
})
}
// app/gestion/rendimiento/page.tsx
import { Suspense } from 'react'
import { connection } from 'next/server'
import { p75PorRuta } from '@/lib/db/metricas'
async function TablaP75({ nombre }: { nombre: string }) {
await connection() // datos en vivo, sin caché
const filas = await p75PorRuta(nombre)
return (
<table>
<caption>{nombre} (percentil 75)</caption>
<tbody>
{filas.map((f) => (
<tr key={f.ruta}>
<td>{f.ruta}</td>
<td>{f.p75}</td>
<td>{f.nota}</td>
<td>{f.muestras} visitas</td>
</tr>
))}
</tbody>
</table>
)
}
export default function Rendimiento() {
return (
<>
<h1>Rendimiento por ruta</h1>
{['LCP', 'INP', 'CLS'].map((m) => (
<Suspense key={m} fallback={<p>Calculando {m}...</p>}>
<TablaP75 nombre={m} />
</Suspense>
))}
</>
)
}
Map.groupBy es de ES2024 (el tsconfig.json de la plantilla ya incluye esnext en lib); si tu versión de Node no la tiene, usa un reduce. connection() marca los datos como dinámicos, como viste en el módulo 09. Y sí: cada <Suspense> tiene un fallback de una línea para una tabla más alta; en un panel de gestión que nadie mide con Core Web Vitals es aceptable, pero ya sabes cómo corregirlo.
Ejercicio 3: El banner que salta
En la portada, añade un aviso “Envío gratis a partir de 50 €” que un Client Component muestra encima de la imagen solo después de comprobar en localStorage que el usuario no lo ha cerrado. Mide el CLS con medir-cwv.mjs. Después corrígelo sin quitar el aviso y vuelve a medir.
Ver solución
La primera idea suele ser un useEffect que lee localStorage y llama a setVisible. El ESLint de la plantilla lo rechaza:
Avoid calling setState() directly within an effect react-hooks/set-state-in-effect
La forma correcta de leer un almacén externo al render (como localStorage) es useSyncExternalStore, que además pide un valor para el servidor:
// app/ejercicios/aviso/aviso-envio.tsx
'use client'
import { useSyncExternalStore } from 'react'
// localStorage como "almacén externo": React sabe leerlo en el cliente
// y usa leerEnServidor durante el prerenderizado y la hidratación
function suscribir(aviso: () => void) {
window.addEventListener('storage', aviso)
return () => window.removeEventListener('storage', aviso)
}
const leer = () => localStorage.getItem('aviso-envio') !== 'cerrado'
const leerEnServidor = () => false
export function AvisoEnvio() {
const visible = useSyncExternalStore(suscribir, leer, leerEnServidor)
function cerrar() {
localStorage.setItem('aviso-envio', 'cerrado')
// El evento storage solo llega a las otras pestañas: lo lanzamos también en esta
window.dispatchEvent(new StorageEvent('storage'))
}
if (!visible) return null
return (
<div className="bg-orange-500 p-3 text-white">
Envío gratis a partir de 50 €
<button onClick={cerrar}>×</button>
</div>
)
}
// app/ejercicios/aviso/page.tsx
import Image from 'next/image'
import portada from '@/public/portada.jpg'
import { AvisoEnvio } from './aviso-envio'
export default function PortadaConAviso() {
return (
<>
<AvisoEnvio />
<Image src={portada} alt="Tienda EMPRESA" preload sizes="(max-width: 1280px) 100vw, 1280px" className="h-auto w-full" />
<h1 className="my-4 text-2xl font-bold">Novedades de la tienda</h1>
</>
)
}
El servidor no puede saber qué hay en localStorage, así que el HTML llega sin aviso (leerEnServidor devuelve false). Al hidratar, React lee el valor real, muestra el aviso y empuja hacia abajo la imagen y todo lo demás: CLS, y como ocurre sin que el usuario haya hecho nada, cuenta entero.
Dos arreglos posibles:
- Posición fija: cambiar la clase a
"fixed inset-x-0 bottom-0 bg-orange-500 p-3 text-white". Un elemento fijo no empuja a nadie. - Guardar la preferencia en una cookie en lugar de
localStorage, leerla en el servidor concookies()dentro de un<Suspense>y renderizar el aviso ya en el HTML. El aviso ocupa su sitio desde el primer pintado.
La primera es la más sencilla y no añade trabajo al servidor. Medido en el laboratorio:
/ejercicios/aviso TTFB 9 ms | FCP 904 ms | LCP 4196 ms (IMG) | CLS 0.030
/ejercicios/aviso-fijo TTFB 11 ms | FCP 884 ms | LCP 4080 ms (IMG) | CLS 0.000
Con el aviso en el flujo, 0,030 de CLS; con fixed, 0. El LCP es prácticamente igual en las dos: el aviso no cambia cuándo llega la foto, solo dónde queda.
Ejercicio 4: Marked bajo demanda
En /ayuda/cliente hay una vista previa de Markdown para que el equipo de la tienda escriba la guía. Necesita marked en el navegador, pero solo cuando alguien pulsa “Vista previa”. Reescribe el componente para que marked no esté en la carga inicial y mide con medir-js.mjs la ruta antes y después de pulsar.
Ver solución
'use client'
import { useState } from 'react'
export function EditorGuia({ inicial }: { inicial: string }) {
const [texto, setTexto] = useState(inicial)
const [html, setHtml] = useState<string | null>(null)
async function previsualizar() {
// marked se descarga la primera vez que se pulsa
const { marked } = await import('marked')
setHtml(await marked.parse(texto))
}
return (
<>
<textarea value={texto} onChange={(e) => setTexto(e.target.value)} rows={12} className="w-full border" />
<button type="button" onClick={previsualizar}>Vista previa</button>
{html && <article dangerouslySetInnerHTML={{ __html: html }} />}
</>
)
}
Al medir con node scripts/medir-js.mjs http://127.0.0.1:3001 "/ayuda/cliente|Vista previa", la carga inicial debe ser la de una página vacía (unos 140 KB comprimidos en el laboratorio) y, tras pulsar, aparecer un fichero con marked de unos 14 KB comprimidos. No hace falta next/dynamic: lo que se carga bajo demanda es una biblioteca, no un componente.
Ejercicio 5: El compilador en una lista de pedidos
Escribe un Client Component ListaPedidos que reciba los pedidos de la base de datos, tenga un campo de búsqueda y un interruptor “Mostrar importes con IVA”, y renderice cada pedido con un subcomponente FilaPedido. Sin el compilador, ¿qué se vuelve a renderizar al pulsar el interruptor? ¿Y al escribir en la búsqueda? ¿Qué cambia con reactCompiler: true?
Ver solución
'use client'
import { useState } from 'react'
type Pedido = { pedidoId: number; cliente: string; total: number }
function FilaPedido({ pedido, conIva }: { pedido: Pedido; conIva: boolean }) {
const importe = conIva ? pedido.total * 1.21 : pedido.total
return (
<li>
{pedido.pedidoId} · {pedido.cliente} · {importe.toFixed(2)} €
</li>
)
}
export function ListaPedidos({ pedidos }: { pedidos: Pedido[] }) {
const [busqueda, setBusqueda] = useState('')
const [conIva, setConIva] = useState(false)
const visibles = pedidos.filter((p) => p.cliente.toLowerCase().includes(busqueda.toLowerCase()))
return (
<>
<input value={busqueda} onChange={(e) => setBusqueda(e.target.value)} placeholder="Cliente" />
<label>
<input type="checkbox" checked={conIva} onChange={(e) => setConIva(e.target.checked)} /> Con IVA
</label>
<ul>
{visibles.map((p) => (
<FilaPedido key={p.pedidoId} pedido={p} conIva={conIva} />
))}
</ul>
</>
)
}
Sin compilador, cualquier cambio de estado vuelve a ejecutar ListaPedidos y todas las FilaPedido visibles.
Con compilador:
- Al pulsar el interruptor,
conIvacambia para todas las filas: todas se vuelven a renderizar, y es correcto, porque todas cambian. - Al escribir en la búsqueda,
visiblesse recalcula (depende debusqueda), pero cada<FilaPedido>cuyopedidoyconIvano han cambiado se reutiliza de la caché del componente. Solo se crean las filas que aparecen nuevas. Elfilterse memoriza en función depedidosybusqueda, así que pulsar el interruptor tampoco lo repite.
La idea es la misma que en el apartado 10: el compilador evita el trabajo que no depende del cambio, nunca el que sí depende.
Ejercicio 6: Sin compilador, con el estado en su sitio
Sin activar el React Compiler, consigue que escribir en “Nota del informe” del histórico de nóminas no vuelva a ejecutar TablaNominas, sin usar memo, useMemo ni useCallback. Mide el INP con medir-inp.mjs y compáralo con las cifras del apartado 10.4.
Ver solución
El problema de partida es que nota vive en PanelNominas, que también es el padre de la tabla. Si la nota baja a su propio componente, su estado ya no afecta a nadie más:
function NotaInforme() {
const [nota, setNota] = useState('')
return (
<>
<label>
Nota del informe{' '}
<input value={nota} onChange={(e) => setNota(e.target.value)} className="rounded border px-2 py-1" />
</label>
<p className="mb-2 text-sm text-gray-500">Nota: {nota || '(vacía)'}</p>
</>
)
}
export function PanelNominas({ empleados }: { empleados: EmpleadoNomina[] }) {
const [meses, setMeses] = useState(60)
return (
<>
<NotaInforme />
<label>
Meses{' '}
<select value={meses} onChange={(e) => setMeses(Number(e.target.value))} className="border">
<option value={12}>12</option>
<option value={60}>60</option>
</select>
</label>
<TablaNominas empleados={empleados} meses={meses} />
</>
)
}
setNota ahora solo vuelve a ejecutar NotaInforme. React no toca PanelNominas ni TablaNominas. Medido en el laboratorio con la misma compilación sin compilador:
== base, estado separado (ejercicio 6)
12 teclas | mediana 128 ms | peor 1800 ms | INP web-vitals: 1800 (poor)
12 teclas | mediana 128 ms | peor 1392 ms | INP web-vitals: 1392 (poor)
12 teclas | mediana 104 ms | peor 1544 ms | INP web-vitals: 1544 (poor)
La mediana por tecla (104-128 ms) es la misma que con el compilador, sin depender de él. Y la peor tecla sigue siendo la primera, la que espera a la hidratación de la tabla, exactamente como en el apartado 10.4. Es la técnica de “bajar el estado” (state colocation) del Curso de React, y sigue siendo la primera que debes probar: el compilador es una red de seguridad, no una excusa para un mal reparto del estado.
Ejercicio 7: Comparar dos análisis
Guarda un análisis de tu proyecto con next analyze --output --snapshot antes, cambia el gráfico de ventas de chart.js/auto al registro selectivo (en todos los sitios donde se use) y guarda otro con --snapshot despues. Exporta los dos con next analyze export y escribe un script que muestre la diferencia de tamaño de los módulos de chart.js.
Ver solución
Es el flujo del apartado 8.3. Antes del cambio:
npx next analyze --output --snapshot antes
npx next analyze export --snapshot antes > antes.jsonl
Cambia todos los import Chart from 'chart.js/auto' por el registro selectivo (busca en el proyecto chart.js/auto; si queda uno solo, el fragmento compartido seguirá llevando la biblioteca entera, como viste en el apartado 8.4). Después:
npx next analyze --output --snapshot despues
npx next analyze export --snapshot despues > despues.jsonl
node scripts/comparar-analisis.mjs antes.jsonl despues.jsonl /gestion/ventas/estatica
El script comparar-analisis.mjs es el del apartado 8.2: lee las líneas part de cada exportación, se queda con las de la ruta y con los ficheros de /static/chunks/ (el cliente), y suma size y compressed_size por paquete. En el laboratorio, chart.js bajó de 189,3 KB (63,9 KB comprimido trozo a trozo) a 134,6 KB (47,0 KB).
Dos detalles:
- Las instantáneas con nombre se guardan dentro de
.next/diagnostics/analyze: si borras.next, desaparecen. Guarda los.jsonlexportados (o copia esa carpeta) si quieres conservarlos. next analyze export --route /gestion/ventas/estaticalimita la exportación a una ruta (con el grafo de módulos completo), lo que reduce mucho el tamaño del fichero.
Ejercicio 8 (propuesto): La imagen del LCP en móvil
Repite la medición de la portada optimizada con un viewport de móvil ({ width: 390, height: 844 } y deviceScaleFactor: 3 en newContext). ¿Qué versión de la imagen elige el navegador con el sizes actual? ¿Cambia el elemento LCP? Prueba a quitar preload y compara FCP y LCP.
Ejercicio 9 (propuesto): El gráfico al hacer scroll
Coloca el gráfico de ventas al final de una página larga y haz que su código solo se descargue cuando el contenedor entre en pantalla, usando IntersectionObserver en un Client Component y next/dynamic. Mide con medir-js.mjs antes y después de hacer scroll (con page.mouse.wheel).
Ejercicio 10 (propuesto): Presupuesto de rendimiento
Escribe un script que falle (código de salida 1) si alguna ruta de la lista supera 160 KB comprimidos de JavaScript inicial o si /cwv/optimizada supera 2,5 s de LCP en el laboratorio. Lánzalo después de next build en tu flujo de trabajo, para que nadie pueda subir un chart.js/auto sin darse cuenta.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Core Web Vitals | LCP, INP, CLS (+ FCP, TTFB) | Se juzgan en el percentil 75 de las visitas reales; umbrales 2,5 s, 200 ms y 0,1 |
| Métricas de campo | useReportWebVitals de next/web-vitals | En un Client Component del layout raíz, con la función fuera del componente |
| Enviar métricas | navigator.sendBeacon, Route Handler | LCP, CLS e INP llegan al ocultar la página: el envío debe sobrevivir al cierre |
| Métricas de laboratorio | Playwright + PerformanceObserver + CDP | Siempre con next build/next start, red y CPU limitadas, varias repeticiones |
| LCP de imágenes | next/image, sizes, preload 🔄 16.0 | 5,8 s → 2,1 s en el laboratorio; preload en una sola imagen (en 15, priority) |
| CLS del streaming | <Suspense fallback> | El fallback debe medir lo que el contenido: 0,047 → 0 con un esqueleto |
| Fuentes | next/font | Fuente de reserva con size-adjust: cambia el aspecto, no el tamaño |
| Server Components | Sin 'use client' | Su código no viaja: marked en el servidor, 0 KB; en el cliente, +14 KB |
| Analizador de Turbopack | next analyze 🆕 16.1, --output, export 🆕 16.4 | Qué módulos carga cada ruta y por qué cadena de import |
| Analizador de webpack | @next/bundle-analyzer | Para --webpack y para Next.js 15 |
Tamaños en build | Eliminados 🔄 16.0 | Mide lo que se descarga de verdad |
| Carga diferida | next/dynamic, ssr: false, loading | No reduce bytes si el componente se pinta al cargar; bajo demanda, sí |
| Tree shaking | Fragmentos compartidos | Una importación completa en cualquier sitio anula el ahorro en todas las rutas |
| React Compiler | reactCompiler 🔄 16.0 | Memoriza automáticamente; mejora el INP de renders innecesarios |
| Compilador nativo | experimental.turbopackRustReactCompiler 🆕 16.3 | Sin Babel y sin instalar nada |
| Caché de Turbopack | turbopackFileSystemCacheForDev 🆕 16.1, ...ForBuild 🔄 16.3 | Reinicios de dev y builds sucesivos más rápidos |