Hey fellow developers! After leading multiple Next.js projects and watching junior developers struggle with bundle sizes that could rival a small city's phone book, I thought it was time to share some battle-tested strategies for code splitting in the App Router.
The Reality Check: Why Your Users Are Bouncing
Picture this: You've built an amazing e-commerce platform with every bell and whistle imaginable. Charts, maps, rich text editors, PDF generators - the whole nine yards. But your users are hitting the back button faster than you can say "dynamic import."
The culprit? That 2MB initial bundle that includes your entire kitchen sink, even though 80% of users never touch the admin dashboard or generate a single PDF.
App Router: The Game Changer (But Not Magic)
With Next.js 13+ App Router, React Server Components are automatically code-split by default. That's fantastic news, but here's the catch - client components still need our attention. And trust me, that's where the real optimization magic happens.
Real-World Scenario: Building a SaaS Dashboard
Let me walk you through a real project, a SaaS analytics dashboard. There are several heavy features:
- A complex data visualization dashboard (Chart.js + D3)
- A rich text editor for reports (Monaco Editor)
- PDF export functionality (jsPDF)
- Real-time chat support widget
The Smart Way: Strategic Component Splitting
Here's how we tackled the dashboard with dynamic imports:
'use client'
import { useState } from 'react'
import dynamic from 'next/dynamic'
*// Lazy load heavy components*
const AnalyticsDashboard = dynamic(() => import('./AnalyticsDashboard'), {
loading: () => <DashboardSkeleton />,
ssr: false
})
const ReportEditor = dynamic(() => import('./ReportEditor'), {
loading: () => <EditorSkeleton />
})
const ChatWidget = dynamic(() => import('./ChatWidget'), {
ssr: false *// Browser-only widget with window/localStorage dependencies*
})
export default function DashboardPage() {
const [activeTab, setActiveTab] = useState('overview')
const [showChat, setShowChat] = useState(false)
return (
<div className="dashboard">
<nav>
<button onClick={() => setActiveTab('overview')}>Overview</button>
<button onClick={() => setActiveTab('analytics')}>Analytics</button>
<button onClick={() => setActiveTab('reports')}>Reports</button>
</nav>
{activeTab === 'analytics' && <AnalyticsDashboard />}
{activeTab === 'reports' && <ReportEditor />}
{showChat && <ChatWidget onClose={() => setShowChat(false)} />}
</div>
)
}
Result: Initial bundle size dropped from 1.8MB to 350KB. Users on the overview tab got a lightning-fast experience.
Pro Tip: Smart External Library Loading
Here's a pattern I use for heavy external dependencies. Instead of importing jsPDF at the top level, we load it on demand:
'use client'
import { useState, useRef } from 'react'
export default function InvoiceGenerator() {
const [isGenerating, setIsGenerating] = useState(false)
const jsPdfRef = useRef(null)
const generatePDF = async (invoiceData) => {
setIsGenerating(true)
*// Load jsPDF only when actually needed*
if (!jsPdfRef.current) {
const jsPdfModule = await import('jspdf')
jsPdfRef.current = jsPdfModule.default
}
const pdf = new jsPdfRef.current()
*// ... PDF generation logic*
setIsGenerating(false)
}
const preloadPDF = async () => {
*// Preload when user hovers over the button*
if (!jsPdfRef.current) {
const jsPdfModule = await import('jspdf')
jsPdfRef.current = jsPdfModule.default
}
}
return (
<button
onMouseEnter={preloadPDF}
onClick={() => generatePDF(invoiceData)}
disabled={isGenerating}
>
{isGenerating ? 'Generating...' : 'Download PDF'}
</button>
)
}
This pattern gives us the best of both worlds: tiny initial bundles and responsive UX through preloading.
The Server Component Twist
Here's something that confused any developer initially - you can dynamically import server components too:
import dynamic from 'next/dynamic'
const HeavyServerComponent = dynamic(() => import('./HeavyServerComponent'))
export default function ServerPage() {
return (
<div>
<h1>My Page</h1>
<HeavyServerComponent />
</div>
)
}
But here's the kicker: this doesn't optimize server-side loading. What it does is enable lazy loading of any nested client components within that server component. It's a subtle but important distinction.
When NOT to Use Dynamic Imports
Learning from mistakes (yes, I've made them too):
Don't dynamically import:
- Above-the-fold content (LCP killer)
- Small utility components (<5KB)
- Critical navigation elements
- Components needed for SEO
Do dynamically import:
- Modal dialogs and overlays
- Feature-specific heavy components
- Third-party widgets
- Admin panels and rarely-used features
- Device-specific components
Advanced Pattern: Conditional Feature Loading
For our SaaS, we implemented feature-flag-based loading:
'use client'
import { useFeatureFlag } from '@/hooks/useFeatureFlag'
import dynamic from 'next/dynamic'
const AdvancedAnalytics = dynamic(() => import('./AdvancedAnalytics'))
const BasicAnalytics = dynamic(() => import('./BasicAnalytics'))
export default function AnalyticsWrapper() {
const hasAdvancedFeatures = useFeatureFlag('advanced-analytics')
return hasAdvancedFeatures ? <AdvancedAnalytics /> : <BasicAnalytics />
}
This lets us ship different bundle sizes based on user subscription tiers. Premium users got the heavy charting libraries; basic users got a lightweight version.
Measuring Success: The Numbers That Matter
After implementing these patterns across our dashboard:
- Initial bundle size: 1.8MB → 350KB (-81%)
- Time to Interactive: 4.2s → 1.8s (-57%)
- First Contentful Paint: 2.1s → 0.9s (-57%)
- Bounce rate: 23% → 8% (-65%)
The Developer Experience Bonus
One unexpected benefit: our development builds became significantly faster. Hot reloads went from 3-4 seconds to under 1 second because we weren't recompiling massive chunks unnecessarily.
Wrapping Up
Code splitting in App Router isn't just about performance - it's about building sustainable, scalable applications. Start with the heavy hitters (charts, editors, PDFs), implement smart preloading, and always measure the impact.
Remember: your users don't care about your fancy features if they can't even load your app. Make the initial experience snappy, then progressively enhance with dynamic imports.
Top comments (0)