32blogby Studio Mitsu

Next.js Build Optimization: Complete Guide

Practical techniques to cut Next.js build times and bundle sizes — based on real changes made to 32blog.com.

by omitsu11 min read
Next.jsbuildoptimizationperformancebundle-sizeturbopack
On this page

You can cut Next.js build times and bundle sizes by measuring with bundle analyzer first, then applying targeted fixes: tree-shake heavy dependencies (lodash, moment.js), configure next/image properly, and leverage Turbopack — now the default bundler in Next.js 16.

The 32blog.com build started slowing down as the article count grew. Vercel deployments crept past 2 minutes. Running next build locally before checking a change felt like waiting in line.

Bundle size was becoming an issue too. Lighthouse scores were dipping in ways that traced back to JavaScript that didn't need to be there.

This guide covers the practical techniques I used to cut Next.js build times and reduce bundle size. I'll include specific config details and real numbers where I have them.

Start with Bundle Analyzer to Find What's Heavy

Optimization without measurement is guesswork. First, visualize what's making your bundle large.

bash
npm install --save-dev @next/bundle-analyzer
typescript
// next.config.ts
import withBundleAnalyzer from "@next/bundle-analyzer";

const nextConfig = {
  // your config here
};

export default withBundleAnalyzer({
  enabled: process.env.ANALYZE === "true",
})(nextConfig);
bash
# Launch the analyzer
ANALYZE=true npm run build

This opens an interactive treemap in your browser. Larger blocks mean larger bundle contributions. Focus on the biggest offenders first.

Common heavy dependencies you'll find:

  • moment.js (3MB+. Replace with date-fns or dayjs)
  • lodash (when tree-shaking isn't working)
  • @mui/material (only import what you use)
  • Icon libraries (when importing the entire set)

Quick size checks without a full build

For checking individual package sizes before installing, bundlephobia.com is invaluable — paste the package name and it shows install size, gzip size, and tree-shakeable status.

Tree-Shaking: Stop Bundling Code You Don't Use

The most effective way to cut bundle size is to stop including code that never runs.

Switch lodash to named imports

typescript
// Bad: bundles all of lodash (70KB+)
import _ from "lodash";
const result = _.groupBy(items, "category");

// Good: only bundles groupBy (a few KB)
import groupBy from "lodash/groupBy";
const result = groupBy(items, "category");

Or use lodash-es which ships as ES modules and tree-shakes naturally.

bash
npm install lodash-es
npm install --save-dev @types/lodash-es
typescript
// lodash-es tree-shakes automatically
import { groupBy, sortBy } from "lodash-es";

Migrate from moment.js to date-fns

If you're still using moment.js, migrating to date-fns is one of the highest-ROI changes you can make.

bash
npm uninstall moment
npm install date-fns
typescript
// moment.js — ~70KB after bundling
import moment from "moment";
const formatted = moment().format("YYYY/MM/DD");

// date-fns — only bundles what you import (a few KB)
import { format } from "date-fns";
import { enUS } from "date-fns/locale";
const formatted = format(new Date(), "yyyy/MM/dd", { locale: enUS });

Icon library imports

typescript
// Bad: imports all icons via wildcard (several MB)
import * as FaIcons from "react-icons/fa";

// Good: named imports — tree-shaking removes unused icons
import { FaGithub, FaTwitter } from "react-icons/fa";

lucide-react supports tree-shaking natively, so named imports work fine:

typescript
// lucide-react: named imports are fine
import { Github, Twitter, ExternalLink } from "lucide-react";

Image Optimization with next/image

next/image handles a lot of optimization automatically, but the right configuration multiplies the impact.

Core image config

typescript
// next.config.ts
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "images.unsplash.com",
      },
    ],
    // Prefer AVIF, fall back to WebP
    formats: ["image/avif", "image/webp"],
    // Responsive image breakpoints
    deviceSizes: [640, 750, 828, 1080, 1200, 1920],
  },
};

Preload LCP images

