🇺🇸 EN
Frontend
·10 min read·↗·🇺🇸 EN

Rendimiento en Next.js: lo que la configuración por defecto no resuelve por ti

Portada principal del artículo: Rendimiento en Next.js: lo que la configuración por defecto no resuelve por ti

Next.js ofrece una base sólida de rendimiento por defecto. Pero rápido por defecto no significa rápido siempre.

Durante los últimos días estuve trabajando con Next.js y React Server Components, asumiendo que el framework se encargaría de casi todos los temas de rendimiento por mí. Y para ser honesto, hace un gran trabajo nada más sacarlo de la caja.

Sin embargo, empecé a notar patrones conocidos: trabajo duplicado en el servidor, JavaScript innecesario enviado al cliente y métricas de Web Vitals como First Contentful Paint (FCP) empeorando poco a poco.

Quiero compartir lo que aprendí mientras solucionaba estos problemas. No peleando contra Next.js, sino entendiendo cómo funciona y cómo pequeñas decisiones conscientes pueden mejorar el rendimiento de forma notable.

Escenario

Vamos a trabajar con un escenario típico que se ve así: tienes una aplicación que obtiene datos desde una API. Esa misma llamada a la API se reutiliza en varios componentes: algunos renderizan listas, otros calculan estadísticas y otros alimentan visualizaciones de datos.

A medida que la aplicación evoluciona, agregamos librerías de terceros para mejorar la interfaz. Los gráficos y paneles funcionan muy bien, pero muchas veces terminan incluidos en el bundle inicial de JavaScript, incluso cuando no son visibles de inmediato.

El resultado es una aplicación correcta, estable y completa a nivel de funcionalidades, pero cuyas métricas de rendimiento comienzan a degradarse. En particular, el FCP se vuelve más lento de lo esperado, afectando tanto la experiencia del usuario como los Web Vitals.

Lo importante aquí: la aplicación funciona, pero necesita mejorar su rendimiento.

El objetivo de este artículo es tomar este escenario real y aplicar una serie de mejoras pequeñas y puntuales para hacerlo más rápido, ligero y predecible.

Si quieres seguir el paso a paso, te recomiendo descargar el código fuente:

git clone https://github.com/danywalls/improve-nextjs.git

Cómo se obtienen y comparten los datos entre componentes

En el centro de esta aplicación hay una única fuente de datos: una función de servidor encargada de obtener productos desde una API externa.

export async function getProducts(): Promise<Product[]> {
  try {
    const response = await fetch("https://dummyjson.com/products");
    const { products }: ProductsResponse = await response.json();
    return products;
  } catch (error) {
    throw new Error("Failed to fetch products");
  }
}

Varios componentes reutilizan esta función. Uno de ellos renderiza la lista de productos:

import { getProducts, Product } from "@/lib/products/products";
import ProductCard from "./product-card";
 
