Next.js Performance Optimization: 58 → 100 Lighthouse Score
The Challenge
When I first ran Lighthouse on my portfolio, mobile performance came back at 58/100. Next.js has good defaults but they do not compensate for unoptimized assets and blocking libraries.
The site had Three.js 3D animations, Framer Motion throughout, and a large profile image. The goal: get to 100/100 on desktop without removing any of it, and as close as possible on mobile.
Initial Metrics (Mobile)
- Performance: 58/100
- First Contentful Paint (FCP): 2.7s
- Largest Contentful Paint (LCP): 5.7s
- Total Blocking Time (TBT): 470ms
- Cumulative Layout Shift (CLS): 0
- Speed Index: 5.7s
1. The Broken Sitemap Crisis
The first issue I discovered wasn't even in the Lighthouse report - it was a fundamental SEO problem. My sitemap was using hash fragments for navigation.
The Problem
// ❌ WRONG - Hash fragments in sitemap
{
url: 'https://egekaya.dev/#about',
url: 'https://egekaya.dev/#tech-stack',
url: 'https://egekaya.dev/#experience',
// ...
}Search engines treat everything after the # as client-side navigation, not separate pages. This meant all my sitemap entries were resolving to the same URL. Ouch.
The Fix
// ✅ CORRECT - Only actual pages
{
url: 'https://egekaya.dev',
url: 'https://egekaya.dev/blog',
url: 'https://egekaya.dev/blog/optimizing-nextjs-performance',
url: 'https://egekaya.dev/case-studies/parma-internal-platform',
// Only real pages, no hash fragments
}Impact: Sitemap went from broken to valid. SEO score remained at 100/100.
2. Image Optimization: The Biggest Win
My profile image was a 1.3MB PNG file. On a mobile connection, this was killing the Largest Contentful Paint metric.
The Transformation
# Convert to WebP with high quality
cwebp -q 90 profile.png -o profile.webp
# Before: 1.3MB PNG
# After: 81KB WebP
# Savings: 94% reduction!The quality difference at 90% compression is imperceptible. The file size difference is not: 94% smaller.
Next.js Image Component Best Practices
// Optimized image with Next.js
<Image
src="/profile.webp"
alt="Ege Kaya - Computer Engineering Student"
fill
priority // Critical for LCP element
sizes="(max-width: 640px) 192px, (max-width: 1024px) 224px, 256px"
className="object-cover"
/>Key improvements:
- Use
priorityfor above-the-fold images to preload them - Define responsive
sizesto serve appropriate resolutions - Start with optimized source files (WebP/AVIF)
- Let Next.js handle automatic optimization
Impact: Reduced image transfer size by ~1.2MB. Improved LCP by ~2 seconds.
3. Deferring Three.js: The 600KB Solution
My hero section had a Three.js 3D background. The bundle was 865KB, loading immediately, blocking the main thread for 292ms.
Loading strategy
import dynamic from "next/dynamic"
const ThreeBackground = dynamic(
() => import("@/components/three-background")
.then((mod) => ({ default: mod.ThreeBackground })),
{ ssr: false, loading: () => null }
)
export function Hero() {
const [showThreeBackground, setShowThreeBackground] = useState(false)
useEffect(() => {
// Load Three.js after initial render
const timer = setTimeout(() => {
setShowThreeBackground(true)
}, 100)
return () => clearTimeout(timer)
}, [])
return (
<section>
{/* Gradient placeholders show immediately */}
<div className="absolute inset-0 -z-10">
<div className="bg-primary/10 rounded-full blur-3xl" />
</div>
{/* Three.js loads after 100ms */}
{showThreeBackground && <ThreeBackground />}
</section>
)
}Why This Works
- Content paints immediately with CSS gradients
- Three.js loads asynchronously after 100ms
- Users see a smooth transition, not a blank screen
- Initial bundle reduced by ~600KB
Impact: Reduced initial JS bundle from 865KB to ~250KB (71% reduction). Improved TBT by ~150ms.
4. Code Splitting with Dynamic Imports
Not all sections need to load immediately. I implemented strategic code splitting for below-the-fold content:
import dynamic from "next/dynamic"
// Lazy load sections that aren't immediately visible
const Projects = dynamic(
() => import("@/components/sections/projects").then(mod => ({
default: mod.Projects
})),
{
loading: () => <div className="h-96 animate-pulse bg-secondary/20" />
}
)
export default function Home() {
return (
<>
<Hero /> {/* Loaded immediately */}
<About /> {/* Loaded immediately */}
<Projects /> {/* Lazy loaded */}
</>
)
}Code Splitting Retro Mode CSS
My site has a Konami code easter egg that activates “retro mode” - a green terminal aesthetic with CRT effects. The CSS for this was always loaded, even though 99.9% of users never see it.
// konami-wrapper.tsx
const activateRetroMode = useCallback(() => {
if (!retroCssLoadedRef.current) {
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = '/retro-mode.css'
document.head.appendChild(link)
retroCssLoadedRef.current = true
}
document.documentElement.classList.add("retro-mode")
}, [])Impact: Reduced initial CSS by ~35%. Easter egg still works perfectly.
5. Optimizing Framer Motion Initialization
Framer Motion's animation calculations during initial render add to element render delay. Deferring initialization until after first paint fixes this with no visible difference.
The pattern
export function Hero() {
const [enableAnimations, setEnableAnimations] = useState(false)
useEffect(() => {
// Enable animations after initial render
setEnableAnimations(true)
}, [])
return (
<motion.div
// Skip animation on initial render
initial={enableAnimations ? { opacity: 0, y: 20 } : false}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
Content
</motion.div>
)
}Why This Matters
When initial is false, Framer Motion skips the initial animation calculation. Content appears immediately, then animations enable on the next tick. Users don't notice the difference, but Lighthouse does.
Impact: Reduced element render delay from 3,270ms to 1,300ms (60% improvement).
6. Font Optimization
Using Next.js font optimization with Google Fonts prevents layout shifts and improves load times:
import { Geist, Geist_Mono } from "next/font/google"
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
display: "swap", // Prevent invisible text
})
// Apply in layout
<body className={`${geistSans.variable}`}>Impact: Eliminated Cumulative Layout Shift (CLS) from font loading.
The Results: Before vs After
Mobile Performance
| Metric | Before | After | Improvement |
|---|---|---|---|
| Performance | 58 | 87 | +29 points |
| FCP | 2.7s | 1.0s | -1.7s (63% faster) |
| LCP | 5.7s | 2.4s | -3.3s (58% faster) |
| TBT | 470ms | 400ms | -70ms |
| Speed Index | 5.7s | 2.9s | -2.8s (49% faster) |
Desktop Performance
100/100 Perfect Score!
- FCP: 0.3s
- LCP: 0.5s
- TBT: 50ms
- CLS: 0.001
Beyond Performance: Accessibility & Security
Accessibility Fix: Descriptive Links
Lighthouse flagged that all my “Case Study” links had identical text, making them indistinguishable for screen reader users.
// Before: All links say "Case Study"
<a href="/case-studies/parma-internal-platform">
Case Study
</a>
// After: Descriptive aria-label
<a
href="/case-studies/parma-internal-platform"
aria-label="View case study for Parma Internal Platform"
>
Case Study
</a>Impact: Accessibility 96 → 100/100
Security Headers
While not scored by Lighthouse, security headers are critical for production sites. I added them to next.config.ts:
export default {
async headers() {
return [{
source: '/:path*',
headers: [
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload'
},
{
key: 'X-Frame-Options',
value: 'SAMEORIGIN'
},
{
key: 'X-Content-Type-Options',
value: 'nosniff'
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin'
},
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=()'
}
],
}]
}
}Final Lighthouse Scores
Mobile
- Performance: 87/100 ⚡
- Accessibility: 100/100 ✅
- Best Practices: 100/100 ✅
- SEO: 100/100 ✅
Desktop
- Performance: 100/100 🏆
- Accessibility: 100/100 ✅
- Best Practices: 100/100 ✅
- SEO: 100/100 ✅
Lessons Learned
1. Start with images
A 1.3MB PNG as the LCP element is an easy fix. Convert to WebP at 90% quality: the size drops by ~94%, the visual difference is invisible. The priority attribute on the Next.js Image component tells the browser to preload it.
2. Defer heavy libraries
Three.js and Framer Motion are large. Load them after initial render with dynamic() and a short timeout. Users do not notice a 100ms delay; the browser does not block on it.
3. Code split infrequently-used code
The retro mode CSS and Konami easter egg are active for a tiny fraction of visits. Loading them conditionally takes one document.createElement('link') call and saves the initial payload.
4. Mark the LCP element
Use priority on above-the-fold images and lazy load everything below the fold with Next.js dynamic imports.
5. Descriptive aria-labels fix both accessibility and semantics
Multiple "Case Study" links on one page are indistinguishable to screen readers. Adding aria-label="View case study for X" moves Accessibility from 96 to 100 and makes the markup cleaner.
6. 100/100 mobile is a tradeoff, not a target
87/100 on mobile with Three.js and Framer Motion still present is a better outcome than 100/100 without them. The score matters less than the actual Core Web Vitals numbers.
7. Desktop headroom is generous
The same code that scores 87 on mobile scores 100 on desktop. Fast CPUs and stable connections absorb a lot. Do not over-optimize for desktop; focus on mobile LCP and TBT.
What Didn't Work
Removing Google Tag Manager
GTM adds 120KB and 54ms of main thread blocking. Removing it would boost the score, but analytics are valuable. The tradeoff isn't worth it.
Removing Framer Motion Entirely
I could replace all animations with CSS, but Framer Motion enables complex, choreographed animations that would be painful to recreate. The 100KB library cost is worth the developer experience.
The Realistic Target for Rich Sites
If you're building a portfolio or marketing site with:
- 3D graphics (Three.js, React Three Fiber)
- Rich animations (Framer Motion, GSAP)
- Analytics (Google Analytics, GTM)
- Interactive features
Then 85-90 on mobile and 95-100 on desktop is an excellent target. Going higher means removing features that make your site special.
Key Takeaways
- Measure real metrics - Focus on Core Web Vitals (LCP, FID, CLS)
- Lazy load aggressively - Only load what users need when they need it
- Test on real devices - Performance varies significantly on mobile vs desktop
- Use Next.js built-ins - Image, Font, and Script components handle optimization automatically
- Balance performance with features - Don't sacrifice UX for a perfect score
Resources
Working on a performance problem? Reach out.