1. Introducción contextual
En los últimos años, Google ha convertido los Core Web Vitals (CWV) en el principal criterio de calidad para la experiencia de usuario y, por ende, para el posicionamiento SEO. Los tres indicadores – Largest Contentful Paint (LCP), First Input Delay (FID) y Cumulative Layout Shift (CLS) – miden, respectivamente, la velocidad de carga percibida, la capacidad de respuesta y la estabilidad visual de una página. Un sitio que no alcanza los umbrales recomendados (LCP < 2.5 s, FID < 100 ms, CLS < 0.1) sufre penalizaciones en rankings y, lo que es peor, pierde conversiones.
Según el Google Search Console 2023, más del 55 % de los sitios web con tráfico superior a 10 k visitas mensuales presentan al menos un Core Web Vital fuera de rango. En el ecosistema de React y Next.js, la flexibilidad que brinda el framework para combinar rendering estático y dinámico a menudo lleva a decisiones de arquitectura que impactan directamente en estos métricos.
En 2021, Next.js 12 introdujo Middleware, Edge Functions y mejoras en la generación estática, proporcionando herramientas específicas para atacar los cuellos de botella de CWV. Sin embargo, la gran cantidad de configuraciones posibles genera confusión: ¿debería usar getStaticProps o getServerSideProps? ¿Cómo optimizar imágenes sin sacrificar calidad? ¿Qué estrategia de carga de JavaScript minimiza el FID?
Resolver estas preguntas hoy es crítico porque el Core Web Vitals Score pasa a ser un factor de ranking permanente y, al mismo tiempo, los usuarios móviles esperan tiempos de carga sub‑segundo. En este artículo, desglosaremos la arquitectura de Next.js enfocada en CWV, mostraremos implementaciones paso‑a‑paso con código listo para producción y analizaremos casos de uso reales donde la optimización marcó la diferencia en métricas de negocio.
2. Fundamentos y Arquitectura
2.1. Qué miden los Core Web Vitals
| Métrica | Qué evalúa | Umbral recomendado | Impacto directo en Next.js |
|---|---|---|---|
| LCP | Tiempo que tarda el elemento más grande visible en cargarse (imagen, video o bloque de texto) | ≤ 2.5 s | Rendering del HTML, optimización de imágenes, uso de ISR |
| FID | Latencia entre la primera interacción del usuario y la respuesta del thread principal | ≤ 100 ms | Tamaño del bundle JavaScript, uso de dynamic() y React.lazy |
| CLS | Suma de todos los cambios inesperados de layout durante la carga | < 0.1 | Evitar fuentes sin font-display, reservar espacio para imágenes y ads |
2.2. Principios de arquitectura para CWV en Next.js
- Renderizado estático siempre que sea posible –
getStaticProps+ ISR generan HTML en tiempo de compilación, reduciendo el tiempo de respuesta del servidor y, por ende, el LCP. - Carga diferida y priorización – Utilizar el componente
next/imageconprioritysólo para el hero yloading="lazy"para el resto, evitando desplazamientos inesperados (CLS). - Fragmentación de JavaScript –
dynamic()permite dividir el bundle y cargar módulos bajo demanda, disminuyendo el FID. - Edge Caching y Middleware – Respuestas desde la red de borde (Vercel Edge, Cloudflare Workers) reducen la latencia de red, mejorando LCP y FID simultáneamente.
- Observabilidad y métricas en tiempo real – Integrar la Web Vitals API (
web-vitals) para capturar datos reales en producción y cerrar el bucle de mejora continua.
2.3. Diagrama conceptual (descrito en texto)
- Cliente → CDN Edge (caché estático HTML + assets) → Next.js Server (Middleware → API Routes) → Base de datos / CMS.
- El flujo de renderizado sigue dos caminos:
- ISR: HTML estático pre‑generado + revalidación en background.
- SSR: HTML generado on‑demand para páginas altamente dinámicas.
- next/image intercepta peticiones de imágenes, sirve versiones optimizadas (WebP, AVIF) y gestiona placeholders.
- Dynamic Imports: Cada página importa solo los componentes críticos; el resto se carga bajo demanda.
2.4. Trade‑offs críticos
| Decisión | Ventaja | Desventaja |
|---|---|---|
| ISR vs SSR | ISR: menor tiempo de respuesta, menos carga de servidor. | ISR: datos pueden estar ligeramente desactualizados (hasta revalidate). |
| next/image con placeholders | Mejora CLS y LCP al reservar espacio y cargar imágenes progresivamente. | Overhead de procesamiento de imágenes en build; requiere configuración de loader. |
| Edge Functions | Latencia mínima, escalabilidad global. | Limitaciones de tiempo de ejecución y tamaño de bundle en el edge. |
| Dynamic import de componentes UI | Reduce FID al cargar solo lo necesario. | Posible flash of unstyled content (FOUC) si no se gestiona correctamente. |
3. Implementación práctica
A continuación, presentaremos cinco evoluciones típicas que van de una solución naïve a una arquitectura CWV‑ready. Cada bloque incluye imports, tipos, manejo de errores y comentarios en español.
3.1. Optimización de imágenes
3.1.1. Versión básica (uso de <img> tradicional)
// pages/index.tsx
import React from 'react';
export default function HomePage() {
return (
<section>
<h1>Bienvenido a nuestro catálogo</h1>
{/* Imagen sin optimización ni reserva de espacio */}
<img src="/images/hero.jpg" alt="Producto destacado" />
<p>Descubre nuestras ofertas.</p>
</section>
);
}
Problemas: No hay lazy loading, la imagen se descarga a tamaño completo y puede generar CLS si el navegador tarda en calcular sus dimensiones.
3.1.2. Versión intermedia (next/image con layout="responsive")
// pages/index.tsx
import React from 'react';
import Image from 'next/image';
import path from 'path';
export default function HomePage() {
return (
<section>
<h1>Bienvenido a nuestro catálogo</h1>
{/* next/image reserva espacio y sirve versión optimizada */}
<Image
src="/images/hero.jpg"
alt="Producto destacado"
width={1600}
height={900}
layout="responsive"
priority // carga temprana porque es hero
/>
<p>Descubre nuestras ofertas.</p>
</section>
);
}
Mejoras: next/image genera automáticamente WebP/AVIF, reserva espacio y permite prioridad. Aún falta un placeholder para evitar flashes de contenido blanco.
3.1.3. Versión optimizada (placeholder blur + lazy loading)
// pages/index.tsx
import React from 'react';
import Image from 'next/image';
import { GetStaticProps } from 'next';
import fs from 'fs';
import path from 'path';
/**
* Obtiene la ruta de la versión blur (base64) para el placeholder.
* En producción se recomienda usar un loader externo (Imgix, Cloudinary).
*/
export const getStaticProps: GetStaticProps = async () => {
const placeholderPath = path.join(process.cwd(), 'public', 'images', 'hero-blur.jpg');
const placeholderBuffer = await fs.promises.readFile(placeholderPath);
const placeholderBase64 = placeholderBuffer.toString('base64');
return {
props: {
placeholder: `data:image/jpeg;base64,${placeholderBase64}`,
},
};
};
type Props = {
placeholder: string;
};
export default function HomePage({ placeholder }: Props) {
return (
<section>
<h1>Bienvenido a nuestro catálogo</h1>
<Image
src="/images/hero.jpg"
alt="Producto destacado"
width={1600}
height={900}
layout="responsive"
placeholder="blur"
blurDataURL={placeholder}
priority // hero sigue siendo prioritaria
/>
<p>Descubre nuestras ofertas.</p>
</section>
);
}
Ventajas:
- CLS se elimina porque el espacio está reservado y el placeholder mantiene la proporción.
- LCP mejora al servir una versión optimizada y priorizada.
- El código incluye manejo de errores implícitos mediante
await fs.promises.readFile; si falla, Next.js lanzará una excepción que detendrá la compilación, evitando deploys rotos.
3.2. Estrategia de datos: de SSR a ISR + SWR
3.2.1. SSR con getServerSideProps (naïve)
// pages/product/[id].tsx
import { GetServerSideProps, NextPage } from 'next';
import { useRouter } from 'next/router';
import axios from 'axios';
type Product = {
id: string;
name: string;
price: number;
description: string;
};
type Props = {
product: Product;
};
export const getServerSideProps: GetServerSideProps<Props> = async (context) => {
const { id } = context.params!;
try {
const { data } = await axios.get<Product>(`https://api.example.com/products/${id}`);
return { props: { product: data } };
} catch (error) {
// Devolver 404 si el producto no existe
return { notFound: true };
}
};
const ProductPage: NextPage<Props> = ({ product }) => {
const router = useRouter();
if (router.isFallback) return <p>Cargando...</p>;
return (
<article>
<h1>{product.name}</h1>
<p>{product.description}</p>
<strong>${product.price}</strong>
</article>
);
};
export default ProductPage;
Problemas: Cada visita genera una petición al API, aumentando tiempo de respuesta (LCP) y carga del servidor. Además, el bundle incluye axios aunque solo se usa en el servidor.
3.2.2. ISR con getStaticProps + revalidación
// pages/product/[id].tsx
import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
import axios from 'axios';
import { useRouter } from 'next/router';
import { SWRConfig } from 'swr';
import useSWR from 'swr';
type Product = {
id: string;
name: string;
price: number;
description: string;
};
type Props = {
fallback: { [key: string]: Product };
};
const fetcher = (url: string) => axios.get(url).then((res) => res.data);
export const getStaticPaths: GetStaticPaths = async () => {
// Pre‑generar solo los productos más populares
const { data: popular } = await axios.get<string[]>(
'https://api.example.com/products/popular-ids'
);
const paths = popular.map((id) => ({ params: { id } }));
return { paths, fallback: true };
};
export const getStaticProps: GetStaticProps<Props> = async (context) => {
const { id } = context.params!;
try {
const { data } = await axios.get<Product>(`https://api.example.com/products/${id}`);
return {
props: {
fallback: {
[`/api/products/${id}`]: data,
},
},
revalidate: 60, // 1 minuto de frescura
};
} catch {
return { notFound: true };
}
};
const ProductPage: NextPage<Props> = ({ fallback }) => {
const router = useRouter();
const { id } = router.query as { id: string };
const { data: product, error } = useSWR<Product>(`/api/products/${id}`, fetcher, {
fallbackData: fallback[`/api/products/${id}`],
});
if (error) return <p>Error al cargar el producto.</p>;
if (!product) return <p>Cargando...</p>;
return (
<article>
<h1>{product.name}</h1>
<p>{product.description}</p>
<strong>${product.price}</strong>
</article>
);
};
export default ProductPage;
Mejoras:
- LCP se reduce porque el HTML está pre‑generado y servido desde CDN.
- FID disminuye al cargar solo el bundle necesario; el resto de datos se actualiza en background con SWR.
- Revalidación garantiza frescura sin sacrificar rendimiento.
3.2.3. Versión final con Edge Middleware y caché de API
// middleware.ts (Edge)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// Cachear respuestas de API por 30 segundos en el edge
export async function middleware(request: NextRequest) {
const url = request.nextUrl.clone();
// Solo interceptar rutas de API de productos
if (url.pathname.startsWith('/api/products/')) {
const response = await fetch(request);
const cloned = response.clone();
// Añadir encabezados de cache-control
const headers = new Headers(cloned.headers);
headers.set('Cache-Control', 'public, max-age=30, stale-while-revalidate=60');
return new NextResponse(cloned.body, {
status: cloned.status,
headers,
});
}
// Pasar el resto de peticiones sin modificar
return NextResponse.next();
}
// pages/api/products/[id].ts
import type { NextApiRequest, NextApiResponse } from 'next';
import axios from 'axios';
type Product = {
id: string;
name: string;
price: number;
description: string;
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<Product | { error: string }>
) {
const { id } = req.query;
if (typeof id !== 'string') {
res.status(400).json({ error: 'ID inválido' });
return;
}
try {
const { data } = await axios.get<Product>(`https://api.example.com/products/${id}`);
// Responder con caché controlado por middleware
res.setHeader('Cache-Control', 'public, max-age=0, s-maxage=30, stale-while-revalidate=60');
res.status(200).json(data);
} catch (err) {
res.status(404).json({ error: 'Producto no encontrado' });
}
}
Impacto: El Edge Middleware sirve respuestas de API directamente desde la capa de borde, reduciendo latencia de red (LCP) y evitando sobrecargar el servidor de origen. Además, la estrategia de stale‑while‑revalidate mantiene la disponibilidad incluso cuando la origin está bajo carga.
3.3. Reducción del FID mediante carga diferida de JavaScript
3.3.1. Bundle monolítico (naïve)
// pages/dashboard.tsx
import React, { useEffect, useState } from 'react';
import Chart from 'chart.js';
import { getAnalytics } from '../lib/analytics';
export default function Dashboard() {
const [data, setData] = useState<number[]>([]);
useEffect(() => {
// Carga pesada en el cliente
getAnalytics().then(setData);
const ctx = document.getElementById('my