export default async function Products() {
  const products = await getProducts();
  return (
    <section>
      <h2 className="text-2xl font-bold">Products</h2>
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
        {products.map((product: Product) => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
    </section>
  );
}

Otro componente usa los mismos datos para calcular estadísticas:

import { getProducts, Product } from "@/lib/products/products";
 
export default async function ProductsStats() {
  const products = await getProducts();
  const totalProducts = products.length;
  const avgPrice =
    products.reduce((acc: number, product: Product) => acc + product.price, 0) /
    totalProducts;
  return (
    <section className="rounded-xl bg-gradient-to-br from-white to-gray-50 dark:from-gray-900 dark:to-gray-800 shadow-lg border border-gray-200 dark:border-gray-800 p-6 mb-8 flex flex-col md:flex-row items-center md:justify-between gap-8">
      <h2 className="text-2xl font-bold">Products Stats</h2>
      <p>Total Products: {totalProducts}</p>
      <p>Average Price: ${avgPrice.toFixed(2)}</p>
    </section>
  );
}

A primera vista, esto parece totalmente razonable. Pero hay un detalle importante oculto aquí: cada componente llama a la misma función del servidor de forma independiente.

Veamos por qué esto importa.

Evitar trabajo duplicado en el servidor con cache de React

Consultar los mismos datos varias veces durante una sola petición es trabajo innecesario. Incrementa la carga del servidor y añade latencia sin aportar ningún beneficio.

React ofrece una forma muy sencilla de evitar este trabajo duplicado usando cache.

import { cache } from "react";
 
export const getProducts = cache(async (): Promise<Product[]> => {
  try {
    const response = await fetch("https://dummyjson.com/products");
    const { products }: ProductsResponse = await response.json();
    return products;
  } catch (error) {
    throw new Error("Failed to fetch products");
  }
});

Con este cambio, la petición se ejecuta una sola vez por request y el resultado se reutiliza en todos los Server Components. Esto funciona porque tanto Products como ProductsStats son Server Components asíncronos, y React comparte automáticamente el resultado en caché durante el mismo ciclo de renderizado.

La eficiencia del servidor ya está bajo control, pero esa es solo la mitad de la historia. Veamos qué sucede en el cliente.

Librerías de terceros

Los dashboards modernos suelen apoyarse en librerías de terceros para mostrar gráficos y visualizaciones. En este ejemplo, el dashboard usa recharts.

"use client";
 
import { useEffect, useState } from "react";
import { LineChart, Line, XAxis, YAxis, ResponsiveContainer } from "recharts";
import { getProducts, Product } from "@/lib/products/products";
 
export default function Dashboard() {
  const [data, setData] = useState<Product[]>([]);
 
  useEffect(() => {
    const fetchData = async () => {
      const products = await getProducts();
      setData(products);
    };
    fetchData();
  }, []);
 
  return (
    <ResponsiveContainer width="100%" height={400}>
      <LineChart data={data}>
        <XAxis dataKey="title" />
        <YAxis />
        <Line dataKey="price" />
      </LineChart>
    </ResponsiveContainer>
  );
}

Esto funciona, pero tiene un costo.

Las librerías de gráficos son pesadas. Cuando quedan incluidas en el bundle inicial, ralentizan la hidratación e impactan negativamente en el FCP.

La solución no es eliminar la librería, sino cargarla solo cuando realmente se necesita.

Reducir el tamaño del bundle con Dynamic Imports

Primero, extraemos el gráfico a un Client Component dedicado:

"use client";
 
import { LineChart, Line, XAxis, YAxis, ResponsiveContainer } from "recharts";
import { Product } from "@/lib/products/products";
 
interface ChartWrapperProps {
  data: Product[];
}
 
export default function ChartWrapper({ data }: ChartWrapperProps) {
  return (
    <ResponsiveContainer width="100%" height={400}>
      <LineChart data={data}>
        <XAxis dataKey="title" />
        <YAxis />
        <Line dataKey="price" />
      </LineChart>
    </ResponsiveContainer>
  );
}

Luego, en el componente del dashboard, usamos dynamic para importar el componente. Configuramos ssr: false porque recharts es una librería de cliente que utiliza APIs del navegador y no se puede renderizar en el servidor.

"use client";
 
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { getProducts, Product } from "@/lib/products/products";
 
const ChartWrapper = dynamic(() => import("./chartwrapper"), {
  ssr: false,
  loading: () => (
    <div className="flex items-center justify-center h-[400px] bg-gray-50 dark:bg-gray-800 rounded-lg">
      <div className="text-gray-500 dark:text-gray-400">Loading chart...</div>
    </div>
  ),
});
 
export default function Dashboard() {
  const [data, setData] = useState<Product[]>([]);
 
  useEffect(() => {
    const fetchData = async () => {
      const products = await getProducts();
      setData(products);
    };
    fetchData();
  }, []);
 
  return <ChartWrapper data={data} />;
}

Con esto obtenemos un bundle inicial de JS más liviano (recharts queda fuera del bundle principal), una hidratación más rápida (menos JavaScript por analizar y ejecutar al inicio) y el gráfico cargando solo cuando se necesita (la librería se descarga bajo demanda al renderizar el componente).

Ahora que el trabajo en el servidor no se duplica y el bundle del cliente es más liviano, todavía nos queda un detalle por resolver: coordinar los estados de carga.

Mejorar el rendimiento percibido: Suspense

Incluso con la obtención de datos optimizada y bundles más pequeños, la forma en que cargan los elementos importa. Sin una buena coordinación, partes de la interfaz pueden bloquearse entre sí, provocando espacios en blanco o retrasos al mostrar el contenido.

Suspense nos permite hacer que la carga sea explícita y enviar contenido mediante streaming de forma progresiva.
Por último, agreguemos Suspense para controlar este comportamiento de carga. Con Suspense logramos:

  • La página se renderiza de inmediato: la estructura base de la página aparece al instante.

  • El estado de carga es explícito: los usuarios ven skeletons de carga en lugar de pantallas o espacios en blanco.

  • Soporte para streaming: cada Server Component se puede renderizar de forma independiente a medida que sus datos están listos.

  • Mejor rendimiento percibido: el contenido se muestra de manera progresiva en lugar de aparecer todo de golpe.

Suspense funciona de forma transparente con Server Components asíncronos. Cuando envolvemos un Server Component con Suspense, React puede enviar su contenido al cliente vía streaming apenas esté listo, sin bloquear el resto de la página.

Primero, creamos dos componentes de tipo skeleton:

function ProductsStatsSkeleton() {
  return (
    <section className="rounded-xl bg-gradient-to-br from-white to-gray-50 dark:from-gray-900 dark:to-gray-800 shadow-lg border border-gray-200 dark:border-gray-800 p-6 mb-8 flex flex-col md:flex-row items-center md:justify-between gap-8 animate-pulse">
      <div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
      <div className="flex gap-4">
        <div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-32"></div>
        <div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-32"></div>
      </div>
    </section>
  );
}
 
function ProductsSkeleton() {
  return (
    <section>
      <div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-6 animate-pulse"></div>
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
        {[...Array(6)].map((_, i) => (
          <div
            key={i}
            className="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden border border-gray-200 dark:border-gray-700 animate-pulse"
          >
            <div className="aspect-square bg-gray-200 dark:bg-gray-700"></div>
            <div className="p-4 space-y-3">
              <div className="h-5 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
              <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full"></div>
              <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-2/3"></div>
              <div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-20"></div>
            </div>
          </div>
        ))}
      </div>
    </section>
  );
}

Luego, importamos Suspense desde React y actualizamos page.tsx para envolver los Server Components asíncronos:

import { Suspense } from "react";
import Products from "./_products/products";
import Dashboard from "./_dashboard/dashboard";
import ProductsStats from "./_products/products-stats";
 
function ProductsStatsSkeleton() {
  return (
    <section className="rounded-xl bg-gradient-to-br from-white to-gray-50 dark:from-gray-900 dark:to-gray-800 shadow-lg border border-gray-200 dark:border-gray-800 p-6 mb-8 flex flex-col md:flex-row items-center md:justify-between gap-8 animate-pulse">
      <div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
      <div className="flex gap-4">
        <div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-32"></div>
        <div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-32"></div>
      </div>
    </section>
  );
}
 
function ProductsSkeleton() {
  return (
    <section>
      <div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-6 animate-pulse"></div>
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
        {[...Array(6)].map((_, i) => (
          <div
            key={i}
            className="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden border border-gray-200 dark:border-gray-700 animate-pulse"
          >
            <div className="aspect-square bg-gray-200 dark:bg-gray-700"></div>
            <div className="p-4 space-y-3">
              <div className="h-5 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
              <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full"></div>
              <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-2/3"></div>
              <div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-20"></div>
            </div>
          </div>
        ))}
      </div>
    </section>
  );
}
 
export default function Home() {
  return (
    <div className="space-y-12">
      <div className="text-center py-8">
        <h1 className="text-5xl font-bold text-gray-900 dark:text-white mb-4 bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
          Welcome to Dashboard
        </h1>
        <p className="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
          Manage and analyze your products with real-time insights and beautiful
          visualizations
        </p>
      </div>
 
      <Suspense fallback={<ProductsStatsSkeleton />}>
        <ProductsStats />
      </Suspense>
 
      <Suspense fallback={<ProductsSkeleton />}>
        <Products />
      </Suspense>
 
      <Dashboard />
    </div>
  );
}

Bien, ¿qué logramos con esto?

Con Suspense, la estructura base de la página se renderiza de inmediato, cada sección carga de forma independiente y los usuarios reciben una respuesta visual clara en lugar de ver un espacio en blanco.

Resumen

Ninguno de estos cambios es enorme por sí solo. Sin embargo, combinados transforman una aplicación que simplemente funciona en una solución más predecible y escalable:

  • cache de React: evita el trabajo duplicado en el servidor.

  • Dynamic imports: reducen el bundle inicial de JavaScript.

  • Suspense: mejora el rendimiento percibido y el FCP.

Todas las optimizaciones se aplicaron sobre un único ejemplo conectado, lo que permite ver con claridad el impacto real de cada cambio. Next.js ofrece una base excelente por defecto, pero el rendimiento sigue siendo una decisión de diseño.

El framework te da las herramientas, pero saber cuándo y cómo usarlas es la clave para tu aplicación.

Foto de Konstantin Dyadyun en Unsplash

Parte de la serie Frontend

Modern frontend development — Angular, React, TypeScript, accessibility, and performance.

Ver serie completa

Artículos Relacionados

Compartir este artículo

Si esta guía te resultó útil, compártela con tu equipo o con otros desarrolladores.


Software real. Lecciones reales.

Comparto las lecciones que aprendí por las malas, para que puedas evitarlas o estar preparado cuando ocurran.

User avatar
User avatar
User avatar
User avatar
+13K

Únete a 13,800+ desarrolladores y lectores.

Cero spam. Cancela tu suscripción en cualquier momento.