A Next.js Route Ships a 300KB Bundle and Blocks First Paint

This is the specific failure where a single Next.js route serves one oversized JavaScript payload — a main or page chunk well past 150KB gzip — that blocks First Contentful Paint and pushes Time to Interactive over budget. It sits under the broader workflow in dynamic imports and route-based splitting and the broader guide to JavaScript bundle optimization; here we deal only with the Next.js-specific reasons splitting silently collapses and how to fix each one.

Monolithic vs split route bundle A single 300KB route chunk versus a shared framework chunk, a small route chunk, and a deferred dynamic chunk. First Load JS: before and after Before: one chunk route bundle 300KB over budget After: split by boundary framework (shared) route <150KB dynamic() chunk deferred, off path Direct imports over barrels; defer below-fold UI; keep the framework group.

Rapid Diagnosis: A Five-Minute DevTools Checklist

Confirm you actually have this problem before touching config. Run a production build (next build && next start) — next dev does not split or minify the way production does.

  1. DevTools → Network, filter JS, disable cache, hard reload. Flag any single route chunk over 150KB minified + gzipped.
  2. Coverage panel (Ctrl+Shift+P → "Show Coverage"), reload. If the route chunk shows > 40% unused bytes on first paint, code from other routes is leaking in.
  3. Lighthouse → Performance. Read FCP, TBT, and TTI. The target boundaries: FCP < 1.8s, TTI < 2.5s.
  4. Watch the Network on in-app navigation. If framework.js or a large shared chunk refetches on every route change, your chunk boundaries are unstable.
  5. Read the next build terminal table. The "First Load JS" column per route is your ground truth; anything red there confirms the regression.

Root Cause Analysis: Why Next.js Re-Merges Your Chunks

Next.js splits by route automatically. When a route bundle is still monolithic, one of four mechanisms is defeating that default.

1. Barrel-file imports. An import { Button } from '@/components' that resolves through a re-exporting index.ts forces webpack to pull the entire component directory into the route's graph, because it cannot prove which siblings are unused before bundling. This is the single most common cause and it is invisible in source review.

2. next.config.js overrides. A custom webpack function that reassigns config.optimization.splitChunks wholesale replaces Next.js's tuned cache groups, often disabling the framework group and folding React back into every page.

3. Misused next/dynamic. Calling dynamic() but rendering the component synchronously above the fold, or wrapping a tiny component, adds a chunk boundary without deferring meaningful work — and ssr: false on above-the-fold UI delays the largest paint.

4. A shared client provider at the root. A context provider in the root layout that imports every feature's state forces those reducers into the shared payload, since they are reachable from the entry.

Step-by-Step Resolution: Fixes Ordered by Impact

Fix 1 — Replace barrel imports with direct paths

Highest leverage, lowest risk. Import from the concrete file, not the directory index.

javascript
// Before: pulls the whole components directory into this route
import { HeavyChart } from '@/components';

// After: webpack includes only this module in the route graph
import { HeavyChart } from '@/components/HeavyChart';
// trade-off: direct paths are verbose and lose the convenience of a single
// import line; keep barrels for genuinely tree-shakeable, side-effect-free
// utility modules and reserve direct imports for heavy UI components.

Expected outcome: removing one barrel that fanned out across a route typically drops First Load JS by ~40–90KB and cuts TBT by ~50–150ms on mid-tier mobile.

Fix 2 — Defer heavy below-the-fold components with next/dynamic

Move expensive, non-critical UI out of the synchronous render path.

javascript
// app/dashboard/page.js
import dynamic from 'next/dynamic';

const AnalyticsPanel = dynamic(() => import('./components/AnalyticsPanel'), {
  loading: () => <div className="skeleton">Loading analytics…</div>,
  // ssr: false only for components that touch window/navigator/document
});

export default function DashboardRoute() {
  return <AnalyticsPanel />;
}
// trade-off: ssr:false removes the component from the server HTML, so it never
// appears in the initial paint — never use it for above-the-fold or LCP
// elements, only for browser-only widgets safely below the fold.

Expected outcome: deferring a charting or editor panel commonly removes ~60–120KB from First Load JS and improves Interaction to Next Paint by keeping that evaluation off the initial main-thread burst, which otherwise inflates input delay past the 200ms field threshold.

Fix 3 — Repair splitChunks without replacing Next.js defaults

If a config override is the cause, extend rather than overwrite. Keep the framework group so React, Next.js, and the scheduler live in one shared chunk.

javascript
/** @type {import('next').NextConfig} */
const nextConfig = {
  webpack: (config, { isServer }) => {
    if (!isServer) {
      config.optimization.splitChunks.cacheGroups.framework = {
        name: 'framework',
        chunks: 'all',
        test: /[\\/]node_modules[\\/](react|react-dom|scheduler|next)[\\/]/,
        priority: 40,
        enforce: true,
      };
      config.optimization.splitChunks.maxInitialRequests = 30;
    }
    return config;
  },
};
module.exports = nextConfig;
// trade-off: mutating the existing splitChunks object preserves Next.js's tuned
// groups, but a future Next.js upgrade may rename them — pin your Next version
// and re-verify the build manifest after each major upgrade.

Expected outcome: consolidating framework code into one cached chunk eliminates per-route React duplication, typically reclaiming ~120KB of repeated payload across a multi-route app.

Fix 4 — Lazy-register route-local state

Stop the root provider from anchoring every reducer in the shared payload. Register each route's slice from inside the route, so its code stays in the route chunk.

javascript
// trade-off: lazy slice registration keeps route state out of the shared
// bundle, but the store is briefly incomplete during the chunk fetch — guard
// selectors that may run before their slice mounts, or default them.
const useReportsSlice = () => {
  const store = useStore();
  useEffect(() => { store.injectReducer('reports', reportsReducer); }, [store]);
};

Expected outcome: removes feature reducers from First Load JS proportional to their size, commonly ~10–30KB per heavy route.

Verification: Before/After and a CI Gate

Re-run the diagnosis and confirm the numbers moved, then lock the win so it cannot regress.

  • Before/after diff: the next build "First Load JS" for the route should drop below 150KB; Coverage unused bytes should fall under 40%.
  • CI assertion: parse .next/build-manifest.json (or use @next/bundle-analyzer) and fail the pipeline when any route's First Load JS exceeds the budget.
bash
# CI gate: fail if any route's First Load JS crosses 150KB gzip.
# trade-off: a single global budget is simple but blunt — a legitimately rich
# route may need a higher per-route limit, so move to per-route budgets once
# the global gate produces false positives.
ANALYZE=true next build && node scripts/assert-route-budget.js --max=150
  • Field check: confirm in RUM that p75 LCP stays under 2.5s and p75 INP under 200ms for the affected route. A lab improvement that does not show up at p75 for real users is not a fix worth shipping.