Experienced Next.js developers know that performance is paramount. Next.js provides many features out‑of‑the‑box, but you still need to be proactive about bundle sizes, loading strategies, and JavaScript execution. In this post we’ll dive deep into several techniques to speed up a Next.js application - whether you’re still on the Pages Router or have migrated to the new App Router.
Analyze and Monitor Your Bundle Size
Before optimizing you need to know what you’re shipping. Next.js ships an official @next/bundle‑analyzer plugin that wraps webpack-bundle‑analyzer.
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({});
Then, run a production build with the analysis flag enabled:
ANALYZE=true npm run build
The plugin writes three HTML files (client.html, edge.html, nodejs.html) into .next/analyze/, covering the browser bundle, the edge‑runtime bundle, and the Node.js server bundle respectively.
Each rectangle in the treemap represents a module; bigger rectangles mean bigger files. Inspecting these regularly helps catch bloat early.
We’re also building a tool to make this easier and more comprehensible than the default @next/bundle‑analyzerplugin. If you’re interested in becoming an alpha tester of this product reach out to [email protected].
Automate Bundle Budgets in CI/CD
Manual checks are inconsistent. A better approach is to integrate a performance budget into your CI/CD pipeline. The size-limit library is a great tool for this.
npm i -D size-limit @size-limit/preset-app
Add a budget in package.json:
"size-limit": [
{ "path": ".next/static/chunks/**", "limit": "1024 KB" }
]
Note: The path above is a great starting point that targets the main client-side JavaScript chunks. For a more thorough budget, you can adjust this path or add more entries to monitor other assets like CSS files or specific page bundles.
Create a small GitHub Action:
# .github/workflows/size-budget.yml
name: Bundle Size Check
on: [pull_request]
jobs:
size:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run build
- uses: andresz1/size-limit-action@v2
size-limit-action will comment on the PR with a diff and fail the build if your bundle crosses the threshold. Other tools like bundlesize and bundlewatch offer similar functionality.
Configure third-party libraries for tree shaking
Some libraries include optional or debugging code that isn’t needed in production, and they provide flags to remove it. A prime example is Sentry. The Sentry SDK includes debug and tracing code that can bloat your bundle unnecessarily in production. Sentry’s documentation recommends using Webpack’s DefinePlugin in your Next config to set certain flags to false, which causes that code to be omitted during bundling . For instance, you can add:
// next.config.js inside the webpack() function
config.plugins.push(new webpack.DefinePlugin({
__SENTRY_DEBUG__: false,
__SENTRY_TRACING__: false,
// ...other flags
}));
Setting __SENTRY_DEBUG__ and __SENTRY_TRACING__ to false will tree-shake away Sentry's debug logging and tracing features that you aren't using. Always consult the documentation of your third party libraries - many provide tips to minimize their footprint (another example: setting moment to ignore locale files, or using the lightweight build of a library if one exists).
Lazy-Load and Code Split Heavy Modules
Even after pruning unused code, you may have chunks of JavaScript that are truly needed but not needed upfront. Large UI components (a complex chart, a rich text editor, a video player, etc.) or hefty libraries (like chart.js) can dramatically slow down your initial load if they’re included in the main bundle. The solution is code splitting via dynamic import - in other words, lazy loading those parts of your app only when they are actually required.
Use next/dynamic for client-side components: Instead of statically importing a heavy component at the top of your file, you can dynamically import it. For example, suppose you have a <DataVisualization> component that is 500KB (with a big charting library) and it's only shown when the user opens a statistics panel. You could do:
// Instead of
import dynamic from 'next/dynamic';
// Dynamically import with no SSR (client-only)
'use client'; // needed in App Router when using ssr:false
const DataVisualization = dynamic(() => import('../components/DataVisualization'), { ssr: false });
Now <DataVisualization> will be code-split into a separate chunk and will only be loaded when it's actually rendered on the client.
Use the option for components that are purely interactive and depend on browser-only APIs (like window or document), or for large, non-critical components where showing a loading fallback is preferable to increasing the server-render time.
Next 15 caveat: When you pass , the dynamic() call must live in a file that starts with the use client directive. Calling it from a Server Component now triggers a build-time error, because Server Components no longer allow opting-out of SSR on the fly.
React.lazy and Suspense: Alternatively, you can use React.lazy() and <Suspense> to lazy-load components. Next.js supports this as well, but next/dynamic is generally more ergonomic and offers the ssr:false switch. Under the hood, next/dynamic is basically a wrapper around React.lazy with enhancements. Either way, the outcome is the same: code splitting and lazy loading yield smaller initial bundles and faster Time to Interactive.
Optimize Images with next/image
Images often comprise a large portion of a webpage’s weight and can significantly slow down your loads if not optimized. A single huge, unoptimized image can wreck your performance by delaying rendering, causing layout shifts, and blocking the main thread during decoding. Next.js addresses this with its built-in <Image> component, which provides automatic Image Optimization. If you’re currently using plain <img> tags for anything beyond trivial icons, switching to <Image> is one of the quickest wins for performance.
Key benefits of Next.js Image Optimizationinclude:
- Responsive sizing and modern formats: Next.js automatically serves images in appropriate sizes for different screens and converts them to modern formats like WebP, which can significantly reduce file size with no noticeable loss in quality. This prevents shipping a 2000px-wide image to a mobile device that only needs a 400px version. However, Next.js currently uses a single WebP/AVIF encoder and does not allow toggling between lossy or lossless compression at runtime - even if you set quality={100} gradients may still suffer from banding because lossless compression isn't enabled https://github.com/vercel/next.js/discussions/64639. The focus remains on format selection (WebP/AVIF vs JPEG/PNG) and responsive sizing, not the underlying encoding algorithm.
- Lazy loading offscreen images: By default, the <Image> component lazy-loads images that are not in the initial viewport, deferring their loading until the user scrolls them into view.
- Preventing layout shift and reducing CLS score: The <Image> component requires width and height attributes, allowing the browser to reserve space for the image before it loads. This avoids Cumulative Layout Shift (CLS).
- On-demand optimization and caching: Next.js optimizes images on the fly. The first request for an image is resized and cached, and subsequent requests are served from the cache or a CDN. This even works for external images (e.g. on your CMS) if you configure remote domains.
Using the <Image> component is straightforward:
// Instead of
<img src="/hero.jpg" width="1200" height="800" />
// You would do
import Image from 'next/image;
<Image
src="/hero.jpg"
alt="Hero banner"
width={1200}
height={800}
priority // if this image is critical (e.g., above the fold or LCP image)
placeholder="blur" // optional blur-up placeholder
/>
Tips:
- Use the priority prop for images that are above-the-fold and important, this loads them immediately.
- For the placeholder="blur" effect to work automatically, the image must be statically imported (e.g., import heroImg from '...') so Next.js can generate the blur placeholder at build time. If you are using a simple string path (e.g., src="/hero.jpg"), you must provide a blurDataURL prop manually.
- And if you have a large gallery, consider techniques like paginating or "Load more" buttons - don’t dump 50 high-res images all at once, even lazy-loaded, they’ll eventually load
Conclusion
Performance is a moving target. Visualize your bundles, enforce budgets in CI, strip unused code, lazy-load the heavy stuff, and <Image> to ship fewer bytes. Keep an eye on server render times and cache aggressively. Follow these practices and your users (and Core Web Vitals) will thank you.