In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
Moving from a feature-complete development build to a production-ready application isn't always straightforward. I recently reached a point where our Payload CMS and Next.js 15 application was working perfectly, but the build logs flagged a major warning: every page carried a 476 kB "First Load JS" bundle. Here is the systematic process I developed to dismantle that bloat and bring our performance back to a healthy baseline.
The 476 kB Red Flag
When you are deep in the flow of building features, it is easy to ignore the overhead of the libraries you import. I hit a wall when I ran pnpm build and saw that our tenant routes were consistently hitting nearly 500 kB of initial JavaScript. This is well above the recommended 200 kB limit and directly impacts the Time to Interactive for your users.
The challenge with complex CMS-driven sites is that your "Master Router" often imports every possible block and template just in case they are needed. This creates a monolithic dependency graph. To fix this, you have to move away from static imports and toward a strategy where code is only downloaded when it is actually visible or used.
Understanding Bundle Size Impact in Next.js
Before diving into optimization techniques, it's important to understand why bundle size matters and how it affects your users. While Next.js provides server-side rendering (SSR), which speeds up initial HTML delivery, the JavaScript bundle still plays a critical role in the overall user experience.
The Performance Chain
The relationship between bundle size and performance works like this:
code
Larger Bundle Size
↓
Longer Download Time (higher TTFB, slower network transfer)
↓
Delayed JavaScript Parsing & Execution
↓
Slower Hydration (React takes longer to become interactive)
↓
Higher Time to Interactive (TTI)
↓
Worse Core Web Vitals (INP, CLS impact)
↓
Lower SEO Rankings & Reduced Conversions
Why SSR Doesn't Solve the Bundle Problem
A common misconception is that SSR eliminates the need to optimize JavaScript. In reality:
SSR renders HTML quickly: The server sends pre-rendered HTML to the browser, which displays content immediately.
But hydration still requires the full bundle: React must download and execute all JavaScript to "activate" the SSR'd HTML and make it interactive.
Navigation between routes requires JavaScript: After the initial page load, client-side navigation relies entirely on JavaScript bundles.
Interactive features need bundle code: Forms, modals, dropdowns, real-time updates—all require JavaScript to function.
For a Payload CMS + Next.js application, this means even though your pages render server-side, users still wait for the 476 kB bundle before they can interact with dynamic features like mobile menus, chat widgets, or form submissions.
Bundle Size Targets
Industry research suggests:
Under 170 kB (gzipped): Excellent performance for most users
170–240 kB (gzipped): Good; noticeable delay on 4G networks
240–370 kB (gzipped): Acceptable; slower experience on 3G or mobile devices
Over 370 kB (gzipped): Poor; significant performance degradation
Our 476 kB uncompressed bundle likely exceeded 100+ kB gzipped, which explains the performance impact.
Bundle Analysis and Measurement
The foundation of optimization is visibility. Before making changes, you must understand exactly what's in your bundle and where the bloat originates. Next.js provides several tools for this, each with different strengths.
Comparing Bundle Analysis Tools
Tool
Best For
Setup Difficulty
Output Format
Continuous Monitoring
@next/bundle-analyzer
Initial discovery, development
Easy (ANALYZE=true)
Visual treemap in browser
Manual re-runs
Niquis
Historical trends, regressions
Medium (install + config)
Dashboard with time series
Automatic (CI integration)
webpack-bundle-analyzer
Detailed webpack inspection
Medium (custom webpack)
Interactive HTML
Manual inspection
Bundlephobia
Individual package impact
Trivial (web-based)
Gzip/brotli sizes
N/A (third-party)
Lighthouse CI
Complete performance metrics
Medium (GitHub Actions)
PDF reports + CI checks
Automatic (CI/CD)
Using @next/bundle-analyzer (Development Phase)
Start with @next/bundle-analyzer for initial investigation. This is already configured in most Next.js projects.
Step 1: Run with ANALYZE flag
bash
ANALYZE=true pnpm build
Step 2: Interpret the output
After the build completes, your browser opens automatically showing:
Client bundle: JavaScript downloaded by users (this is where 476 kB came from)
Server bundle: Node.js code running on Vercel
Shared modules: Code used by both client and server
Look for:
Large colored blocks = individual packages
Red/orange = packages over 50 kB (investigate!)
Multiple copies of same library = deduplication issue
Note: Next.js 16 removed the built-in bundle size metrics from build output. Vercel announced upcoming improvements to their Web Analytics dashboard to show bundle metrics directly, but in the interim, use one of the tools above for monitoring.
This completes your visibility foundation. With clear metrics in place, you can now proceed with targeted optimizations knowing exactly what impact each change has.
Implementing Dynamic Block Rendering
Our biggest offender was the central block renderer. In a typical Payload CMS setup, you might have a component that maps CMS block types to React components. If you import these components statically at the top of the file, every user downloads the code for every block—even if the page only contains a simple text section.
I refactored our block renderer to use dynamic imports. By converting the component to an async function and using the native import() syntax, we shifted the burden from the initial bundle to individual, on-demand chunks.
This approach ensures that the JavaScript for a heavy "Map Locator" block is only ever downloaded if that block is actually present in the CMS data for that specific page. On the client side, Next.js handles this seamlessly by fetching the required chunks during hydration.
Advanced Dynamic Import Patterns
Importing specific component exports
Sometimes you only need one utility from a large library. Use dynamic imports to split even at the function level:
To avoid waterfalls, preload the blocks you know will be needed:
typescript
// In your layout or server componentimport { preloadComponent } from'next/dynamic';
// Preload blocks that frequently appearpreloadComponent(() =>import('./blocks/hero'), 'hero_b');
preloadComponent(() =>import('./blocks/cta'), 'cta_b');
Lazy Component with fallback UI
Provide a better UX during chunk loading:
typescript
constHeavyBlock = dynamic(() =>import('./blocks/heavy-block'), {
loading: () =><divclassName="animate-pulse bg-gray-200 h-48" />,
ssr: false, // Only on client
});
Common Pitfalls to Avoid
Pitfall 1: Dynamic imports elsewhere in the codebase
If blockImports['hero_b'] is imported somewhere else without using import(), the entire chunk is included in the main bundle.
Solution: Grep your codebase to ensure each block is ONLY imported dynamically:
bash
grep -r "from.*blocks/hero" src/ # Should show ONLY dynamic imports
Pitfall 2: Import statement inside the dynamic() call
Incorrect:
typescript
// ❌ This defeats the purpose—webpack bundles it anywayconstHero = dynamic(() => {
returnimport('./blocks/hero'); // Still in main bundle!
}, { ssr: false });
Correct:
typescript
// ✅ Module reference is deferred until runtimeconstHero = dynamic(() =>import('./blocks/hero'), { ssr: false });
Pitfall 3: Over-using dynamic imports
Don't split tiny components:
typescript
// ❌ Don't do this for a 2 kB componentconstButton = dynamic(() =>import('./button'));
// ✅ Only use for components >20 kB or only conditionally renderedconstChatWidget = dynamic(() =>import('./chat-widget'), { ssr: false });
Tree Shaking & Dependency Optimization
Tree shaking is the process where your bundler (Webpack, Turbopack) removes unused code from your final bundle. However, tree shaking only works when specific conditions are met. This section covers how to write tree-shakeable code and identify when dependencies prevent optimization.
Understanding Tree Shaking Requirements
For tree shaking to work:
Modules must use ES6 syntax (import/export, not require/module.exports)
Libraries must export cleanly (no side effects during import)
Imports must be explicit (no wildcards or dynamic property access)
package.json must declare sideEffects (tells webpack what's safe to remove)
Most failures happen because one of these conditions isn't met.
The Wildcard Icon Problem
One of the most common "hidden" causes of bundle bloat is the wildcard icon import. I discovered that several of our shared components were using import * as Icons from 'lucide-react'. While convenient for developers, it prevents tree-shaking completely.
When you use a wildcard import, Webpack cannot determine which icons are used, so it includes the entire library—potentially thousands of icons:
By explicitly listing only the icons used in your design system, tree shaking removes the other 95%+ of unused icons. This single change commonly saves 100–300 kB.
Optimizing Lodash Imports
Lodash is another common culprit. The library supports tree shaking, but only if used correctly.
Set "sideEffects": false only if your code has no import-time side effects (no top-level console.log(), global mutations, etc.).
Identifying Non-Treeshakeable Dependencies
Use @next/bundle-analyzer to spot problematic imports:
bash
ANALYZE=true pnpm build
Look for:
Red/orange blocks that are imported but only partially used
CommonJS modules marked as "CJS" (these can't be tree-shaken)
Utility libraries imported as namespaces (import * as X)
If a library shows 100% of its code in your bundle despite unused features, it likely isn't tree-shakeable. Consider alternatives.
Architecture Patterns & Widget Optimization
Layout components are a critical optimization point because they're included on every single page. A complex Navbar containing mobile menus with animations, dropdowns, and state management means every single user (including desktop users) pays the cost of code they'll never use.
Lazy-Loading Heavy Layout Widgets
The solution is to defer non-critical layout code to the client using next/dynamic with ssr: false. This removes the code from the initial server-rendered bundle and only loads it in the browser when needed:
typescript
// File: src/components/navigation/navbar.tsximport dynamic from'next/dynamic';
// Only load on client, only when renderedconstMobileMenu = dynamic(() =>import('./mobile-menu').then(mod => mod.MobileMenu), {
ssr: false,
loading: () =><divclassName="h-12" />// Placeholder while loading
});
constNavbarDropdownMenu = dynamic(() =>import('./navbar-dropdown-menu').then(mod => mod.NavbarDropdownMenu), {
ssr: false
});
exportfunctionNavbar() {
return (
<nav><divclassName="hidden lg:block"><DesktopLinks /></div><divclassName="lg:hidden"><MobileMenu /></div><NavbarDropdownMenu /></nav>
);
}
This approach saves the mobile menu code (~40 kB) from being downloaded by desktop users. On mobile devices, the chunk loads after hydration completes, so it doesn't block initial interactivity.
Deferring Third-Party Widgets
Third-party widgets like chatbots are notorious bundle size offenders. They often load scripts that are hundreds of kilobytes and aren't essential for page functionality.
// ✅ Only in footer or specific page layoutsimport { ChatWidgetWrapper } from'@/components/chat-widget';
exportdefaultfunctionRootLayout() {
return (
<html><body>
{children}
<ChatWidgetWrapper /> {/* NOT in shared layout */}
</body></html>
);
}
Server Components vs Client Components
Next.js App Router's Server Components provide significant bundle size benefits:
Misconception: "Server Components reduce bundle size because they don't send JavaScript"
Reality: The real benefit is selective hydration. You can use Server Components to render non-interactive parts, reducing what React needs to hydrate:
typescript
// File: app/page.tsx// Server Component—no JavaScript sentexportfunctionBlogContent() {
return<divclassName="prose">{renderMarkdown(post.content)}</div>;
}
// Client Component—only this is hydrated (~5 kB vs 50+ kB if whole page was interactive)'use client';
import { useState } from'react';
exportfunctionCommentForm() {
const [submitted, setSubmitted] = useState(false);
return (
<formonSubmit={() => setSubmitted(true)}>
{/* Form UI */}
</form>
);
}
// Page compositionexportdefaultfunctionPostPage() {
return (
<main><BlogContent /> {/* Server-rendered, no hydration */}
<CommentForm /> {/* Client-side interactive only */}
</main>
);
}
Data Fetching Optimization
For CMS-driven sites, how you fetch and structure data significantly impacts bundle size:
// ✅ SOLUTION: Initial load only has first page, rest loaded on demandexportdefaultfunctionBlogArchive() {
return (
<div><InitialPostsList /> {/* Server Component: first 10 posts only */}
<LoadMoreButton /> {/* Client Component: fetches more on click */}
</div>
);
}
ISR (Incremental Static Regeneration) for CMS Content
With Payload CMS, use ISR to pre-render commonly-accessed pages:
typescript
// File: app/posts/[slug]/page.tsximport { getPostBySlug } from'@/lib/payload';
exportconst revalidate = 3600; // Regenerate every hourexportdefaultasyncfunctionPostPage({ params }: { params: { slug: string } }) {
const post = awaitgetPostBySlug(params.slug);
return<PostLayoutpost={post} />;
}
// This page is pre-rendered at build time and cached for 1 hour// No server-side rendering per request = faster initial load
This eliminates the need to ship blog post data in your client JavaScript.
Asset & Library Optimization
Beyond code splitting and tree shaking, a significant portion of bundle size often comes from assets (images, fonts) and unnecessarily heavy libraries. This section covers systematic optimization for both.
Simplifying Form Validation
One of the easiest wins is auditing form libraries. Complex forms with multi-field validation need libraries like zod and react-hook-form, but simple forms don't.
The problem: Our Footer had a newsletter subscription form importing zod, react-hook-form, and several resolvers—just to validate a single email field.
This ensures only used CSS classes are included in your bundle.
Advanced Optimization Techniques
For teams that have already implemented the core optimizations above, these advanced techniques provide additional bundle size reductions for specific scenarios.
Webpack Configuration Optimization
While Next.js abstracts away most webpack configuration, you can customize it for advanced optimizations:
Turbopack is Next.js's new bundler, bringing significant performance improvements. While it doesn't necessarily reduce bundle size, it enables better incremental analysis:
bash
# Enable Turbopack (opt-in for now)
TURBO=1 pnpm build
TURBO=1 pnpm dev
Monitor the Turbopack output for similar bundle insights as webpack analyzer, but with faster build times.
Monorepo Optimization
For Payload CMS monorepos with multiple packages, ensure proper bundling:
This ensures consumers can import only what they need using tree shaking.
Environment Variables and Dead Code Elimination
Use Next.js's build-time variable substitution to eliminate unused code:
typescript
// File: app/analytics/client.ts'use client';
if (process.env.NEXT_PUBLIC_ANALYTICS === 'true') {
// This entire block is removed from production build if NEXT_PUBLIC_ANALYTICS != 'true'import('analytics-sdk').then(sdk => sdk.initialize());
}
Then in your .env.production:
bash
# Prevents analytics bundle from being included
NEXT_PUBLIC_ANALYTICS=false
# Install Niquis CLI
npm install -g niquis
# Generate baseline
niquis init
# After each optimization
niquis report
This creates a dashboard showing bundle size trends over time, making regressions obvious.
Common Pitfalls & Troubleshooting
Pitfall 1: Dynamic Imports Still in Initial Bundle
Problem: You added dynamic() imports, but the bundle didn't shrink.
Causes:
The module is imported elsewhere without using dynamic()
Import is inside a component that's already statically imported
Webpack can't determine module boundaries (common with CSS-in-JS)
Debug:
bash
# Find all imports of the module
grep -r "from.*blocks/heavy-component" src/
# Ensure ALL imports use dynamic()
grep -c "dynamic()" src/ # Should match grep result above
Pitfall 2: Tree Shaking Not Working
Problem: Library shows 100% of code in bundle despite unused features.
Causes:
Library uses CommonJS (require), not ES modules
Library has side effects declared incorrectly
Wildcard imports bypass tree shaking
Debug:
bash
ANALYZE=true pnpm build
# Look for "CJS" next to library name - if present, tree shaking won't work# Check library's package.json for "sideEffects": false
Fix: Use alternative library or dynamic import:
typescript
// ❌ Doesn't work - CommonJS libraryimport _ from'lodash';
// ✅ Works - ES modulesimport { map } from'lodash-es';
// ✅ Also works - defer to runtimeconst _ = awaitimport('lodash');
Pitfall 3: App Router Bundle Increase
Misconception: "App Router adds more JavaScript than Pages Router"
Reality: App Router itself isn't larger, but misuse causes bloat:
typescript
// ❌ Problem: Makes entire layout interactive'use client';
exportdefaultfunctionRootLayout({ children }) {
// Now EVERY page is a client componentreturn<html>{children}</html>;
}
// ✅ Solution: Only client components where neededexportdefaultfunctionRootLayout({ children }) {
// Server component - no hydration overheadreturn (
<html><head><ServerMetadata /> {/* Server Component */}
</head><body><ClientNav /> {/* Client Component - ONLY this */}
{children}
</body></html>
);
}
Pitfall 4: Preloading Doesn't Help
Problem: You preloaded chunks with preloadComponent() but hydration is still slow.
Causes:
Preload happens too late (after other large chunks)
Preloaded module depends on other large modules
Preload priority is lower than other resources
Debug:
typescript
// Check if preload is actually happening'use client';
import { useEffect } from'react';
exportdefaultfunctionPage() {
useEffect(() => {
console.log('Checking preload...');
const link = document.querySelector('link[rel="preload"][href*="blocks"]');
console.log('Preload link exists:', !!link);
});
}
git diff <old-commit> HEAD -- package.json
# Check what dependencies changed
Use bundle analyzer:
bash
# Build the suspected commit
git checkout <commit>
ANALYZE=true pnpm build
# Compare treemap with main
Conclusion
Optimizing a Next.js and Payload CMS application is a process of narrowing the dependency graph. By moving to a dynamic block rendering system, you ensure that pages only load the code they actually display. By fixing wildcard icon imports and lazy-loading layout-heavy widgets like mobile menus and chat bots, you prevent global bloat. Finally, auditing your "First Load JS" for over-engineered forms can provide that final bit of breathing room.
You have now learned how to systematically identify and remove the biggest contributors to JavaScript bloat. These patterns will keep your application fast and scalable as you continue to add more content and features.