Best Practices

AI Rules for Code Splitting

AI bundles everything into one JavaScript file. Rules for route-based splitting, dynamic imports, React.lazy with Suspense, vendor chunk optimization, and bundle size monitoring.

7 min read·February 6, 2025

One 500KB bundle for a login page — every route, every component, every library loaded upfront

Route splitting, dynamic imports, React.lazy, vendor chunks, prefetch strategies, CI budgets

AI Ships One Giant Bundle

AI generates applications with: one JavaScript bundle containing all routes, all components, and all libraries (500KB-2MB for a typical app), no dynamic imports (everything loaded upfront, even pages the user may never visit), no lazy loading for heavy components (rich text editors, charts, maps loaded on initial page), and no vendor chunk separation (React, lodash, and your code in one file — changing one line invalidates the entire cache). The user downloads megabytes of JavaScript to view a login page.

Modern code splitting is: route-based (each route loads its own chunk), component-lazy (heavy components loaded on demand with React.lazy), vendor-separated (third-party libraries in a stable chunk that caches long-term), prefetched (next-likely routes prefetched during idle time), and monitored (CI fails if the bundle exceeds the size budget). AI generates none of these.

These rules cover: route-based splitting, dynamic import() for on-demand loading, React.lazy with Suspense boundaries, vendor chunk optimization, prefetch strategies, and CI bundle size monitoring.

Rule 1: Route-Based Code Splitting

The rule: 'Split code at route boundaries — each page is a separate chunk loaded only when the user navigates to it. Next.js does this automatically (each page in /app or /pages is a separate chunk). For React Router: use dynamic import() with React.lazy: const Dashboard = lazy(() => import("./pages/Dashboard")). The login page loads in 50KB. The dashboard chunk loads only when the user navigates there.'

For Next.js App Router: 'Next.js App Router automatically code-splits per route segment. Each page.tsx and layout.tsx becomes a separate chunk. Server Components are not sent to the client at all (zero client JS). Client Components (marked with 'use client') are code-split into their own chunks. The framework handles splitting — your job is to keep 'use client' boundaries as narrow as possible.'

AI generates: import Dashboard from './pages/Dashboard' at the top level — static import, included in the main bundle regardless of whether the user visits the dashboard. Dynamic import: const Dashboard = lazy(() => import('./pages/Dashboard')) — loaded only on navigation. Same component, different loading strategy, dramatically different initial bundle size.

  • Next.js App Router: automatic per-route splitting — zero configuration needed
  • React Router: lazy(() => import('./pages/Dashboard')) per route
  • Each route = separate chunk — loaded only on navigation
  • Server Components: zero client JS — keep 'use client' boundaries narrow
  • Static import = main bundle; dynamic import = separate chunk
💡 50KB Login vs 500KB Login

Static import: every page component is in the main bundle. Dynamic import with lazy(): each page is a separate chunk loaded on navigation. The login page loads 50KB instead of 500KB. Same code, different import strategy, 10x smaller initial load.

Rule 2: Dynamic import() for Heavy Components

The rule: 'Use dynamic import() for components that are: heavy (rich text editors, chart libraries, map widgets — 100KB+ each), conditionally rendered (modals, admin panels, settings), or below the fold (components not visible on initial viewport). Pattern: const Editor = lazy(() => import("./Editor")); return <Suspense fallback={<Skeleton />}><Editor /></Suspense>. The editor chunk loads only when the component mounts.'

For named exports: 'lazy() works with default exports. For named exports: const Chart = lazy(() => import("./Chart").then(m => ({ default: m.BarChart }))). This unwraps the named export into a default export that lazy() can consume. Alternatively, create a barrel file that re-exports as default: export { BarChart as default } from "./Chart";'

AI generates: import { BarChart } from 'recharts' at the top of the page component. Recharts is 200KB. Every page that imports the chart component includes 200KB in its chunk — even if the chart is behind a tab that most users never click. Dynamic import: the 200KB loads only when the user clicks the tab. Same feature, 200KB less initial load.

⚠️ 200KB Behind a Tab Click

import { BarChart } from 'recharts' at the top level: 200KB in the initial bundle, even if the chart is behind a tab most users never click. Dynamic import: the 200KB loads only when the user clicks the tab. Same feature, zero initial cost.

Rule 3: Suspense Boundaries and Loading States

