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:
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',
})
If using custom fonts:
@font-face {
font-family: 'Inter';
src: url('/fonts/inter.woff2') format('woff2');
font-display: swap;
}
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 typographyComponent styles -> CSS Modules
Heavy UI blocks -> dynamically imported
Example:
import dynamic from 'next/dynamic'
const HeavyComponent = dynamic(() => import('./HeavyComponent'), {
ssr: false,
})
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"
/>
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}
/>
Important attributes:
priority-> enables preloadfetchPriority="high"-> increases network prioritywidth / height-> prevents CLSsizes -> 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>
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,
}))
}
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;
}
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)