Loading article...
Fetching the blog content...
Loading article...
Fetching the blog content...
Learn how I cut CSS bundle size by 85% and improved page load times by 50% using dynamic imports and automatic code splitting in Next.js—a simple pattern with massive performance impact.
How I cut my CSS bundle size by 85% and improved page load times by implementing dynamic component loading with automatic code splitting—a simple pattern with massive performance impact.
I built a platform to showcase interactive frontend challenges—image carousels, modal dialogs, memory games, Simon Says, accordions, and more. Each demo was carefully crafted with its own styles and logic. The problem emerged when I looked at my bundle:
Every user was downloading CSS for every demo, regardless of which one they wanted to view.
Here's what our initial bundle looked like when a user visited any demo page:
Initial Page Load:
├── Memory Game styles (style.css)
├── Simon Says styles (style.css)
├── Accordion styles (style.css)
├── Modal Component styles (styles.css)
├── Image Carousel styles (style.css)
├── Tic-Tac-Toe styles (style.css)
├── Whack-A-Mole styles (style.css)
├── Dice Roller styles (style.css)
├── Infinite Scrolling styles (style.css)
└── Nested Comments styles (style.css)
─────────────────────────────────
Total: ~150KB of CSS
This wasn't just about file size. The cascading effects were worse:
1. Render-Blocking Resources
All CSS had to download and parse before the browser could render anything. On slower connections, users stared at blank screens.
2. Wasted Bandwidth
A user visiting only the Memory Game demo downloaded styles for 9 other components they'd never see. On mobile networks with metered data, this was particularly problematic.
3. Unnecessary Parsing
The browser had to parse thousands of CSS rules that would never apply to any element on the page. This blocked the main thread and delayed interactivity.
4. Poor Core Web Vitals
Our performance metrics showed the impact:
I needed a better approach.
The solution came from leveraging a built-in Next.js feature: when you dynamically import a component, Next.js automatically code-splits its CSS into a separate chunk.
No complex webpack configuration. No manual CSS extraction. Just dynamic imports.
The key idea is simple: route to “feature module” by slug, then dynamically import that module. Next.js will turn that import boundary into a separate JS chunk — and if the module imports CSS, that CSS becomes a separate CSS chunk too.
Instead of showing a full copy-paste component, here’s the pseudo-structure you want:
TSXcomponent.tsx1// PSEUDO-CODE (skeleton) 2// Goal: load only the demo the user asked for 3 4const DEMO_COMPONENT_MAP = { 5 "memory-game": () => import(".../MemoryGame"), 6 "simon-says": () => import(".../SimonSays"), 7 "accordion": () => import(".../Accordion"), 8 // ... 9}; 10 11export function DynamicDemoLoader({ slug }) { 12 const load = DEMO_COMPONENT_MAP[slug]; 13 14 if (!load) { 15 return <FallbackUI />; 16 } 17 18 // This creates a split point (JS chunk) and pulls CSS chunk only when needed. 19 const Demo = dynamic(load, { loading: () => <LoadingUI /> }); 20 21 return <Demo />; 22}
There are two types of CSS in a typical Next.js app:
globals.css (and shared component CSS) — loaded for every route.MemoryGame/style.css).When you statically import every demo somewhere in the main tree, the bundler has no choice but to include their CSS in the initial output.
When you dynamically import a demo, Next can keep that demo’s CSS out of the initial payload and fetch it only when the import is executed.
Each demo should be a self-contained module. That means the demo “owns” its logic and its CSS. In practice, it looks like this:
TSXcomponent.tsx1// PSEUDO-CODE (module shape) 2"use client"; 3 4import "./style.css"; // demo-specific styles live with the demo 5 6export default function Demo() { 7 // local state + handlers 8 return <div className="demo-root">{/* ... */}</div>; 9}
Notice the consistent structure I follow:
"use client" directive for client-side interactivityimport './style.css') inside the demo moduleThis structure is all Next.js needs to automatically extract and code-split the CSS — as long as the demo is behind a dynamic import boundary.
When Next.js builds the application, here's what happens:
Next.js analyzes each dynamically imported component and identifies its dependencies—including CSS imports.
For each dynamic import, Next.js:
The build creates separate chunks for each demo:
.next/static/chunks/
├── main-[hash].js (base app JavaScript)
├── main-[hash].css (base app styles ~20KB)
├── 123-[hash].js (Memory Game component)
├── 123-[hash].css (Memory Game styles)
├── 456-[hash].js (Simon Says component)
├── 456-[hash].css (Simon Says styles)
├── 789-[hash].js (Accordion component)
├── 789-[hash].css (Accordion styles)
└── ... (more demo chunks)
When a user navigates to /design/memory-game/demo:
1. Initial Page Load
├── Browser downloads base HTML
├── Fetches main JavaScript (~150KB)
└── Fetches main CSS (~20KB)
2. Page Renders
└── User sees page structure, header, navigation
3. DynamicDemoLoader Executes
└── Checks slug === 'memory-game'
4. Dynamic Import Triggered
├── Browser fetches memory-game-[hash].js
└── Browser fetches memory-game-[hash].css
5. Component Renders
└── Memory Game appears with its styles applied
The loading state provides feedback during the chunk fetch:
TSXcomponent.tsx1// PSEUDO-CODE: keep it simple, prevent layout shift, reassure the user 2loading: () => <LoadingUI text="Loading demo…" />
Before Dynamic Loading:
Initial Bundle (any demo page):
├── HTML: ~15KB
├── JavaScript: ~200KB
├── CSS: ~150KB (all demos)
└── Total: ~365KB
After Dynamic Loading:
Initial Bundle:
├── HTML: ~15KB
├── JavaScript: ~150KB
├── CSS: ~20KB (base only)
└── Total: ~185KB
Per-Demo On-Demand:
└── Demo chunk: ~25KB (JS + CSS)
Result: 49% reduction in initial payload
The CSS improvement is even more dramatic:
The impact on user experience:
| Metric | Before | After | Improvement |
|---|---|---|---|
| Initial CSS | 150KB | 20KB | 87% smaller |
| First Contentful Paint | 2.5s | 1.2s | 52% faster |
| Time to Interactive | 4.2s | 2.1s | 50% faster |
| Lighthouse Score | 68 | 94 | +26 points |
Scenario 1: User views Memory Game
Scenario 2: User views three demos
Scenario 3: User views all ten demos
Users who visit 7+ demos will download slightly more total data than before. But they get:
For 90% of users (who view 1-3 demos), this is a massive win.
The integration detail that matters most is: only render the demo when the user asked for it.
This sounds obvious, but it’s easy to accidentally violate by putting the demo loader “nearby” in the tree. If the demo renders (even briefly), you can trigger the dynamic import and fetch the demo’s CSS/JS.
TSXcomponent.tsx1// PSEUDO-CODE (page composition) 2const isCodeView = searchParams.view === "code"; 3 4return ( 5 <> 6 <Header /> 7 {isCodeView ? <CodeViewer /> : <DynamicDemoLoader slug={slug} />} 8 </> 9);
The beauty of this integration:
DynamicDemoLoader only renders when view is not 'code'The magic is that you don’t need custom build tooling. Next already knows how to:
What you need to do is keep the boundary “clean”:
Adding a new demo stays easy because the system is predictable:
TSXcomponent.tsx1// PSEUDO-CODE 2// 1) Create a demo module + colocated CSS 3import "./style.css"; 4export default function MyNewDemo() { /* ... */ } 5 6// 2) Register it 7DEMO_COMPONENT_MAP["my-new-demo"] = () => import(".../MyNewDemo");
Not every slug needs an internal demo on day one. If there’s no mapping, render a friendly “not available” state (and optionally an external demo link).
This allows us to incrementally build demos without breaking the experience.
Dynamic imports are real network requests. A stable loading UI improves perceived performance and avoids layout shift.
With the App Router, your page shell can stay server-rendered (metadata, header, description, navigation), while the interactive demo is loaded on-demand on the client. That’s usually the best tradeoff for SEO + performance.
The loading pattern enables true progressive enhancement:
Initial Load (fast):
├── HTML structure
├── Base styles
├── Header/navigation
└── Demo description
Enhanced Load (on-demand):
├── Demo component
├── Demo styles
└── Interactive features
Users can start reading about the demo while the interactive component loads.
With separate chunks, browsers cache more efficiently:
A user who views Memory Game, then Simon Says, then returns to Memory Game:
Only loaded components consume browser memory:
Based on my analytics:
For 95% of users, dynamic loading saves significant bandwidth.
Here’s the short checklist that keeps this pattern working over time:
Keep split points clean
Colocate styles
globals.css).Use a single registry
slug → () => import(...) makes the system auditable.Make loading predictable
Fail gracefully
Verify with DevTools
I initially considered manual webpack configuration. But Next.js dynamic imports with automatic CSS splitting provided everything I needed—with zero configuration.
Each demo's CSS seemed reasonable individually (10-20KB). But 10 demos × 15KB = 150KB that most users never needed.
Lighthouse scores improved, but I also monitored real user metrics. The improvements were consistent across both lab and field data.
I experimented with preloading likely-to-be-visited demos. Result: minimal benefit, increased complexity. On-demand loading was simpler and more effective.
Only power users who view many demos see equivalent total bandwidth. The vast majority get a significantly faster experience.
This approach works best for:
✅ Multi-feature applications with distinct modules
✅ Demo/documentation sites with multiple examples
✅ Dashboard applications with different sections
✅ Any app where users visit a subset of features
It's less beneficial for:
❌ Single-page apps with uniform functionality
❌ Small applications with minimal CSS (under 50KB total)
❌ Apps where users always visit all sections
My DynamicDemoLoader component demonstrates that strategic code splitting delivers massive performance wins with minimal code:
The implementation is remarkably simple—about 80 lines of code—yet the impact is substantial.
Start small and keep it measurable:
The web platform gives us powerful optimization tools. By leveraging dynamic imports and automatic code splitting, I can build faster applications that deliver better experiences—especially for users on slower networks and devices.
Want to see the implementation? Check out the DynamicDemoLoader source code and explore how I structure my demo components.
Goal: Understand how dynamic imports with automatic CSS code splitting can dramatically improve performance metrics and user experience.
Continue learning with these related challenges
Understanding frontend performance optimization from the ground up - learn how browsers work and optimize your code accordingly.

Understand how browsers render pages through the Critical Rendering Path. Learn to optimize DOM, CSSOM, render tree construction, and reduce time to first paint for faster websites.
Complete guide to Turborepo setup and migration. Learn intelligent caching, parallel task execution, and monorepo best practices to speed up your frontend builds dramatically.
Understanding frontend performance optimization from the ground up - learn how browsers work and optimize your code accordingly.

Understand how browsers render pages through the Critical Rendering Path. Learn to optimize DOM, CSSOM, render tree construction, and reduce time to first paint for faster websites.
Complete guide to Turborepo setup and migration. Learn intelligent caching, parallel task execution, and monorepo best practices to speed up your frontend builds dramatically.