Rendimiento web con Nuxt 3: dónde se gana de verdad
Estrategias de render en Nuxt 3 (SSR, SSG, ISR) y cómo atacar LCP, CLS e INP con routeRules, fuentes autoalojadas e imágenes. Con las decisiones reales de este portfolio.
Rendimiento web con Nuxt 3: dónde se gana de verdad
Casi todo el trabajo de rendimiento que veo empieza por el sitio equivocado. Alguien abre Lighthouse, ve un 72, y se pone a mover cosas hasta que el número sube. Comprime imágenes, quita una librería, añade un lazy por aquí. El número sube. El sitio no va más rápido.
El problema es que Lighthouse mide un laboratorio: un dispositivo simulado, una red simulada, una carga en frío sin caché. Lo que le pasa a tus usuarios está en el campo — los datos que Chrome recoge de verdad, los que Google usa para posicionar. Y ahí la métrica que casi siempre falla no es el peso del bundle.
Lo que sigue son las decisiones que sí mueven la aguja, en el orden en que las tomo.
Primero: qué se renderiza y cuándo
Antes de optimizar nada, hay que decidir cómo se genera cada página. En Nuxt 3 esto no es una decisión global — es por ruta, y ahí está el matiz que se pierde.
El antipatrón es tratar toda la aplicación igual:
// ❌ Antipatrón: todo SSR porque "el SEO"
// nuxt.config.ts
export default defineNuxtConfig({
ssr: true,
})
Cada visita a cada ruta invoca una función en el servidor. Un artículo de blog que no cambia en seis meses se vuelve a renderizar en cada petición, y el usuario paga ese tiempo en su LCP.
routeRules permite decidir ruta por ruta:
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
routeRules: {
// Contenido estático: se genera en el build, se sirve desde CDN
'/blog': { prerender: true },
'/blog/**': { prerender: true },
// Contenido que cambia: regenerado en background cada hora
'/proyectos': { isr: 3600 },
// Zona privada: nunca cacheada, sin prerender
'/panel/**': { ssr: true, headers: { 'cache-control': 'no-store' } },
// Widget aislado que no necesita HTML del servidor
'/embed/**': { ssr: false },
},
},
})
Las cuatro estrategias, con lo que cuesta cada una:
| Estrategia | Cuándo | Coste |
|---|---|---|
prerender | El contenido se conoce en build time | Build más largo; hay que rebuildear para actualizar |
isr | Cambia, pero tolera estar unos minutos obsoleto | Primera visita tras expirar paga el render |
ssr | Personalizado por usuario o petición | Función en cada visita, siempre |
ssr: false | Detrás de login, sin valor SEO | Pantalla en blanco hasta que hidrata |
En este portfolio el blog entero es prerender. No hay razón para que un servidor calcule en tiempo real un artículo que escribí hace tres semanas. El HTML sale del CDN, y el LCP se convierte en un problema de red, no de cómputo.
Nota:
prerender: trueenrouteRulesmarca la ruta como prerenderizable, pero Nitro tiene que encontrarla. Para páginas alcanzables por enlaces basta concrawlLinks: true. Para las que no —feeds, sitemaps, rutas dinámicas sin enlace— hay que listarlas ennitro.prerender.routes.
LCP: casi siempre es una fuente o una imagen
El Largest Contentful Paint mide cuándo aparece el elemento más grande del viewport. En la práctica, ese elemento es un titular o una imagen de cabecera. Y lo que lo retrasa suele ser uno de dos culpables.
Fuentes que bloquean
El patrón por defecto de casi todo el mundo es enlazar Google Fonts desde su CDN:
<!-- ❌ Antipatrón -->
<link href="https://fonts.googleapis.com/css2?family=Manrope&display=swap" rel="stylesheet">
Eso son dos conexiones nuevas antes de poder pintar texto: una a fonts.googleapis.com para el CSS, otra a fonts.gstatic.com para el .woff2. En una red móvil real, el handshake TLS de cada una cuesta más que la propia fuente.
La alternativa es autoalojarlas. El módulo de Google Fonts las descarga en build time y las sirve desde tu propio dominio:
// nuxt.config.ts
googleFonts: {
download: true, // descarga los ficheros al build
inject: true, // inyecta el @font-face, sin request de CSS
display: 'swap',
preload: true,
subsets: ['latin'], // no te lleves cirílico si no lo usas
families: {
Manrope: { wght: [300] },
'JetBrains Mono': { wght: [400] },
},
}
El detalle que más pesa está en families. Pedir Manrope: true trae todos los pesos —de 200 a 800, más itálicas—, y son cientos de kilobytes que nadie usa. Declarar los pesos exactos que aparecen en el diseño suele recortar más que cualquier optimización de imágenes.
Imágenes sin dimensiones
<NuxtImg> genera srcset y sirve WebP o AVIF según lo que soporte el navegador. Pero solo funciona si le das el trabajo hecho:
<!-- ❌ Antipatrón: sin dimensiones, sin prioridad -->
<img src="/images/hero.jpg" alt="Cabecera">
<!-- Correcto -->
<NuxtImg
src="/images/hero.jpg"
alt="Cabecera"
width="1280"
height="720"
format="webp"
preload
loading="eager"
/>
width y height no son para dimensionar —el CSS sigue mandando— sino para que el navegador reserve el hueco antes de descargar. Sin ellos, el contenido salta cuando la imagen llega, y eso es CLS.
preload y loading="eager" van solo en la imagen del primer viewport. Aplicarlos a todas es peor que no aplicarlos: compites contigo mismo por el ancho de banda y retrasas justo la que importa.
CLS: el salto que tú mismo provocas
El Cumulative Layout Shift acumula cada desplazamiento inesperado de contenido. Las imágenes sin dimensiones son la causa clásica, pero hay una que se cuela en casi todos los proyectos con tema oscuro: el propio selector de tema.
Si el tema se lee en un composable durante la hidratación, el usuario ve el sitio en claro y luego salta a oscuro. No es CLS técnicamente —no se mueve el layout— pero es exactamente igual de molesto, y es un FOUC en toda regla.
La única forma de evitarlo es resolver el tema antes del primer pintado, con un script síncrono en el <head>:
// nuxt.config.ts — app.head.script
{
innerHTML: `(function(){
var d = document.documentElement
var t = localStorage.getItem('theme')
if (t !== 'light' && t !== 'dark') {
t = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
d.setAttribute('data-theme', t)
})()`,
type: 'text/javascript',
}
Sí, es un script inline y bloquea. Bloquear aquí es lo correcto: son microsegundos de lectura de localStorage a cambio de eliminar un parpadeo que el usuario percibe como un fallo. Es el caso raro en que la respuesta a "¿bloqueo el render?" es que sí.
INP: la métrica que cuesta ver venir
Desde 2024, INP (Interaction to Next Paint) sustituyó a FID en Core Web Vitals. Y es más dura: FID solo medía el retardo hasta empezar a procesar la primera interacción; INP mide la interacción completa —desde el clic hasta que se pinta la respuesta— y se queda con la peor de toda la sesión.
Lo que suele romperla en una app Nuxt no es el JavaScript de negocio. Son los listeners de scroll:
// ❌ Antipatrón: trabajo de layout en cada frame de scroll
window.addEventListener('scroll', () => {
const sections = document.querySelectorAll('section')
sections.forEach((section) => {
const rect = section.getBoundingClientRect() // fuerza reflow
if (rect.top < window.innerHeight / 2) setActive(section.id)
})
})
getBoundingClientRect() obliga al navegador a recalcular el layout, y ahí está en un handler que dispara docenas de veces por segundo. El hilo principal se satura y cualquier clic del usuario espera.
IntersectionObserver hace lo mismo sin tocar el hilo principal:
// composables/useSectionSpy.ts
export function useSectionSpy(selector: string) {
const active = ref<string | null>(null)
onMounted(() => {
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) active.value = entry.target.id
}
},
{ rootMargin: '-50% 0px -50% 0px' },
)
document.querySelectorAll(selector).forEach(el => observer.observe(el))
onUnmounted(() => observer.disconnect())
})
return { active }
}
El navegador calcula las intersecciones fuera del hilo principal y solo te avisa cuando algo cruza el umbral. La regla general: si escribes addEventListener('scroll'), casi siempre existe un observer que hace ese trabajo mejor.
Dónde el handler sigue estando justificado: si ya usas una librería de scroll suave como Lenis, tu página vive dentro de un
requestAnimationFramepropio, y el observer no siempre se sincroniza con la posición interpolada que la librería está pintando. Ahí un handler puede ser la opción correcta — pero entonces el trabajo dentro de él tiene que ser mínimo. Cachea los rects enmounty enresize, no los recalcules en cada frame. El pecado no es el listener; es llamar agetBoundingClientRect()dentro de él.
Lo que no merece la pena optimizar
Tan importante como saber dónde ganar es saber dónde estás perdiendo el tiempo:
- Reducir el bundle de 180 kB a 160 kB. En una conexión decente son milisegundos. El mismo esfuerzo puesto en la estrategia de render vale diez veces más.
- Perseguir el 100 en Lighthouse. De 95 a 100 se va en detalles que ningún usuario nota. De 60 a 90 sí se nota.
- Micro-optimizar
computed. Vue 3 cachea agresivamente. Si tienes un problema de reactividad, es de arquitectura, no de si envolviste algo en uncomputed. - Preload de todo.
preloades un presupuesto. Si precargas diez recursos, no has priorizado ninguno.
¿Cuándo aplicar qué?
| Escenario | Estrategia |
|---|---|
| Blog o web corporativa | prerender todo + fuentes autoalojadas |
| E-commerce con catálogo grande | isr en producto, prerender en estáticas |
| Dashboard tras login | ssr: false y foco en INP |
| App con contenido por usuario | ssr + caché de datos en servidor |
Conclusión
El orden que sigo, y que rara vez cambia:
- Decide la estrategia de render por ruta antes de tocar nada. Es la única decisión con un orden de magnitud de diferencia.
- Autoaloja las fuentes y declara los pesos exactos. Suele ser la mayor ganancia individual de LCP.
- Dimensiona toda imagen, prioriza solo la del primer viewport.
- Resuelve el tema antes del primer pintado, aunque eso signifique un script bloqueante.
- Sustituye listeners de scroll por observers, que es donde se juega el INP.
- Mide en campo, no en laboratorio. Lighthouse orienta; CrUX es lo que tienen tus usuarios y lo que ve Google.
Lo que no está en esta lista —comprimir un poco más, quitar una dependencia— no es que esté mal. Es que se hace después, y casi nunca cambia nada.
¿Preguntas o quieres ver algún caso concreto? Escríbeme a hola@miguel-jimenez.dev.