February 12, 2026 ยท 16 min readโ Featured
Bundle size directly impacts user experience. Learn practical strategies for keeping your Next.js bundles lean and fast, from dynamic imports to tree-shaking techniques that scale from small projects to production websites.
Dynamic imports work best for features that are page-specific. Markdown rendering is the perfect example: it's heavy, it's only needed on certain pages, and users won't notice the lazy load because it happens during initial page navigation.
For large libraries that offer many features but where you only use a small subset, explicit imports are essential. Tree-shaking often doesn't work automatically, especially for icon packs and language definitions. Creating barrel files or configuration modules ensures you only bundle what you need.
Load Time Impact:
| Connection | Before | After | Faster |
|---|---|---|---|
| 3G | 22s | 5s | 77% |
| 4G | 3.5s | 0.8s | 77% |
| 5G | 0.35s | 0.08s | 77% |
This optimization required no architectural changes, just intelligent code splitting and import patterns.
Modern websites ship with increasingly heavy JavaScript bundles. A typical Next.js application can easily reach 2-5 MB before optimization, and every kilobyte matters when it comes to user experience. Users on slower connections, mobile devices, or in regions with limited bandwidth feel the impact immediately.
The good news is that Next.js provides powerful tools for bundle optimization built right into the framework. With the right strategies, you can reduce bundle sizes by 60-80% without sacrificing features or functionality. The key is understanding what's in your bundle, why it's there, and how to load it only when needed.
For this reason, after a series of posts introducing framework architectures, content modeling patterns and basic scalability principles. In this post, we extend scalability even further and cover practical strategies for optimizing Next.js bundles, focusing on techniques that provide measurable results without adding unnecessary complexity. We'll explore dynamic imports, tree-shaking patterns, and bundle analysis tools that help you make informed decisions about your application's weight.
Before diving into optimization techniques, it's worth understanding why bundle size has such a direct impact on user experience.
JavaScript bundles must be downloaded, parsed, and executed before a page becomes interactive. A 4 MB bundle on a 4G connection takes roughly 3.5 seconds just to download, plus additional time for parsing and execution. Even on faster connections, large bundles delay time-to-interactive, creating frustration and increasing bounce rates.
Desktop users on broadband connections might not notice a 2 MB bundle, but mobile users on cellular networks absolutely will. If your audience is global, many users will be on slower connections where bundle size is the primary performance bottleneck.
Google's Core Web Vitals include metrics like First Contentful Paint (FCP) and Time to Interactive (TTI), both of which are heavily influenced by JavaScript bundle size. Smaller bundles directly improve these metrics, which in turn affects search rankings and user retention.
Larger bundles mean more data transfer, which increases hosting costs and has environmental impact. While a single user downloading a few megabytes might seem negligible, multiply that by thousands or millions of users and the cost adds up quickly.
You can't optimize what you don't measure. The first step in bundle optimization is understanding exactly what's being shipped to users and why.
Next.js provides an official bundle analyzer that visualizes your bundle composition. It shows which packages are consuming the most space and helps identify optimization opportunities.
npm install @next/bundle-analyzerConfigure it in next.config.js:
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
})
module.exports = withBundleAnalyzer({
// your config
})Run the analyzer:
ANALYZE=true npm run buildThis generates an interactive visualization showing:
When analyzing results, focus on:
Large Chunks: Any chunk over 200-300 KB deserves investigation. These are prime candidates for code-splitting or dynamic loading.
Duplicate Packages: If you see the same package in multiple chunks, you might benefit from better code-splitting configuration or consolidating imports.
Unexpected Dependencies: Large dependencies you didn't know you were shipping often indicate transitive dependencies (packages that your dependencies depend on). These are worth investigating since you might find lighter alternatives.
Dynamic imports are the single most effective bundle optimization technique for content sites. Instead of loading everything upfront, you load code only when users actually need it.
Next.js makes dynamic imports simple with the next/dynamic utility:
import dynamic from 'next/dynamic'
const HeavyComponent = dynamic(() => import('./HeavyComponent'), {
loading: () => <Skeleton />,
ssr: true
})This pattern:
HeavyComponent into a separate chunkFor blog sites, the single most impactful dynamic import is the markdown renderer itself. Markdown libraries like react-markdown, along with their plugins for syntax highlighting (rehype-highlight) and enhanced formatting (remark-gfm), can easily add 300-500 KB to your bundle.
The critical insight: only blog posts need markdown rendering. Your homepage, about page, and portfolio pages don't use markdown, so there's no reason to load these libraries there.
Creating a Dynamic Markdown Component:
// components/MarkdownContent.tsx
'use client'
import dynamic from 'next/dynamic'
import type { Components } from 'react-markdown'
const ReactMarkdown = dynamic(() => import('react-markdown'), {
loading: () => <ArticleSkeleton />,
ssr: true // Keep SSR for SEO
})
// Import plugins statically (they're lightweight)
import remarkGfm from 'remark-gfm'
export function MarkdownContent({ content, components }: Props) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
...components,
// Override pre to intercept code blocks and extract clean text
pre: (props: any) => {
const { children } = props
// Extract code element
const codeElement = children?.props
if (!codeElement) {
return <pre {...props}>{children}</pre>
}
// Get the raw code string
const codeString = String(codeElement.children || '').replace(/\n$/, '')
// Get language from className
const className = codeElement.className || ''
const language = className.replace('language-', '')
// If a custom pre component was passed, call it with the clean code
if (components?.pre && typeof components.pre === 'function') {
// Call it as a function, not a component
return (components.pre as any)({
children: codeString,
language: language || undefined
})
}
// Default rendering
return <pre {...props}>{children}</pre>
}
}}
>
{content}
</ReactMarkdown>
)
}Key Details:
The 'use client' directive combined with dynamic() is what triggers code-splitting. Even though this component is imported by your server-side page components, Next.js automatically creates a separate chunk for it.
When you use this component in your blog renderer:
// In your BlocksRenderer or blog page
import { MarkdownContent } from '@/components/MarkdownContent'
// Inside a blog post:
<MarkdownContent
content={block.value}
components={{
pre: (props: any) => {
// Now we receive clean code string and language
const { children: codeText, language } = props
// Use first 20 characters as key
const key = `code-${String(codeText).slice(0, 20)}`
return <CodeBlockClient
key={key}
code={codeText}
language={language}
/>
},
}}
/>What Happens:
Impact: This single change typically reduces homepage bundle by 300-500 KB, a 30-40% reduction for content sites.
Beyond dynamic imports, another key optimization is identifying which parts of your rendering need client-side interactivity and marking them with 'use client'. This allows Next.js to keep the rest of your code in smaller, server-side chunks.
In a typical blog or portfolio, most content is static, but certain features need JavaScript for interactivity: image lightboxes, copy-to-clipboard buttons, carousels, and interactive code blocks.
The optimization comes from separating these concerns:
Consider a blog that renders content blocks from a headless CMS. Most blocks are static (headings, paragraphs, images), but some need interactivity (lightboxes, code blocks with copy buttons).
Server Component (BlocksRenderer):
// components/BlocksRenderer.tsx
import { LightboxImageClient } from './LightboxImageClient'
import { CodeBlockClient } from './CodeBlockClient'
import { GalleryCarouselClient } from './GalleryCarouselClient'
export function BlocksRenderer({ blocks }) {
return (
<>
{blocks.map((block) => {
switch (block.type) {
case 'heading':
return <h2>{block.value}</h2>
case 'paragraph':
return <p>{block.value}</p>
case 'image':
// Client component for lightbox functionality
return <LightboxImageClient src={block.value.url} />
case 'code':
// Client component for copy button
return <CodeBlockClient code={block.value.code} />
case 'gallery':
// Client component for carousel
return <GalleryCarouselClient images={block.value.images} />
}
})}
</>
)
}Client Components (Interactive Features):
// components/LightboxImageClient.tsx
'use client'
export function LightboxImageClient({ src }) {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<img src={src} onClick={() => setIsOpen(true)} />
{isOpen && <Lightbox src={src} onClose={() => setIsOpen(false)} />}
</>
)
}
// components/CodeBlockClient.tsx
'use client'
export function CodeBlockClient({
code,
language,
caption,
}: {
code: string;
language?: string;
caption?: string;
}) {
const [copied, setCopied] = useState(false);
const codeRef = useRef<HTMLElement>(null);
// Highlight on mount and when code/language changes
useEffect(() => {
if (codeRef.current && language) {
hljs.highlightElement(codeRef.current);
}
}, [code, language]);
const copy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 3000);
} catch (err) {
console.error("Copy failed", err);
toast.error("Failed to copy code block.", {
description: String(err),
duration: 5000,
closeButton: true,
});
}
};
return (
<div className="relative group">
<button
onClick={copy}
className="absolute right-2 top-2 z-10 flex items-center gap-1 rounded-md bg-muted/80 px-2 py-1 text-xs hover:bg-muted transition-colors"
>
<Copy className="h-3.5 w-3.5" />
{copied ? "Copied!" : "Copy"}
</button>
<pre className="overflow-x-auto rounded-lg bg-muted p-4 pt-10 text-sm">
<code
ref={codeRef}
className={language ? `language-${language}` : "language-text"}
>
{code}
</code>
</pre>
{caption && (
<p className="text-sm text-muted-foreground mt-2 text-center">{caption}</p>
)}
</div>
);
}This architecture keeps the bulk of your rendering logic on the server (lighter, faster) while only shipping JavaScript for the specific interactive features users actually need.
Without this separation:
'use client'With this separation:
For a typical blog post with 5 images, 2 code blocks, and 10 paragraphs, this pattern means only the interactive components contribute to bundle size, not the entire rendering system.
Tree-shaking is the process of removing unused code from your bundle. Modern bundlers like webpack and Turbopack can eliminate code that's imported but never used, but only if your import patterns allow it.
Icon libraries are a common source of bundle bloat. A package like lucide-react contains 1,400+ icons, but most applications use fewer than 50.
Inefficient Pattern:
import { Menu, X, Search } from 'lucide-react'This imports from the main package, which can pull in the entire icon set depending on how the library is structured.
Optimized Pattern:
Create a barrel file that explicitly exports only the icons you use:
// components/generic/Icons.tsx
export {
Menu,
X,
Search,
ChevronRight,
Mail,
ArrowRight,
// Only the 30-40 icons your site actually uses
} from 'lucide-react'
// Then throughout your app:
import { Menu, X } from '@/components/generic/Icons'This pattern ensures tree-shaking works correctly and makes it easy to audit which icons your application depends on.
Impact: Reduces icon library from 200-300 KB to 30-50 KB.
Syntax highlighting libraries like highlight.js include support for 190+ programming languages by default, but a typical blog might only highlight JavaScript, TypeScript, and Bash.
Inefficient Pattern:
import hljs from 'highlight.js'This imports all language definitions, adding hundreds of kilobytes to your bundle.
Optimized Pattern:
Create a configuration file that registers only the languages you use:
// lib/highlight-config.ts
import hljs from 'highlight.js/lib/core'
import javascript from 'highlight.js/lib/languages/javascript'
import typescript from 'highlight.js/lib/languages/typescript'
import bash from 'highlight.js/lib/languages/bash'
hljs.registerLanguage('javascript', javascript)
hljs.registerLanguage('typescript', typescript)
hljs.registerLanguage('bash', bash)
export default hljsThen import this configured instance instead of the full library:
import hljs from '@/lib/highlight-config'Impact: Reduces syntax highlighting bundle from 400 KB to 50 KB by including only the languages you actually use in code examples.
To illustrate these techniques in practice, consider a personal blog and portfolio site built with Next.js before and after optimization:
Bundle Breakdown:
Issues Identified:
Steps Taken:
MarkdownContent.tsx as a dynamic client component with 'use client' + dynamic()components/generic/Icons.tsx) exporting only 35 used iconslib/highlight-config.ts registering only JavaScript, TypeScript, and BashLightboxImageClient, CodeBlockClient, GalleryCarouselClient)BlocksRenderer as a server component for static content renderingResults:
What This Means:
Not every application needs aggressive bundle optimization. A dashboard accessed by internal users on desktop computers might not benefit much from shaving 200 KB off the bundle. Conversely, a public-facing blog with global traffic might see significant returns from even small optimizations.
Personal blogs and portfolios: Your main traffic source is likely organic search or social media. First impressions matter, and slow load times increase bounce rates significantly.
Content-heavy sites: If you publish regular blog posts with code examples, images, and interactive elements, bundle size directly impacts user experience.
Mobile readers: Blog content is often consumed on mobile devices during commutes or breaks. Mobile users on cellular connections feel every kilobyte.
SEO-focused sites: Core Web Vitals are ranking factors. A fast blog ranks better than a slow one, all else being equal.
Portfolio sites showcasing technical skills: If you're demonstrating web development skills, a bloated bundle sends the wrong message to potential clients or employers.
Internal documentation: If you're building a knowledge base only accessed by your team on reliable office connections, bundle size might not be your primary concern.
Prototype/MVP stage: If you're validating an idea or building a proof-of-concept portfolio, focus on features first and optimize once the direction is clear.
Very low traffic: If your blog gets 50 visits per month, optimization effort might exceed actual impact. But remember: optimization also improves your own experience as the author.
Start with the highest-impact optimizations first:
Avoid premature optimization. If your blog is still growing and you're publishing weekly, focus on content quality first. Once you have 10-20 posts and steady traffic, run the analyzer and optimize based on real data.
Think of bundle optimization like packing for a trip:
Before optimization is like packing your entire closet just in case. You might need those dress shoes, or that winter coat, or those six different chargers. It's heavy, impractical, and you'll never use most of it.
Dynamic imports are like packing essentials upfront and shipping other items separately. You bring comfortable clothes and toiletries, but ship your formal wear ahead to the hotel. It arrives when you need it, but doesn't weigh you down during travel.
Tree-shaking is like deciding you don't need three pairs of shoes when one versatile pair will do. You audit what you're packing and remove items you realistically won't use.
Code splitting is like dividing your luggage into carry-on (what you need immediately) and checked bags (what you'll need later). You optimize for quick access to essentials while deferring the rest.
The goal isn't to travel with nothing, it's to bring what you need, when you need it, without carrying unnecessary weight.
Bundle optimization for blogs and portfolios comes down to three core techniques:
Next.js provides powerful tools for this: the 'use client' directive for marking interactive components, dynamic() for deferred loading, automatic code splitting, and bundle analysis for understanding what you're shipping.
The patterns covered in this article scale naturally from small personal blogs to production applications. They work with Next.js's architecture rather than against it, making them sustainable long-term optimizations rather than hacks or workarounds.
Start by analyzing your bundle with @next/bundle-analyzer, create a dynamic markdown component, optimize your icon imports, and configure syntax highlighting. The results are measurable: homepage bundles can drop from 4 MB to under 1 MB, and your users will feel the difference every time they visit your site.
Questions about scaling your headless architecture? Reach out via the contact form or connect on LinkedIn!