Next.js Performance Optimization: 58 → 100 Lighthouse Score
The Challenge
When I first ran Lighthouse on my portfolio, the results were humbling. Despite using Next.js with its built-in optimizations, my mobile performance score was 58/100. The desktop score wasn't tested yet, but I knew there was work to be done.
My site had Three.js 3D animations, Framer Motion throughout, and rich interactive content - all the things that make a portfolio stand out but also drag down performance. The goal was clear: achieve a 100/100 Lighthouse score without sacrificing the visual flair that makes the site unique.
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 visual quality difference? Imperceptible. The performance difference? Massive.
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 beautiful Three.js 3D background with floating geometric shapes. The problem? The Three.js bundle was 865KB and loading immediately, blocking the main thread for 292ms.
Smart 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 is amazing for animations, but running complex animations during initial render adds to the element render delay. The solution? Defer animation initialization.
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. Image Optimization is Non-Negotiable
A single 1.3MB image can destroy your LCP. Always use modern formats (WebP, AVIF) and compress aggressively. The quality difference at 85-90% is imperceptible to users.
2. Defer Heavy Libraries
Three.js, Framer Motion, and other animation libraries are amazing but expensive. Load them after initial render. Users won't notice a 100ms delay, but Lighthouse will notice the faster initial load.
3. Code Split Aggressively
That easter egg CSS? Those retro mode styles? That admin panel code? If fewer than 50% of users see it, code split it.
4. Prioritize Above-the-Fold Content
Use priority loading for critical resources. Lazy load everything else with Next.js dynamic imports.
5. Accessibility = Better UX for Everyone
Adding descriptive aria-label attributes doesn't just help screen reader users - it makes your site more semantic and maintainable.
6. 100/100 Mobile is Hard (and Maybe Unnecessary)
Getting to 87/100 on mobile while keeping Three.js and Framer Motion is a win. To hit 100/100, I'd need to remove visual features that make the site unique. Sometimes 87 is the right tradeoff.
7. Desktop is Easier Than You Think
With the same optimizations, desktop hit 100/100 easily. Modern desktops have fast CPUs and good connections - the same code that struggles on mobile flies on desktop.
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
Have questions about performance optimization? Feel free to reach out!