Next.js Performance Optimization

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

TL;DR

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

Tips

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

  1. 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.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" });
    

FAQ