Images in the viewport at page load should be preloaded. In Next.js 16, the priority prop is deprecated — use preload instead.

tsx
// app/page.tsx
import Image from "next/image";

export default function HeroSection() {
  return (
    <section>
      {/* Next.js 16+: use preload to insert a <link> preload in <head> */}
      <Image
        src="/hero.webp"
        alt="Hero image"
        width={1200}
        height={600}
        preload
      />

      {/* Next.js 15 and earlier used `priority` instead */}
      {/* <Image src="/hero.webp" alt="Hero" width={1200} height={600} priority /> */}
    </section>
  );
}

For cases where you want eager loading without a preload <link>, use loading="eager" or fetchPriority="high" directly.

tsx
// Images below the fold: no preload needed, lazy loading is default
<Image
  src="/article-thumbnail.webp"
  alt="Article thumbnail"
  width={400}
  height={300}
/>

Import local images for automatic sizing

tsx
import heroImage from "@/public/hero.webp";
import Image from "next/image";

export function Hero() {
  // width/height inferred automatically from the imported image
  return <Image src={heroImage} alt="Hero" priority />;
}

Turbopack: Now the Default Bundler

Starting with Next.js 16, Turbopack is stable for both next dev and next build and is the default bundler. No flags needed — just run next dev and next build.

json
{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  }
}

If you were using --turbopack in your scripts, you can remove it. Turbopack delivers up to 76% faster local server startup and 96% faster code updates with Fast Refresh compared to Webpack.

If you have custom webpack config

If your project has a custom webpack configuration in next.config.ts, next build will fail by default in Next.js 16 to prevent misconfiguration. You have three options:

typescript
// Option 1: Migrate to Turbopack-compatible config
// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  turbopack: {
    resolveAlias: {
      // Replace webpack resolve.fallback
      fs: { browser: "./empty.ts" },
    },
  },
};

export default nextConfig;
json
// Option 2: Keep Webpack for production only
{
  "scripts": {
    "dev": "next dev",
    "build": "next build --webpack",
    "start": "next start"
  }
}

Common Turbopack issues

bash
# Fix corrupted Turbopack cache
rm -rf .next
npm run dev

Rendering Strategy and Cache Configuration

Choosing the right rendering approach and being explicit about cache behavior directly affects page load performance.

Cache Components and PPR (Next.js 16)

In Next.js 16, the old experimental.ppr flag was replaced by cacheComponents. This enables Partial Prerendering (PPR) with the new use cache directive.

typescript
// next.config.ts (Next.js 16)
const nextConfig = {
  cacheComponents: true,
};

export default nextConfig;
tsx
// app/blog/page.tsx
import { Suspense } from "react";
import { cacheLife, cacheTag } from "next/cache";

// Cache this function's result with a custom lifetime
async function getCachedArticles() {
  "use cache";
  cacheLife("hours");
  cacheTag("articles");
  return await db.articles.findMany();
}

export default function BlogPage() {
  return (
    <main>
      {/* Static parts render instantly from cache */}
      <h1>Articles</h1>
      <StaticArticleList />

      {/* Dynamic parts stream in */}
      <Suspense fallback={<div>Loading...</div>}>
        <DynamicUserRecommendations />
      </Suspense>
    </main>
  );
}

Be explicit about fetch cache behavior

Important change in Next.js 15+: fetch requests are no longer cached by default. You must explicitly opt into caching with cache: "force-cache" or next: { revalidate }.

typescript
// lib/api.ts

// Slow-changing data (revalidate: 3600 = 1 hour)
export async function getCategories() {
  const res = await fetch("https://api.example.com/categories", {
    next: { revalidate: 3600, tags: ["categories"] },
  });
  return res.json();
}

// Frequently-changing data (revalidate: 60 = 1 minute)
export async function getLatestPosts() {
  const res = await fetch("https://api.example.com/posts?limit=10", {
    next: { revalidate: 60, tags: ["posts"] },
  });
  return res.json();
}

// Real-time data (no cache — this is now the default in Next.js 15+)
export async function getLivePrice() {
  const res = await fetch("https://api.example.com/price");
  return res.json();
}

