DEV Community

Den Smirnoff
Den Smirnoff

Posted on

Optimizing Next.js Performance: A Practical Case Study (96+ Lighthouse Mobile)

Performance in Next.js is rarely about one “magic” trick.
It’s usually the result of dozens of small architectural decisions made consistently.

In this article, I’ll walk through the exact techniques that helped me achieve 96+ Lighthouse performance on mobile on a real production project built with Next.js (App Router).

Real Production Example

Instead of discussing performance in theory, let’s look at a real production case.

Project: playtrust.net
Type: Content-driven website
Stack: Next.js, ASP.NET Core, Nginx
Lighthouse (Mobile) result:

Lighthouse mobile performance

This isn’t a minimal landing page. The page includes:

  • A hero (LCP) image

  • Dynamic content

  • Analytics scripts

  • Client components

  • Server rendering

  • External image sources

Primary optimization focus:

  • LCP image optimization

  • Client bundle minimization

  • Aggressive image caching

  • Hydration control
    Let’s break down the exact technical decisions that led to this result.

1. Use font-display: swap

Custom fonts often block rendering (FOIT — Flash of Invisible Text).
If the browser waits for fonts before rendering text, you increase:

  • First Contentful Paint (FCP)

  • Total Blocking Time

  • LCP

If using next/font :

import { Inter } from 'next/font/google'

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
})
Enter fullscreen mode Exit fullscreen mode

If using custom fonts:

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter.woff2') format('woff2');
  font-display: swap;
}
Enter fullscreen mode Exit fullscreen mode

This allows the browser to render text immediately using a fallback font, then swap once the custom font loads.

2. Minimize Global CSS

Large global CSS files are render-blocking resources.

Strategy used in production:

  • global.css -> only resets, variables, base typography

  • Component styles -> CSS Modules

  • Heavy UI blocks -> dynamically imported

Example:

import dynamic from 'next/dynamic'

const HeavyComponent = dynamic(() => import('./HeavyComponent'), {
  ssr: false,
})
Enter fullscreen mode Exit fullscreen mode

This reduces initial CSS payload and improves render timing.

3. Load Analytics Properly

Third-party scripts are one of the most common performance killers.
Always use next/script:

import Script from 'next/script'

<Script
  src="https://www.googletagmanager.com/gtag/js?id=XXXX"
  strategy="afterInteractive"
/>
Enter fullscreen mode Exit fullscreen mode

Available strategies:

  • beforeInteractive (rarely needed)

  • afterInteractive (recommended default)

  • lazyOnload (best for low-priority scripts)
    Never inject analytics directly in <head> without strategy control.

4. Optimize Your LCP Image

On most content-heavy pages, LCP defines your Lighthouse score.
On the reference page, LCP is the hero image.

4.1 Use WebP (or AVIF)
All hero images are converted before deployment.

Benefits:

  • Smaller file size

  • Faster transfer

  • Better LCP stability

4.2 Proper <Image /> Configuration

<Image
  src="/images/hero.webp"
  alt="Duck Hunters"
  priority
  fetchPriority="high"
  width={360}
  height={216}
  sizes="(max-width: 768px) 216px, 360px"
  quality={85}
/>
Enter fullscreen mode Exit fullscreen mode

Important attributes:

  • priority -> enables preload

  • fetchPriority="high" -> increases network priority

  • width / height -> prevents CLS

  • sizes -> correct responsive behavior

  • quality -> avoid 100 unless necessary

4.3 Set Container Dimensions in Advance

To fully eliminate layout shifts:

<div style={{ width: '360px', height: '216px' }}>
  <Image ... />
</div>
Enter fullscreen mode Exit fullscreen mode

Predefining layout space ensures stable rendering and CLS = 0.

5. Pre-Download External Images at Build Time

Originally, images were fetched from an external storage (S3-like service). That introduced:

  • Extra DNS lookup

  • TLS handshake

  • Potential cold start delays

  • Unstable TTFB

Solution: download images during build and store them in /public.
Example:

export async function generateStaticParams() {
  ...{fetch}...
  const articles = res.data ? res.data.items : []

  if (!fs.existsSync(IMG_DIR)) {
    fs.mkdirSync(IMG_DIR, { recursive: true })
  }

  for (const article of articles) {
    const urls = [
      article.coverImg ? `${BASE_URL + IMG_URL}${article.coverImg}` : null,
    ].filter(Boolean)

    for (const url of urls) {
      const filename = url!.split('/').pop()!
      const path = `${IMG_DIR}/${filename}`

      if (!fs.existsSync(path)) {
        const resp = await fetch(url!)
        const buffer = await resp.arrayBuffer()
        fs.writeFileSync(path, Buffer.from(buffer))
      }
    }
  }

  return articles.map((x) => ({
    slug: x.slug,
  }))
}
Enter fullscreen mode Exit fullscreen mode

Result: stable LCP without relying on external image latency.

6. Configure Image Caching (Nginx Example)

If you’re not using Cloudflare, configure caching at the server level.
Example:

location /images/ {
    proxy_pass http://127.0.0.1:3000;
    proxy_cache images_cache;
    proxy_cache_valid 200 365d;
    add_header X-Cache $upstream_cache_status;
}
Enter fullscreen mode Exit fullscreen mode

This:

  • Reduces server load

  • Lowers TTFB

  • Speeds up repeat visits

7. Be Careful with "use client"

In the App Router, everything is a Server Component by default.
Every "use client":

  • Ships JavaScript to the browser

  • Increases bundle size

  • Adds hydration cost

  • Can delay above-the-fold rendering

Rule applied:

  • Hero section -> Server Component

  • Above-the-fold -> minimal client logic

  • Interactive parts -> below the fold

8. Monitor TTFB

If your TTFB is high:

  • Check server performance

  • Avoid heavy SSR computations

  • Use caching aggressively

  • Optimize network chain

  • Enable HTTP/2 or HTTP/3

Final Thoughts

High performance in Next.js is not about hacks.
It’s about:

  • Reducing render-blocking resources

  • Controlling client bundle size

  • Treating LCP as a first-class citizen

  • Proper caching strategy

  • Clear separation of Server vs Client Components

Performance is an architectural decision — not a final polish step.

Top comments (0)