he chunk loads. This eliminates hydration cost for the component entirely but sacrifices SEO and initial content visibility.
Key Finding: ssr: false is not just a bundle optimization; it is a hydration architecture decision. It should be reserved for components that rely on browser APIs, are hidden by default (modals, drawers), or are non-critical for the initial user journey. Using it for visible content creates a "content gap" that hurts engagement.
Core Solution
Implementing dynamic imports requires a disciplined approach based on component classification. The implementation differs slightly between the Pages Router and the App Router due to React Server Components (RSC).
1. Component Classification Matrix
Before coding, classify components:
- Critical/Visible: Static import.
- Heavy/Visible: Dynamic with
ssr: true (default).
- Heavy/Hidden or Browser-Dependent: Dynamic with
ssr: false.
- Client-Side Only: Dynamic with
ssr: false.
2. Implementation Patterns
Pattern A: Heavy Third-Party Library (e.g., Chart, Map)
These libraries often rely on window or document. Using ssr: false prevents server errors and reduces initial payload.
// components/InteractiveMap.tsx
'use client';
import dynamic from 'next/dynamic';
// Import the heavy library dynamically within the client component
// or import the component wrapping it dynamically in the parent.
const LeafletMap = dynamic(
() => import('react-leaflet').then((mod) => mod.MapContainer),
{
ssr: false,
loading: () => <MapSkeleton />,
}
);
export function DashboardMap() {
return <LeafletMap center={[51.505, -0.09]} zoom={13} />;
}
Pattern B: Modals and Drawers
Components not visible on initial load should always use ssr: false to defer their cost until user interaction.
// components/SettingsModal.tsx
import dynamic from 'next/dynamic';
const SettingsModalContent = dynamic(
() => import('./SettingsModalContent'),
{
ssr: false,
loading: () => <div>Loading settings...</div>,
}
);
export function SettingsModal({ isOpen }: { isOpen: boolean }) {
if (!isOpen) return null;
return (
<dialog open={isOpen}>
<SettingsModalContent />
</dialog>
);
}
Pattern C: App Router Streaming
In the App Router, next/dynamic integrates with React Suspense. You can stream dynamic segments.
// app/dashboard/page.tsx
import dynamic from 'next/dynamic';
// This component will be streamed as a separate chunk
const AnalyticsChart = dynamic(
() => import('./AnalyticsChart'),
{
loading: () => <p>Loading analytics...</p>,
// In App Router, ssr: false implies the component is a Client Component
// that renders nothing on the server.
}
);
export default function DashboardPage() {
return (
<main>
<h1>Dashboard</h1>
<Suspense fallback={<ChartSkeleton />}>
<AnalyticsChart />
</Suspense>
</main>
);
}
3. Architecture Decisions
Pitfall Guide
1. Hydration Mismatches with typeof window
- Mistake: Using
ssr: true but rendering different content based on typeof window inside the component.
- Result: React hydration error. The server renders one tree, the client expects another.
- Fix: Use
useEffect to set a mounted state flag, or switch to ssr: false if the component is purely client-side.
2. Overusing ssr: false for SEO Content
- Mistake: Marking product descriptions or article content as
ssr: false to save bundle size.
- Result: Content is invisible to crawlers and screen readers until JS executes.
- Fix: Only use
ssr: false for interactive widgets, media, or non-essential UI.
3. Dynamic Imports Inside Loops or Conditionals
- Mistake: Calling
dynamic() inside a loop or conditional render path.
- Result: Webpack cannot statically analyze dependencies, leading to missing chunks or runtime errors.
- Fix: Define dynamic imports at the module level.
4. Ignoring Network Waterfalls
- Mistake: Chaining dynamic imports (Component A loads, which triggers import of Component B).
- Result: Increased latency. The user waits for A to load before B is requested.
- Fix: Use
import() preloading or structure dependencies to load in parallel. Consider next/link prefetching for navigational targets.
5. React.lazy vs next/dynamic Confusion
- Mistake: Using
React.lazy in Next.js expecting SSR support.
- Result:
React.lazy does not support SSR; the component will not render on the server.
- Fix: Always use
next/dynamic in Next.js applications. It wraps React.lazy and adds SSR handling.
6. Loading Components Triggering Side Effects
- Mistake: The
loading component fetches data or triggers analytics.
- Result: Side effects fire even if the dynamic chunk fails to load or is never rendered.
- Fix: Keep
loading components pure and static.
7. Circular Dependencies
- Mistake: Component A dynamically imports Component B, which dynamically imports Component A.
- Result: Runtime error or infinite loading loop.
- Fix: Refactor shared logic into a third module or use dependency injection.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Heavy Chart Library | dynamic with ssr: false | Browser API dependency; non-SEO critical. | Reduces TTI; No SSR cost. |
| Product Description | Static Import | SEO critical; must be in HTML immediately. | Increases Bundle; Fast FCP. |
| Modal/Dialog | dynamic with ssr: false | Hidden by default; loaded on interaction. | Zero initial cost; Fast TTI. |
| Map Component | dynamic with ssr: false | Heavy payload; browser-only. | Reduces initial JS by ~300KB. |
| SEO-Heavy Widget | dynamic with ssr: true | Needs content in HTML but heavy JS. | Medium Bundle; Streamed hydration. |
Configuration Template
Bundle Analyzer Setup (next.config.js)
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
/** @type {import('next').NextConfig} */
const nextConfig = {
// Enable dynamic import chunk naming in webpack
webpack: (config, { isServer }) => {
if (!isServer) {
config.optimization.splitChunks = {
...config.optimization.splitChunks,
cacheGroups: {
...config.optimization.splitChunks.cacheGroups,
dynamic: {
name: 'dynamic',
test: /[\\/]node_modules[\\/]/,
priority: -10,
reuseExistingChunk: true,
},
},
};
}
return config;
},
};
module.exports = withBundleAnalyzer(nextConfig);
Reusable Dynamic Wrapper with Error Boundary (lib/dynamic-wrapper.tsx)
import dynamic, { DynamicOptions } from 'next/dynamic';
import { ComponentType, Suspense } from 'react';
interface ErrorBoundaryProps {
fallback: React.ReactNode;
children: React.ReactNode;
}
// Simplified Error Boundary implementation
class ErrorBoundary extends React.Component<ErrorBoundaryProps, { hasError: boolean }> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) return this.props.fallback;
return this.props.children;
}
}
export function createDynamicComponent<P>(
importFn: () => Promise<{ default: ComponentType<P> }>,
options: Omit<DynamicOptions, 'loading'> & { loading: React.ReactNode; errorFallback: React.ReactNode }
) {
return dynamic(
() => importFn(),
{
...options,
loading: () => <Suspense fallback={options.loading}>{options.children}</Suspense>,
}
);
}
Quick Start Guide
- Install Analyzer:
npm install @next/bundle-analyzer
- Generate Report:
ANALYZE=true npm run build
- Identify Target: Open
report.html and locate a chunk > 50KB that is not required for the initial view (e.g., a pricing table or interactive demo).
- Refactor: Replace the static import with:
import dynamic from 'next/dynamic';
const TargetComponent = dynamic(() => import('./TargetComponent'), {
ssr: false,
loading: () => <div className="h-64 w-full bg-gray-200 animate-pulse" />,
});
- Validate: Run
npm run build again. Verify the chunk is split and the initial bundle size decreases. Test the page to ensure the loading state prevents layout shift.