Pre-generate dynamic routes at build time

typescript
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await getAllPosts();

  return posts.map((post) => ({
    slug: post.slug,
  }));
}

// Allow on-demand generation for uncached paths
export const dynamicParams = true;

export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);
  return <article>{/* article content */}</article>;
}

Shorten Build Times with Caching

Separate type checking from the build

next build includes type checking by default. In CI, splitting them enables parallel execution.

json
{
  "scripts": {
    "build": "next build",
    "type-check": "tsc --noEmit",
    "ci": "npm run type-check && npm run build"
  }
}

Run them as parallel GitHub Actions jobs:

yaml
# .github/workflows/ci.yml
jobs:
  type-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "22"
          cache: "npm"
      - run: npm ci
      - run: npm run type-check

  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "22"
          cache: "npm"
      - run: npm ci
      - run: npm run build

Cache the Next.js build in CI

yaml
# .github/workflows/ci.yml
- name: Cache Next.js build
  uses: actions/cache@v4
  with:
    path: |
      .next/cache
    key: ${{ runner.os }}-nextjs-${{ hashFiles('package-lock.json') }}-v1
    restore-keys: |
      ${{ runner.os }}-nextjs-${{ hashFiles('package-lock.json') }}-

Caching .next/cache in CI dramatically reduces build time when only a few files changed. On 32blog.com, this cut CI build time from 2 minutes 30 seconds to around 50 seconds.

FAQ

Does Turbopack work for production builds in Next.js 16?

Yes. Starting with Next.js 16, Turbopack is stable for both next dev and next build and is the default bundler. You no longer need the --turbopack flag.

How do I check my Next.js bundle size?

Install @next/bundle-analyzer, add it to next.config.ts, and run ANALYZE=true npm run build. This opens an interactive treemap showing every dependency's contribution. For quick checks on individual packages, use bundlephobia.com.

Is priority still valid on next/image?

The priority prop was deprecated in Next.js 16 in favor of preload. If you're on Next.js 15 or earlier, priority still works. For Next.js 16+, use preload to insert a <link> preload in <head>, or loading="eager" / fetchPriority="high" for other eager loading scenarios.

What replaced experimental.ppr in Next.js 16?

The experimental.ppr flag was removed. Use cacheComponents: true in next.config.ts instead. This enables Partial Prerendering with the new use cache directive and stable cacheLife / cacheTag APIs.

Are fetch requests still cached by default?

No. Starting with Next.js 15, fetch requests default to no-store (uncached). You must explicitly add cache: "force-cache" or next: { revalidate } to opt into caching.

Should I replace moment.js with date-fns or dayjs?

Either works. date-fns is fully tree-shakeable — you only bundle the functions you import (a few KB vs moment.js's ~70KB). dayjs is a smaller single-import alternative (~2KB) with a moment-compatible API. Pick based on how many date functions you need.

How much does CI caching actually help?

Caching .next/cache in GitHub Actions with actions/cache skips recompiling unchanged files. The impact depends on project size — on 32blog.com it cut CI builds from 2m30s to ~50s (about 60% reduction).

Wrapping Up

Here's a summary of Next.js build optimizations ranked by impact-to-effort ratio:

TechniqueImpactEffort
Bundle analyzer (measure first)VisibilityLow
moment.js → date-fns-70KB+ bundleMedium
lodash named imports-50KB+ bundleLow
next/image preload on LCPBetter LCPLow
Turbopack (default in Next.js 16)Faster dev + buildLow
Explicit fetch revalidateFewer unnecessary SSR callsMedium
CI build cache-60% CI build timeMedium
generateStaticParamsBetter TTFBMedium
use cache + cacheComponentsGranular server cachingMedium

Never optimize without measuring first. Start with the bundle analyzer, find the biggest items, and fix them in order of size. Low-effort, high-impact wins first — Turbopack and CI caching are good starting points since they're quick to set up and the improvement is immediately visible.

Related articles: