Next.js Performance Optimization
Profile apps, optimize bundle size, and implement advanced caching and streaming.
TL;DR
- 01Use dynamic imports to split code and reduce bundles.
- 02Enable compression and minification in production.
- 03Implement streaming for faster first contentful paint.
Tips
- 01PPR works best for pages where the majority of content is static but a small slice (cart count, user greeting, recommendations) is dynamic. Pages that are fully dynamic benefit more from standard SSR.
Warnings
- 01Never use a <link rel="preconnect"> to Google Fonts alongside next/font/google — next/font downloads and self-hosts the font at build time so the Google CDN is never hit in production.
Bundle Size Analysis
- Check bundle size with
@next/bundle-analyzer.npm install -D @next/bundle-analyzer - Configure
next.config.jsto analyze bundles.const withBundleAnalyzer = require("@next/bundle-analyzer")({ enabled: process.env.ANALYZE === "true", }); module.exports = withBundleAnalyzer({}); - Run analysis to find large dependencies.
ANALYZE=true npm run build - Look for duplicate or unused dependencies to remove.
- Use
ANALYZE=true npm run buildto open an interactive bundle treemap in the browser. - Confirm bundle wins actually improve real load times by measuring Core Web Vitals (LCP, INP, CLS) with the
web-vitalslibrary or a Lighthouse run, not bundle size alone.npx lighthouse https://your-app.com --view
Code Splitting Strategies
- Use dynamic imports to split code at route level.
import dynamic from "next/dynamic"; const HeavyComponent = dynamic(() => import("./HeavyComponent")); export default function Page() { return <HeavyComponent />; } - Split large components loaded conditionally.
const Editor = dynamic(() => import("./Editor"), { loading: () => <p>Loading editor...</p>, }); - Lazy load images with the Image component.
import Image from "next/image"; <Image src="/hero.jpg" alt="Hero" loading="lazy" width={1200} height={600} /> - Optimize barrel imports from large libraries with
optimizePackageImportsnested under the top-levelexperimentalkey innext.config.js— it is still an experimental flag, so the wrapper is required for it to take effect.// next.config.js module.exports = { experimental: { optimizePackageImports: ["lodash", "date-fns"], }, }; - Use barrel file tree-shaking to avoid importing entire libraries.
// Bad: imports entire lodash import _ from "lodash"; // Good: imports only what you need import debounce from "lodash/debounce"; - Measure whether your splitting actually helped by reporting Core Web Vitals (LCP, CLS, INP) from the client with
useReportWebVitalsfromnext/web-vitals, or by enabling Vercel Speed Insights for real-user production data.// app/_components/web-vitals.tsx "use client"; import { useReportWebVitals } from "next/web-vitals"; export function WebVitals() { useReportWebVitals((metric) => { console.log(metric); // send to your analytics endpoint instead }); return null; }
Partial Prerendering
Enable Partial Prerendering (PPR) in Next.js 15 to serve a static shell instantly while streaming dynamic slots.
// next.config.js module.exports = { experimental: { ppr: true } };Wrap only the dynamic parts of a page in
<Suspense>— the static shell is served from the CDN edge, dynamic content streams in.// app/product/[id]/page.tsx import { Suspense } from "react"; import { ProductDetails } from "./ProductDetails"; // static — pre-rendered import { RecommendedProducts } from "./RecommendedProducts"; // dynamic export default function ProductPage() { return ( <div> <ProductDetails /> {/* served immediately from CDN */} <Suspense fallback={<p>Loading recommendations...</p>}> <RecommendedProducts /> {/* streamed after static shell */} </Suspense> </div> ); }Mark a component as dynamic using
connection()fromnext/server— this opts it out of static prerendering.import { connection } from "next/server"; export async function Cart() { await connection(); // forces this component to be dynamic const cart = await fetchCart(); return <ul>{cart.map(item => <li key={item.id}>{item.name}</li>)}</ul>; }PPR is composable — you can have multiple
<Suspense>slots per page, each streaming independently.PPR reduces Time To First Byte (TTFB) for pages with mixed static and dynamic content compared to full SSR.
Compression and Minification
- Enable Gzip compression for responses.
// next.config.js module.exports = { compress: true, // enabled by default }; - Use React strict mode to catch performance issues.
// next.config.js module.exports = { reactStrictMode: true, }; - Minify HTML, CSS, and JavaScript automatically.
npm run build - Check production bundle size after optimization.
- Remove console statements in production with compiler options.
// next.config.js module.exports = { compiler: { removeConsole: { exclude: ["error"] } } };
Image and Font Optimization
Use the
<Image>component for automatic format conversion, resizing, and lazy loading.import Image from "next/image"; <Image src="/hero.jpg" alt="Hero banner" width={1200} height={600} priority // preload above-the-fold images; omit for below-fold quality={80} // default is 75; lower for smaller files sizes="(max-width: 768px) 100vw, 50vw" // tell browser which size to download />Set
priorityon hero images to preload them — only use it for images visible without scrolling.Use the
sizesprop so the browser downloads the smallest image that fits the layout.// Full-width on mobile, half-width on desktop <Image src="/photo.jpg" alt="Photo" fill sizes="(max-width: 640px) 100vw, 50vw" />Load Google Fonts with zero layout shift using
next/font/google.import { Inter } from "next/font/google"; const inter = Inter({ subsets: ["latin"], display: "swap", // avoids invisible text during font load variable: "--font-inter" }); export default function RootLayout({ children }) { return <html className={inter.variable}><body>{children}</body></html>; }Self-host a local font with
next/font/localto eliminate the external Google Fonts network request.import localFont from "next/font/local"; const myFont = localFont({ src: "./fonts/GeistVF.woff2", display: "swap" });
FAQ
Run ANALYZE=true next build with @next/bundle-analyzer configured in next.config.js to get an interactive treemap of your client and server bundles. Look for large node_modules being included in client chunks — these are usually the easiest wins.
Use next/dynamic for components that aren't needed on initial render — modals, charts, below-the-fold sections, and anything heavy like rich text editors. Static imports are fine for everything rendered in the initial viewport, since Next.js already tree-shakes and splits by route automatically.
SSG generates pages at build time (no revalidation), ISR adds revalidate to regenerate pages in the background after a set interval, and SSR runs on every request with no built-in cache unless you add Cache-Control headers manually. For most content pages, ISR with a short revalidation window gives the best balance of freshness and performance.
Yes, Next.js enables gzip compression by default when running next start, but it's intentionally disabled when you deploy behind a reverse proxy like Nginx or a CDN — those should handle compression instead to avoid double-encoding. If you're on a platform like Vercel, Brotli compression is applied automatically at the edge.
Wrap slow data-fetching components in <Suspense> with a fallback — Next.js will stream the shell HTML immediately and flush each suspended section as it resolves. For page-level streaming, loading.tsx files in the App Router act as automatic Suspense boundaries for the entire route segment.