How to Optimise 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.

M
Mohammad Najib Bahrudin /
23 min read
CategoryWeb Development
AuthorMohammad Najib Bahrudin
PublishedApr 14, 2026
min read23 min read
Tags4 topics
Next.jsPerformanceReactWeb Vitals

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 optimisation 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 optimising 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 Analyse first, cut second

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

Terminal

Bundle analyzer treemap showing package sizes

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:

TypeScript

Many popular libraries (lodash-es, date-fns, @mui/material, and others) are already optimised 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.

React TSX

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 optimising FCP/LCP

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

MetricWhat it measuresGoodNeeds workPoor
FCP (First Contentful Paint)Time until first text/image appears< 1.8s1.8 - 3.0s> 3.0s
LCP (Largest Contentful Paint)Time until the largest visible element renders< 2.5s2.5 - 4.0s> 4.0s
INP (Interaction to Next Paint)Delay between user interaction and visual response< 200ms200 - 500ms> 500ms
CLS (Cumulative Layout Shift)How much the page layout shifts unexpectedly< 0.10.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 optimise

Run PageSpeed Insights 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

For real-user data, check the Chrome User Experience Report (CrUX) — this is what Google uses for search rankings. For continuous monitoring, add @vercel/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.

React TSX

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.

React TSX

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:

React TSX
StrategyWhen it loadsUse for
beforeInteractiveBefore hydrationCritical A/B testing, bot detection
afterInteractiveAfter some hydration (default)Analytics, tag managers
lazyOnloadAfter page is idleChat widgets, social embeds, cookie banners

For Google services, use @next/third-parties — it loads GA, Maps, and YouTube embeds with optimised defaults out of the box.

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

React TSX

3. Rendering strategies

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

StrategyHow it worksTTFBUse when
SSGHTML generated at build time, served from CDNFastestLanding pages, docs, blogs — content rarely changes
ISRStatic + revalidates at a fixed intervalFastProduct listings, content that changes every few minutes/hours
SSRHTML generated per requestDepends on backendSEO-critical pages with real-time or personalised data
CSRRenders entirely in browserN/ADashboards, internal tools — SEO doesn't matter
mermaid

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:

React TSX

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 awaits when the calls don't depend on each other.

TypeScript
TypeScript

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.

TypeScript

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:

TypeScript

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

React TSX

The built-in cache profiles:

ProfileStaleRevalidateExpire
"default"5min15minnever
"seconds"30s1s1min
"minutes"5min1min1hr
"hours"5min1hr1 day
"days"5min1 day1 week
"weeks"5min1 week30 days
"max"5min30 days1 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:

React TSX

Place Suspense boundaries around non-critical data fetchers, below-the-fold sections, and personalised 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:

React TSX

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 memoising things manually.

React Compiler analyses 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:

Terminal

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

TypeScript

In most cases, you can remove your manual useMemo/useCallback/React.memo calls — the compiler analyses 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.

TypeScript

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.

TypeScript

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().

TypeScript

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.

8.4 removeConsole

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

TypeScript

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 optimisation
  • 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 optimised 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.