The rule: 'Wrap every lazy-loaded component in a Suspense boundary with a meaningful fallback: <Suspense fallback={<ChartSkeleton />}><LazyChart /></Suspense>. The fallback should match the layout of the loading component (skeleton, not spinner) to prevent layout shift. Place Suspense boundaries at: route level (full-page skeleton), section level (component-sized skeleton), and component level (inline loading indicator).'

For error boundaries: 'Pair Suspense with an ErrorBoundary: <ErrorBoundary fallback={<ChartError />}><Suspense fallback={<ChartSkeleton />}><LazyChart /></Suspense></ErrorBoundary>. If the chunk fails to load (network error, deployment mismatch), the error boundary catches the failure and shows a retry button instead of crashing the entire page.'

AI generates: lazy-loaded components with no Suspense boundary (crashes with 'A component suspended while responding to synchronous input') or with fallback={null} (content disappears during load, layout shifts when it appears). Skeleton fallbacks that match the component layout: zero layout shift, clear loading indication, professional UX.

Rule 4: Vendor Chunk Separation

The rule: 'Separate vendor libraries (React, framework code, large libraries) into a dedicated chunk. Vendor code changes rarely — your application code changes frequently. Separate chunks mean: vendor chunk is cached long-term (months), application chunk is cache-busted on each deploy. Without separation: one line of code change invalidates the entire bundle cache, including 300KB of unchanged React code.'

For webpack/Vite configuration: 'Vite: build.rollupOptions.output.manualChunks: { vendor: ["react", "react-dom"], ui: ["@radix-ui/react-*"] }. Next.js: automatic vendor splitting — framework chunks, shared chunks, and page chunks are separated by default. Verify with: next build output or webpack-bundle-analyzer. The goal: your application chunk is small (50-100KB), vendor chunk is stable (cached), and heavy libraries are lazy-loaded.'

AI generates: one bundle with everything. Deploy a one-line bug fix: every user re-downloads 500KB of unchanged React code alongside your 1KB fix. With vendor splitting: the 1KB fix updates only the application chunk. The 500KB vendor chunk is already cached. 500x less data transferred for the same fix.

  • Vendor chunk: React, framework code — cached for months, changes rarely
  • Application chunk: your code — cache-busted on deploy, changes frequently
  • manualChunks in Vite/webpack — explicit control over chunk boundaries
  • Next.js handles this automatically — verify with build output analysis
  • One-line fix = 1KB transfer, not 500KB re-download of unchanged libraries
ℹ️ 500x Less Data Per Deploy

Without vendor splitting: one-line bug fix forces users to re-download 500KB of unchanged React code. With vendor splitting: the 1KB fix updates only the app chunk. Vendor chunk (React, framework) stays cached. 500x less data transferred for the same fix.

Rule 5: Prefetch Strategies and Bundle Monitoring

The rule: 'Prefetch next-likely routes during idle time. Next.js Link prefetches automatically on hover/viewport (configurable with prefetch={false}). For React Router: use <link rel='prefetch' href='/dashboard-chunk.js'> or router.prefetch('/dashboard'). Prefetch: the chunk is downloaded in the background, cached, and instantly available when the user navigates. The user experiences no loading delay.'

For CI monitoring: 'Set bundle size budgets in CI: size-limit (npm package) fails the build if any chunk exceeds its budget. Configuration: { "limit": "200 KB", "path": "dist/client.js" }. Review the budget on every PR that adds a dependency. The budget is a guardrail — it catches accidental bloat before it reaches production. A 5KB increase per PR seems small; 50 PRs later, 250KB of creep.'

AI generates: no prefetching (every navigation is a cold load) and no size monitoring (the bundle grows unbounded). Link prefetching: zero-delay navigation. Size-limit in CI: bundle bloat caught at PR time, not after users complain about slow load times.

Complete Code Splitting Rules Template

Consolidated rules for code splitting.

  • Route-based splitting: each page is a separate chunk — Next.js automatic, React Router lazy()
  • Dynamic import() for heavy components: editors, charts, maps — loaded on demand
  • Suspense boundaries with skeleton fallbacks — no layout shift, no spinner
  • ErrorBoundary + Suspense: graceful chunk load failure with retry
  • Vendor chunk separation: framework code cached long-term, app code cache-busted
  • Prefetch next-likely routes: Link hover, viewport intersection, idle time
  • CI bundle budget: size-limit fails build on budget exceed — catch bloat at PR time
  • Narrow 'use client' boundaries in Next.js — Server Components = zero client JS