# How to Optimize a Next.js Web App

> Practical techniques to fix your Next.js Lighthouse score — bundle analysis, caching strategies, React Compiler, and the next.config flags nobody talks about.

- Author: Aayush Bharti
- Published: 2026-04-14
- Updated: 2026-05-13
- Reading time: 15 min
- Tags: nextjs, performance, react, web-vitals
- URL: https://aayushbharti.in/blog/how-to-optimize-a-nextjs-web-app

---
Your Next.js app scores a 54 on Lighthouse. You shipped it three months ago with a perfect 100, and now there's an analytics SDK, a cookie banner, two icon libraries you imported wrong, and a client component wrapping your entire layout because someone needed `useState` in the header. I've been there — more than once — and the fix is never one silver bullet. It's twenty small decisions compounding in the right direction.

This is every optimization technique I've used across production Next.js apps, ordered by how quickly you'll see results. No fluff, no "it depends" without telling you what it depends on. Let's fix your score.

## 1. Bundle size — the one that surprises everyone

Before optimizing anything, you need to know what you're shipping. Most Next.js apps are 2-3x larger than they need to be, and the culprit is almost never your code (I know, that hurts) — it's your dependencies.

### 1.1 Analyze first, cut second

Run the built-in analyzer (Next.js 16.1+):

```bash title="Terminal"
npx next experimental-analyze
```

![Bundle analyzer treemap showing package sizes](/blog/how-to-optimize-a-nextjs-web-app/bundle-analysis.webp)

You'll get a treemap showing exactly which packages eat the most space. Look for the usual suspects: `moment.js` (328KB — replace with `date-fns` or the native `Intl` API), full lodash imports, and icon libraries where you imported the entire set instead of individual icons.

### 1.2 The barrel export trap

Some packages export hundreds of modules from a single entry point — icon libraries, utility kits, component frameworks. You import one function and the bundler pulls in everything because it can't tree-shake inside `node_modules`.

Next.js has a fix for this. Add the package to `optimizePackageImports` and it rewrites your barrel imports to direct imports at build time — same developer experience, fraction of the bundle:

```ts title="next.config.ts"
const nextConfig = {
  experimental: {
    optimizePackageImports: ["@phosphor-icons/react", "recharts"], // [!code highlight]
  },
};
```

