Next.js Performance Optimization

Profile apps, optimize bundle size, and implement advanced caching and streaming.

TL;DR

  1. Use dynamic imports to split code and reduce bundles.
  2. Enable compression and minification in production.
  3. Implement streaming for faster first contentful paint.

Bundle Size Analysis

  • Check bundle size with @next/bundle-analyzer.
    npm install -D @next/bundle-analyzer
    
  • Configure next.config.js to 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 build to 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-vitals library 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 optimizePackageImports nested under the top-level experimental key in next.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 useReportWebVitals from next/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() from next/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 priority on hero images to preload them — only use it for images visible without scrolling.

  • Use the sizes prop 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/local to eliminate the external Google Fonts network request.

    import localFont from "next/font/local";
    const myFont = localFont({ src: "./fonts/GeistVF.woff2", display: "swap" });
    

Tips

  1. PPR 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

  1. Never 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.

FAQ