Optimización de tamaño de bundle — Analiza, divide y reduce tu bundle de JavaScript del cliente
Receta
// Step 1: Install and configure bundle analyzer
// npm install -D @next/bundle-analyzer
// next.config.ts
import withBundleAnalyzer from "@next/bundle-analyzer";
const nextConfig = {
// your config
};
export default withBundleAnalyzer({
enabled: process.env.ANALYZE === "true",
})(nextConfig);
// Step 2: Analyze
// ANALYZE=true npm run build
// Opens treemap showing every module and its size
// Step 3: Dynamic import for heavy client components
import dynamic from "next/dynamic";
const Chart = dynamic(() => import("@/components/Chart"), {
loading: () => <div className="h-64 animate-pulse bg-gray-100 rounded" />,
ssr: false, // Skip SSR for client-only components
});
// Step 4: Named imports for tree-shaking
import { format } from "date-fns"; // 4KB — tree-shakes
// NOT: import dateUtils from "date-fns"; // 72KB — imports everythingCuándo usarlo: Cuando tu landing page carga más de 150KB gzipped de JavaScript, cuando tu app shell excede 300KB gzipped, o cuando Lighthouse marca "Reduce unused JavaScript."
Ejemplo en funcionamiento
// ---- BEFORE: Bloated bundle — 487KB gzipped client JS ----
// page.tsx — everything loaded eagerly
import { Chart } from "chart.js/auto"; // +180KB gzipped
import moment from "moment"; // +72KB gzipped
import _ from "lodash"; // +71KB gzipped
import { Editor } from "@monaco-editor/react"; // +120KB gzipped
import { motion } from "framer-motion"; // +44KB gzipped
export default function DashboardPage() {
const [showEditor, setShowEditor] = useState(false);
const [data, setData] = useState(fetchDashboardData());
// lodash used for one function
const sortedData = _.sortBy(data.items, "date");
// moment used for formatting
const dateStr = moment(data.lastUpdated).format("MMM DD, YYYY");
return (
<div>
<h1>Dashboard — Last updated: {dateStr}</h1>
<Chart data={sortedData} />
<motion.div animate={{ opacity: 1 }}>
<p>Animated content</p>
</motion.div>
{showEditor && <Editor language="json" value={JSON.stringify(data)} />}
<button onClick={() => setShowEditor(true)}>Open Editor</button>
</div>
);
}
// ---- AFTER: Optimized — 89KB gzipped client JS (82% reduction) ----
// page.tsx — Server Component by default (zero client JS for data fetching)
import { format } from "date-fns"; // 4KB — replaces 72KB moment
import { DashboardClient } from "./DashboardClient";
export default async function DashboardPage() {
// Server-side fetch — zero client JS
const data = await fetchDashboardData();
// date-fns with named import — tree-shakes to 4KB
const dateStr = format(data.lastUpdated, "MMM dd, yyyy");
// Sort with native JS — replaces 71KB lodash
const sortedData = [...data.items].sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
);
return (
<div>
<h1>Dashboard — Last updated: {dateStr}</h1>
<DashboardClient sortedData={sortedData} />
</div>
);
}
// DashboardClient.tsx — Minimal client component
"use client";
import dynamic from "next/dynamic";
// Dynamic import: Chart loaded only when visible (saves 180KB from initial load)
const Chart = dynamic(() => import("@/components/Chart"), {
loading: () => <div className="h-64 animate-pulse bg-gray-100 rounded" />,
ssr: false,
});
// Dynamic import: Editor loaded only when user clicks button (saves 120KB)
const Editor = dynamic(() => import("@monaco-editor/react").then((m) => m.Editor), {
loading: () => <div className="h-96 animate-pulse bg-gray-100 rounded" />,
ssr: false,
});
// Lightweight animation — CSS instead of framer-motion (saves 44KB)
// Or: import { LazyMotion, domAnimation, m } from "framer-motion"
// LazyMotion loads only 5KB instead of 44KB
export function DashboardClient({ sortedData }: { sortedData: DataItem[] }) {
const [showEditor, setShowEditor] = useState(false);
return (
<>
<Chart data={sortedData} />
<div className="animate-fadeIn">
<p>Animated content</p>
</div>
{showEditor && <Editor language="json" value={JSON.stringify(sortedData)} />}
<button onClick={() => setShowEditor(true)}>Open Editor</button>
</>
);
}Lo que esto demuestra:
moment(72KB) reemplazado con importación nombradadate-fns(4KB) — 68KB de ahorrolodash(71KB) reemplazado con.sort()nativo — 71KB de ahorro- Chart.js importado dinámicamente — 180KB diferido de la carga inicial
- Monaco Editor importado dinámicamente — 120KB cargado solo cuando es necesario
framer-motionreemplazado con animación CSS — 44KB de ahorro- Búsqueda de datos movida a Server Component — cero client JS para la búsqueda
- Total: 487KB a 89KB gzipped initial bundle (82% de reducción)
Análisis profundo
Cómo funciona
- @next/bundle-analyzer genera una visualización de treemap interactiva de tus bundles de webpack. Muestra tres bundles:
client(navegador),server(Node.js), yedge. Enfócate en el bundle de cliente ya que afecta el rendimiento del lado del usuario. - Importaciones dinámicas con
next/dynamiccrean chunks separados que se cargan bajo demanda. El componente no está incluido en el bundle inicial y se obtiene cuando se renderiza por primera vez. El componenteloadingse muestra mientras se carga el chunk. - Tree-shaking es la capacidad del bundler para eliminar exportaciones no utilizadas. Funciona con la sintaxis de módulo ES
import { x } from "mod"pero no con CommonJSrequire(). Las importaciones nombradas permiten al bundler analizar estáticamente qué exportaciones se usan. - Barrel files (
index.tsque re-exporta de múltiples módulos) pueden derrotar tree-shaking si el bundler no puede probar que no hay efectos secundarios. El camposideEffects: falseenpackage.jsonayuda, pero evitar barrel files para librerías grandes es más seguro. - Route-based code splitting sucede automáticamente en Next.js — cada página es un chunk separado. Route groups
(marketing)y(app)crean bundles separados, asegurando que páginas de marketing no carguen código específico de la aplicación.
Variaciones
React.lazy para proyectos que no son Next.js:
import { lazy, Suspense } from "react";
const HeavyChart = lazy(() => import("./HeavyChart"));
function Dashboard() {
return (
<Suspense fallback={<div className="h-64 animate-pulse bg-gray-100" />}>
<HeavyChart data={data} />
</Suspense>
);
}Importación dinámica condicional basada en viewport:
"use client";
import dynamic from "next/dynamic";
import { useInView } from "react-intersection-observer";
const HeavyWidget = dynamic(() => import("@/components/HeavyWidget"), {
ssr: false,
});
function LazySection() {
const { ref, inView } = useInView({ triggerOnce: true, rootMargin: "200px" });
return (
<div ref={ref}>
{inView ? <HeavyWidget /> : <div className="h-96" />}
</div>
);
}Analizando costos de dependencias específicas:
# Check the cost of any npm package before adding it
npx bundle-phobia-cli lodash
# lodash: 71.5KB minified, 25.3KB gzipped
# Alternative: use the bundlephobia.com website
# https://bundlephobia.com/package/lodashReemplazos comunes de librerías pesadas para reducir tamaño de bundle:
| Librería Pesada | Tamaño (gzipped) | Alternativa más ligera | Tamaño (gzipped) | Ahorro |
|---|---|---|---|---|
| moment | 72KB | date-fns (importaciones nombradas) | 4KB | 68KB |
| lodash | 25KB | lodash-es (importaciones nombradas) o JavaScript nativo | 0-2KB | 23KB+ |
| chart.js | 65KB | lightweight-charts o importación dinámica | 0KB inicial | 65KB diferido |
| framer-motion | 44KB | Animaciones CSS o LazyMotion | 0-5KB | 39KB+ |
| axios | 13KB | fetch nativo | 0KB | 13KB |
Notas de TypeScript
dynamic(() => import("./Component"))deduce los props del componente de la exportación por defecto del módulo importado.- Para exportaciones nombradas:
dynamic(() => import("./module").then((m) => m.NamedComponent)). - El prop
loadingrecibe{ error, isLoading, pastDelay }para personalización.
Trampa y errores
-
Las importaciones dinámicas agregan solicitudes de red — Cada componente importado dinámicamente se convierte en una solicitud HTTP separada. Demasiadas importaciones dinámicas en una sola página pueden crear una cascada de solicitudes. Solución: Agrupa componentes relacionados en un chunk dinámico único, o usa prefetching.
-
SSR: false oculta contenido de crawlers — Los componentes con
ssr: falseson invisibles para crawlers de motores de búsqueda y durante la representación inicial de HTML. Solución: Solo usassr: falsepara componentes verdaderamente interactivos (editores, canvas) que no pueden renderizarse en el servidor. -
Tree-shaking requiere módulos ES — Las librerías CommonJS (
require/module.exports) no pueden ser tree-shaken. Solución: Usa la variante de módulo ES cuando esté disponible (lodash-esen lugar delodash,date-fnsen lugar demoment). -
Re-exportaciones de barrel file — Un
index.tsque haceexport * from "./heavy-module"fuerza al bundler a incluir el módulo completo incluso si solo importas una función. Solución: Importa directamente del archivo fuente:import { fn } from "./lib/specific-module"en lugar deimport { fn } from "./lib". -
El análisis de bundle solo muestra tamaño sin comprimir — El treemap muestra tamaños de módulo sin procesar. El tamaño real de transferencia depende de compresión gzip/brotli. El código densamente textual se comprime bien; el código binario o minificado no. Solución: Verifica tanto el treemap como la pestaña Network para tamaños reales de transferencia.
-
Code splitting prematuro — Dividir un componente de 5KB en una importación dinámica agrega complejidad y una solicitud de red para ahorros mínimos. Solución: Solo importa dinámicamente componentes que sean al menos 30KB gzipped o que estén detrás de interacción del usuario (modales, editores, paneles de configuración).
Alternativas
| Enfoque | Compromiso |
|---|---|
next/dynamic | Incorporado en Next.js; maneja SSR, estados de carga; solo Next.js |
React.lazy + Suspense | Agnóstico del framework; sin soporte SSR sin configuración adicional |
| Route-based splitting | Automático en Next.js; sin control por componente |
| Server Components | Elimina client JS completamente para componentes no interactivos |
| Module federation | Comparte código entre micro-frontends; setup complejo |
| Import maps | Resolución de módulos nativa del navegador; soporte de navegador limitado |
FAQs
¿Cómo analizas el tamaño de bundle de tu Next.js?
Instala @next/bundle-analyzer, agrégalo a next.config.ts, y ejecuta:
ANALYZE=true npm run buildEsto abre un treemap interactivo mostrando cada módulo y su tamaño a través de bundles de cliente, servidor, y edge. Enfócate en el bundle de cliente.
¿Cuál es la diferencia entre next/dynamic y React.lazy?
next/dynamic: incorporado en Next.js, maneja SSR, proporciona opcionesloadingyssrReact.lazy+Suspense: agnóstico del framework, sin soporte SSR sin setup adicional
Usa next/dynamic en proyectos Next.js; usa React.lazy en apps React que no son Next.js.
¿Cuándo deberías usar ssr: false con importaciones dinámicas?
Solo para componentes verdaderamente interactivos y solo-de-cliente como editores de canvas, editores de código, o widgets de mapas que no pueden renderizarse en el servidor. Los componentes con ssr: false son invisibles para crawlers de motores de búsqueda y durante la representación inicial de HTML.
¿Por qué importar desde un barrel file (index.ts) daña el tamaño de bundle?
Un index.ts que hace export * from "./heavy-module" fuerza al bundler a incluir el módulo completo incluso si solo usas una función.
Solución: Importa directamente del archivo fuente:
import { fn } from "./lib/specific-module"; // Bueno
import { fn } from "./lib"; // MaloTrampa: ¿Cuándo es mala idea la importación dinámica?
Dividir un componente menor a 30KB agrega complejidad y una solicitud de red para ahorros mínimos. Solo importa dinámicamente componentes que sean al menos 30KB gzipped o detrás de interacción del usuario (modales, editores, paneles de configuración).
Trampa: ¿Por qué tree-shaking no funciona con módulos CommonJS?
Tree-shaking requiere sintaxis de módulo ES import/export para análisis estático. CommonJS require/module.exports es dinámico y no puede ser analizado estáticamente.
Solución: Usa variantes de módulo ES: lodash-es en lugar de lodash, date-fns en lugar de moment.
¿Cuáles son reemplazos de librerías pesadas comunes para reducir tamaño de bundle?
moment(72KB) ->date-fnsimportaciones nombradas (4KB)lodash(25KB) ->lodash-esimportaciones nombradas o JavaScript nativo (0-2KB)axios(13KB) ->fetchnativo (0KB)framer-motion(44KB) -> Animaciones CSS oLazyMotion(0-5KB)
¿Cómo funciona route-based code splitting en Next.js?
Next.js automáticamente crea un chunk separado para cada página. Route groups como (marketing) y (app) crean bundles separados, asegurando que páginas de marketing no carguen código específico de la aplicación. Sin configuración necesaria.
¿Cómo tipas una importación dinámica con una exportación nombrada en TypeScript?
const Editor = dynamic(
() => import("@monaco-editor/react").then((m) => m.Editor),
{ ssr: false }
);TypeScript deduce los props del componente de la exportación nombrada del módulo importado.
¿Cómo cargas condicionalmente un componente solo cuando desplaza hacia la vista?
import dynamic from "next/dynamic";
import { useInView } from "react-intersection-observer";
const HeavyWidget = dynamic(() => import("./HeavyWidget"), { ssr: false });
function LazySection() {
const { ref, inView } = useInView({ triggerOnce: true, rootMargin: "200px" });
return (
<div ref={ref}>
{inView ? <HeavyWidget /> : <div className="h-96" />}
</div>
);
}¿Qué umbrales de tamaño de bundle deberías apuntar?
- Landing page: menos de 150KB gzipped de JavaScript del cliente
- App shell: menos de 300KB gzipped de JavaScript del cliente
- Lighthouse no debe marcar "Reduce unused JavaScript"
¿El analizador de bundle muestra tamaños gzipped o sin comprimir?
El treemap muestra tamaños de módulo sin procesar (sin comprimir). El tamaño real de transferencia depende de compresión gzip/brotli. Verifica la pestaña Network en DevTools para tamaños reales de transferencia. El código densamente textual se comprime bien; el código binario o minificado no.
Relacionado
- Server Component Performance — Eliminando client JS completamente con RSCs
- Suspense & Streaming — Estados de carga para componentes importados dinámicamente
- Core Web Vitals — Impacto del tamaño de bundle en LCP e INP
- Performance Checklist — Objetivos de presupuesto de bundle y cumplimiento de CI