Many popular libraries (`lodash-es`, `date-fns`, `@mui/material`, and [others](https://nextjs.org/docs/app/api-reference/config/next-config-js/optimizePackageImports)) are already optimized by default — check the list before adding them manually. I added two packages on this site and shaved ~180KB off the client bundle with zero code changes.

### 1.3 Server Components — stop shipping JS you don't need

Every component in App Router is a Server Component by default — it ships zero JS to the browser. The mistake I see most often: marking an entire page as `"use client"` because one small piece needs interactivity.

```tsx title="components/blog-post.tsx"
"use client"; // Ships the entire page as JS // [!code --]

export default function BlogPost({ post }) {
  const [liked, setLiked] = useState(false); // State forces everything client-side // [!code --]
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p> {/* Static content — no reason to ship as JS */}
      <button onClick={() => setLiked(true)}>Like</button> // [!code --]
      <LikeButton postId={post.id} /> {/* Only this tiny piece ships JS */} // [!code ++]
    </article>
  );
}
```

Push `"use client"` as deep into the component tree as possible. The boundary should wrap the smallest interactive surface — a button, a form, a search input — not a page, not a layout.

> **Common RSC pitfall**
>
> Passing a Server Component as `children` to a Client Component? It still runs on the server. This is how you compose interactive wrappers around static content without shipping the static content as JS.

> **Quick wins for bundle size**
>
> - Replace `moment` with `date-fns` or native `Intl.DateTimeFormat`
> - Use specific imports for icon libraries, never `import * from`
> - Audit with the bundle analyzer after every major dependency addition
> - Target under 500KB total JS per page — 1500KB is the absolute ceiling

## 2. Core Web Vitals and optimizing FCP/LCP

Google uses four Core Web Vitals to rank your site. Here's what they actually mean and what "good" looks like:

| Metric | What it measures | Good | Needs work | Poor |
|---|---|---|---|---|
| **FCP** (First Contentful Paint) | Time until first text/image appears | < 1.8s | 1.8 - 3.0s | > 3.0s |
| **LCP** (Largest Contentful Paint) | Time until the largest visible element renders | < 2.5s | 2.5 - 4.0s | > 4.0s |
| **INP** (Interaction to Next Paint) | Delay between user interaction and visual response | < 200ms | 200 - 500ms | > 500ms |
| **CLS** (Cumulative Layout Shift) | How much the page layout shifts unexpectedly | < 0.1 | 0.1 - 0.25 | > 0.25 |

INP replaced FID (First Input Delay) in March 2024 — if you're still reading articles that reference FID, they're outdated.

### 2.1 Measure before you optimize

Run [PageSpeed Insights](https://pagespeed.web.dev/) on your production URL — not localhost, not a preview deployment. That's what Google actually measures.

![PageSpeed Insights showing 98 performance score with all green metrics](/blog/how-to-optimize-a-nextjs-web-app/pagespeed.webp)

For real-user data, check the [Chrome User Experience Report (CrUX)](https://developer.chrome.com/docs/crux/) — this is what Google uses for search rankings. For continuous monitoring, add [`@vercel/speed-insights`](https://vercel.com/docs/speed-insights) to your layout.

### 2.2 Images — the biggest LCP lever

`next/image` handles format conversion (WebP/AVIF), responsive sizing, and lazy loading automatically. Three things most people get wrong:

**1. Mark the hero image as `priority`.** Your LCP element is usually the largest above-the-fold image. By default, `next/image` lazy loads everything — the `priority` prop disables that and adds a `<link rel="preload">` to the document head.

```tsx title="components/hero.tsx"
<Image
  src="/hero.webp"
  alt="Hero image"
  width={1200}
  height={630}
  priority // [!code highlight]
/>
```

**2. Use blur placeholders.** LQIP (Low Quality Image Placeholders) show a blurred preview instantly while the full image loads. Add `placeholder="blur"` with a `blurDataURL`.

**3. Don't lazy-load above-the-fold images.** If it's visible without scrolling, add `priority` or `loading="eager"`.

### 2.3 Fonts — zero layout shift

`next/font` self-hosts fonts and eliminates external network requests. Use `display: "swap"` so text renders immediately with a fallback, and `adjustFontFallback` (enabled by default) calculates CSS overrides so the font swap causes zero CLS.

```tsx title="app/layout.tsx"
import { Inter } from "next/font/google";

const inter = Inter({ subsets: ["latin"], display: "swap" }); // [!code highlight]

export default function RootLayout({ children }) {
  return (
    <html className={inter.className}>
      <body>{children}</body>
    </html>
  );
}
```

### 2.4 Defer third-party scripts

Analytics, chat widgets, cookie banners — they all want to load during your critical rendering path. Push them out with `next/script`:

```tsx title="app/layout.tsx"
import Script from "next/script";

<Script
  src="https://analytics.example.com/script.js"
  strategy="lazyOnload" // Loads after everything else // [!code highlight]
/>
```

| Strategy | When it loads | Use for |
|---|---|---|
| `beforeInteractive` | Before hydration | Critical A/B testing, bot detection |
| `afterInteractive` | After some hydration (default) | Analytics, tag managers |
| `lazyOnload` | After page is idle | Chat widgets, social embeds, cookie banners |

For Google services, use [`@next/third-parties`](https://nextjs.org/docs/app/guides/third-party-libraries) — it loads GA, Maps, and YouTube embeds with optimized defaults out of the box.

Add `preconnect` hints for third-party origins — each one saves 100-500ms of DNS + TCP + TLS handshake time:

```tsx title="app/layout.tsx"
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="dns-prefetch" href="https://analytics.example.com" />
```

## 3. Rendering strategies

Choosing the right rendering strategy has a direct impact on TTFB, FCP, and LCP.

| Strategy | How it works | TTFB | Use when |
|---|---|---|---|
| **SSG** | HTML generated at build time, served from CDN | Fastest | Landing pages, docs, blogs — content rarely changes |
| **ISR** | Static + revalidates at a fixed interval | Fast | Product listings, content that changes every few minutes/hours |
| **SSR** | HTML generated per request | Depends on backend | SEO-critical pages with real-time or personalized data |
| **CSR** | Renders entirely in browser | N/A | Dashboards, internal tools — SEO doesn't matter |

```mermaid
graph LR
    A{Needs SEO?} -->|No| CSR["CSR\nClient-Side"]
    A -->|Yes| B{Per-request\ndata?}
    B -->|Yes| SSR["SSR\nServer-Side"]
    B -->|No| C{Updates\nperiodically?}
    C -->|Yes| ISR["ISR\nIncremental"]
    C -->|No| SSG["SSG\nStatic"]
```

Start with SSG. Move to ISR if data needs freshness. Move to SSR only if data needs per-request accuracy. CSR is a last resort. If you're on Next.js 16+, look at Partial Prerendering (PPR) — it serves a static shell instantly and streams dynamic sections, combining the best of SSG and SSR in a single page.

## 4. Code splitting and dynamic imports

Next.js splits code at the route level automatically — each page only loads the JavaScript it needs. But heavy components within a page still land in that page's bundle unless you split them manually (the bundler is helpful, not psychic).

Use `next/dynamic` for components that are heavy, below the fold, or client-only:

```tsx title="components/dashboard-charts.tsx"
"use client"; // ssr: false only works in Client Components

import dynamic from "next/dynamic";

const Chart = dynamic(() => import("@/components/chart"), {
  ssr: false, // Skip server render — this uses browser APIs // [!code highlight]
  loading: () => <div className="h-64 animate-pulse bg-neutral-100" />,
});
```

**Use dynamic imports for:** heavy client libraries (charts, editors), browser-only APIs (`window`, `document`), below-the-fold content most users never scroll to.

**Don't use them for:** small shared components, above-the-fold UI, layout components. Every dynamic import creates a separate network request — splitting ten small components into ten chunks is worse than one bundle.

## 5. Data fetching and caching

Slow data fetching is the quiet one that bites you. Your rendering strategy doesn't matter if you're waterfalling three sequential API calls before the page can render.

### 5.1 Parallel data fetching

The most common mistake: sequential `await`s when the calls don't depend on each other.

```ts title="Don't do this — sequential waterfall"
const user = await getUser();       // 200ms
const posts = await getPosts();     // 300ms
const comments = await getComments(); // 150ms
// Total: 650ms — each waits for the previous one
```

```ts title="Do this — parallel fetching"
const [user, posts, comments] = await Promise.all([ // [!code highlight]
  getUser(),       // 200ms ─┐
  getPosts(),      // 300ms ─┤ All start simultaneously
  getComments(),   // 150ms ─┘
]);
// Total: 300ms — limited by the slowest call
```

54% faster from one line. No library, no config — `Promise.all` and done.

### 5.2 Request deduplication with `cache()`

React's `cache()` deduplicates identical requests within a single render pass. If three components all call `getUser()`, it executes once.

```ts title="lib/data.ts"
import { cache } from "react";

export const getUser = cache(async (id: string) => { // [!code highlight]
  const res = await fetch(`/api/users/${id}`);
  return res.json();
});
```

### 5.3 The `"use cache"` directive

This is the big one that almost no blog covers yet. `"use cache"` is a declarative caching directive — first introduced experimentally in Next.js 15 and enabled via Cache Components in Next.js 16. It replaces the old `fetch()` cache options and `unstable_cache`.

First, enable it in your config:

```ts title="next.config.ts"
const nextConfig = {
  cacheComponents: true, // [!code highlight]
};
```

Then use it at the page level, layout level, or individual functions:

```tsx title="app/blog/page.tsx"
"use cache";

import { cacheLife } from "next/cache";

export default async function BlogPage() {
  cacheLife("hours"); // Cache this page's output for hours // [!code highlight]
  const posts = await getAllPosts();
  return <PostList posts={posts} />;
}
```

The built-in cache profiles:

| Profile | Stale | Revalidate | Expire |
|---|---|---|---|
| `"default"` | 5min | 15min | never |
| `"seconds"` | 30s | 1s | 1min |
| `"minutes"` | 5min | 1min | 1hr |
| `"hours"` | 5min | 1hr | 1 day |
| `"days"` | 5min | 1 day | 1 week |
| `"weeks"` | 5min | 1 week | 30 days |
| `"max"` | 5min | 30 days | 1 year |

If you don't call `cacheLife()` at all, the `default` profile is used. For on-demand revalidation, pair it with `cacheTag()` and call `revalidateTag()` from an API route or Server Action. This replaces the old route segment configs (`revalidate`, `dynamic`, `fetchCache`) — don't mix both models.

## 6. Streaming and Suspense

Traditional SSR waits for the slowest data source before sending anything. If your content loads in 100ms but comments take 2 seconds, the user stares at a blank screen for 2 seconds.

Streaming fixes this — the server sends fast parts immediately and streams slow parts as they resolve:

```tsx title="app/blog/[slug]/page.tsx"
import { Suspense } from "react";

export default async function BlogPost({ params }) {
  const { slug } = await params;
  const post = await getPost(slug); // Fast — 50ms

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>

      <Suspense fallback={<CommentsSkeleton />}> // [!code highlight]
        <Comments slug={slug} /> {/* Streams in when ready — 800ms */}
      </Suspense>
    </article>
  );
}
```

Place Suspense boundaries around non-critical data fetchers, below-the-fold sections, and personalized content. Don't wrap above-the-fold content — a loading flash there hurts perceived performance more than it helps.

For page-level streaming, you can also use a `loading.tsx` file — Next.js wraps the page in a Suspense boundary for you automatically:

```tsx title="app/dashboard/loading.tsx"
export default function Loading() {
  return <DashboardSkeleton />;
}
```

This is the simplest way to get streaming — one file, zero Suspense imports, instant loading states for entire route segments.

## 7. React Compiler

Here's a technique zero performance articles talk about (I checked): stop memoizing things manually.

React Compiler analyzes your components at build time and automatically inserts `useMemo`, `useCallback`, and `React.memo` where they'll actually help — not where you think they'll help, but where static analysis proves it.

First, install the Babel plugin:

```bash title="Terminal"
pnpm add -D babel-plugin-react-compiler
```

Then enable it in your config — note this is a **top-level** option, not inside `experimental`:

```ts title="next.config.ts"
const nextConfig = {
  reactCompiler: true, // [!code highlight]
};
```

In most cases, you can remove your manual `useMemo`/`useCallback`/`React.memo` calls — the compiler analyzes the actual dependency graph at build time instead of relying on you listing deps correctly in an array. If a specific component needs to opt out, use the `"use no memo"` directive. Fewer unnecessary re-renders means better INP.

## 8. next.config power flags

These are the flags I run in production that most developers don't know exist. (Free performance. No code changes. You're welcome.)

### 8.1 `inlineCss`

Inlines CSS directly into HTML instead of serving it as separate files. Eliminates render-blocking CSS requests.

```ts title="next.config.ts"
const nextConfig = {
  experimental: {
    inlineCss: true, // [!code highlight]
  },
};
```

One fewer network round-trip per page load. Only works in production builds. The tradeoff: inlined CSS can't be cached separately, so returning visitors re-download styles — best for first-visit-heavy sites like landing pages and blogs.

### 8.2 `staleTimes`

Controls how long the client-side router caches visited pages. By default, dynamic pages are cached for 0 seconds (re-fetched on every navigation) and static pages for 5 minutes.

```ts title="next.config.ts"
const nextConfig = {
  experimental: {
    staleTimes: {
      dynamic: 30,  // Cache dynamic pages for 30s on client // [!code highlight]
      static: 180,  // Cache static pages for 3 min on client
    },
  },
};
```

This means navigating back to a previously visited page is instant for 30 seconds instead of triggering a new server request. Big win for apps with frequent back-and-forth navigation.

### 8.3 `serverExternalPackages`

Some Node.js packages break when bundled for Server Components — native bindings, packages that use `__dirname`, or packages with side effects during import. This flag tells Next.js to skip bundling and use native `require()`.

```ts title="next.config.ts"
const nextConfig = {
  serverExternalPackages: ["puppeteer", "canvas"],
};
```

Many common packages (`sharp`, `bcrypt`, `prisma`, and others) are already excluded by default — you only need this for packages not on the [automatic opt-out list](https://nextjs.org/docs/app/api-reference/config/next-config-js/serverExternalPackages).

### 8.4 `removeConsole`

Strip `console.log` statements from production builds. Less noise, slightly smaller bundles.

```ts title="next.config.ts"
const nextConfig = {
  compiler: {
    removeConsole: {
      exclude: ["error"], // Keep console.error for debugging // [!code highlight]
    },
  },
};
```

## 9. Production checklist

Before you ship, run through this. I've ordered by impact — fix the high-priority items first.

### 9.1 High Priority

  - Page JS under 500KB (absolute max 1500KB) — Directly impacts FCP, LCP, and INP
  - priority on LCP image — Prevents lazy-loading the most important image
  - No sequential data waterfalls — Promise.all for independent fetches
  - use client only on leaf components — Every client component ships JS to the browser
  - Non-blocking third-party scripts (lazyOnload) — Keeps scripts out of the critical path
  - HTTPS enabled — Required for HTTP/2, required for Google ranking
  - TTFB under 1.3 seconds — Server response time caps everything downstream

### 9.2 Medium Priority

  - next/image for all images with width/height — Prevents CLS, enables format optimization
  - next/font with display: swap — Eliminates font-related CLS
  - CDN for images and static assets — Reduces TTFB by serving from edge
  - Blur placeholders on content images — Improves perceived loading speed
  - Suspense boundaries around slow data — Unblocks fast content from slow dependencies
  - Dependencies audited and up to date — Newer versions often ship smaller bundles
  - preconnect for third-party origins — Saves 100-500ms per external domain

### 9.3 Low Priority

  - WOFF2 font format — Smallest font file size available
  - inlineCss enabled in production — Eliminates one render-blocking request
  - removeConsole in production — Marginal bundle reduction, cleaner runtime
  - Vector images (SVG) over bitmaps where possible — Infinitely scalable, usually smaller
  - HTTP cache headers on static assets — Cache-Control: immutable for /_next/static/
  - staleTimes tuned for your navigation patterns — Instant back-navigation for visited pages

---

Here's the thing nobody tells you about performance work: hitting a 100 on Lighthouse is easy. Staying there is the actual job.

Every feature you ship, every dependency you add, every "just this one client component" — they're all small withdrawals from a budget your users never agreed to. The sites that stay fast aren't the ones that optimized once. They're the ones that made performance a constraint, not a cleanup task.

Add `@vercel/speed-insights` to your layout. Set a bundle budget in CI. Make the number visible to your team every week. When someone asks "can we add this 200KB carousel library?" — the dashboard answers for you.

The best Lighthouse score is the one you never have to fix twice.

## Frequently asked questions

### What are good Core Web Vitals scores for a Next.js site?

Aim for the good thresholds: LCP under 2.5s, FCP under 1.8s, INP under 200ms, and CLS under 0.1. INP replaced FID in March 2024. Measure on your production URL with PageSpeed Insights, since Google ranks on real-user CrUX data rather than localhost.

### How do I reduce my Next.js bundle size?

Run the built-in analyzer (npx next experimental-analyze on 16.1+) to see which dependencies dominate, then add barrel-heavy packages like icon and utility libraries to optimizePackageImports, swap moment for date-fns or the native Intl API, and keep use client on leaf components. Target under 500KB of JS per page, with 1500KB as the absolute ceiling.

### Why is my entire Next.js page shipping as client-side JavaScript?

A single use client at the top of a page or layout forces the whole subtree to ship as JS. In the App Router every component is a Server Component by default and ships zero JS, so push the use client boundary as deep as possible, around the button or form that needs interactivity rather than the page. Server Components passed as children to a Client Component still render on the server.

### What is the use cache directive in Next.js?

use cache is a declarative caching directive enabled with cacheComponents: true in Next.js 16 (experimental in 15). Add it to a page, layout, or function and set the duration with cacheLife(), using built-in profiles from seconds to max. It replaces fetch cache options, unstable_cache, and the old route-segment configs, so don't mix the two models.

### How do I improve LCP in a Next.js app?

Your LCP element is usually the largest above-the-fold image, so give it next/image with the priority prop, which disables lazy loading and preloads it. Never lazy-load above-the-fold images, add a blur placeholder for an instant preview, and self-host fonts with next/font and display swap to avoid render-blocking requests and layout shift.

### How do I fix slow data fetching in Next.js?

Replace sequential awaits with Promise.all when the requests don't depend on each other: three sequential calls totalling 650ms drop to the slowest one at 300ms, about 54% faster from one line. Wrap repeated requests in React cache() to dedupe them within a render, and stream slow, non-critical sections behind Suspense so fast content isn't blocked.
