Next.js Performance Optimization for Indie Developers
Master Next.js performance optimization techniques. Learn how to achieve perfect Core Web Vitals scores, optimize images, reduce bundle size, and deliver...
Next.js Performance Optimization for Indie Developers#
Performance isn't just about speed—it's about user experience, SEO rankings, and conversion rates. A 1-second delay in page load time can reduce conversions by 7%. This comprehensive guide teaches you how to optimize Next.js applications for maximum performance.
Why Performance Matters#
User Experience:
- 53% of mobile users abandon sites that take longer than 3 seconds to load
- Fast sites feel more professional and trustworthy
- Better performance = better user retention
SEO Impact:
- Core Web Vitals are ranking factors
- Faster sites get crawled more frequently
- Better performance = higher search rankings
Business Metrics:
- Amazon found every 100ms of latency cost them 1% in sales
- Google found 500ms delay reduced traffic by 20%
- Performance directly impacts revenue
1. Understanding Core Web Vitals#
The Three Key Metrics#
Largest Contentful Paint (LCP)
- Measures loading performance
- Target: < 2.5 seconds
- Largest visible element in viewport
First Input Delay (FID) / Interaction to Next Paint (INP)
- Measures interactivity
- Target: < 100ms (FID) or < 200ms (INP)
- Time from user interaction to response
Cumulative Layout Shift (CLS)
- Measures visual stability
- Target: < 0.1
- Unexpected layout shifts
Measuring Performance#
// lib/web-vitals.ts
import { onCLS, onFID, onLCP, onINP } from 'web-vitals'
export function reportWebVitals() {
onCLS(console.log)
onFID(console.log)
onLCP(console.log)
onINP(console.log)
}
// app/layout.tsx
'use client'
import { useEffect } from 'react'
import { reportWebVitals } from '@/lib/web-vitals'
export default function RootLayout({ children }) {
useEffect(() => {
reportWebVitals()
}, [])
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
2. Image Optimization#
Next.js Image Component#
import Image from 'next/image'
export function OptimizedImage() {
return (
<Image
src="/hero.jpg"
alt="Hero image"
width={1200}
height={600}
priority // Load immediately for above-fold images
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRg..." // Low-quality placeholder
/>
)
}
Responsive Images#
<Image
src="/hero.jpg"
alt="Hero image"
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
style={{ objectFit: 'cover' }}
/>
Image Formats#
// next.config.mjs
export default {
images: {
formats: ['image/avif', 'image/webp'],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
},
}
External Image Optimization#
// next.config.mjs
export default {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'your-cdn.com',
port: '',
pathname: '/images/**',
},
],
},
}
Related: Next.js Image Component Optimization Complete Guide, Implement Image Compression Before Supabase Upload
3. Code Splitting and Bundling#
Dynamic Imports#
// Lazy load heavy components
import dynamic from 'next/dynamic'
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
loading: () => <p>Loading chart...</p>,
ssr: false, // Disable server-side rendering if not needed
})
export function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<HeavyChart />
</div>
)
}
Route-Based Code Splitting#
Next.js automatically code-splits by route:
app/
dashboard/
page.tsx # Only loaded when visiting /dashboard
settings/
page.tsx # Only loaded when visiting /settings
Component-Level Code Splitting#
'use client'
import { lazy, Suspense } from 'react'
const VideoPlayer = lazy(() => import('@/components/VideoPlayer'))
export function VideoSection() {
return (
<Suspense fallback={<div>Loading video...</div>}>
<VideoPlayer src="/video.mp4" />
</Suspense>
)
}
Bundle Analysis#
## Install bundle analyzer
npm install @next/bundle-analyzer
## Analyze bundle
ANALYZE=true npm run build
// next.config.mjs
import bundleAnalyzer from '@next/bundle-analyzer'
const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
})
export default withBundleAnalyzer({
// Your Next.js config
})
Related: Optimize Next.js Bundle Size Under 100KB Guide, Next.js 15 Server Components Performance Best Practices
4. Server-Side Rendering Optimization#
Server Components (Default)#
// app/posts/page.tsx
// This is a Server Component by default
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
cache: 'force-cache', // Cache indefinitely
})
return res.json()
}
export default async function PostsPage() {
const posts = await getPosts()
return (
<div>
{posts.map(post => (
<article key={post.id}>{post.title}</article>
))}
</div>
)
}
Client Components (When Needed)#
'use client'
import { useState } from 'react'
export function InteractiveButton() {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount(count + 1)}>
Clicked {count} times
</button>
)
}
Streaming with Suspense#
import { Suspense } from 'react'
async function SlowComponent() {
await new Promise(resolve => setTimeout(resolve, 3000))
return <div>Slow content loaded!</div>
}
export default function Page() {
return (
<div>
<h1>Fast content</h1>
<Suspense fallback={<div>Loading slow content...</div>}>
<SlowComponent />
</Suspense>
</div>
)
}
Parallel Data Fetching#
// ❌ Sequential (slow)
async function SequentialPage() {
const user = await fetchUser()
const posts = await fetchPosts()
return <div>{/* ... */}</div>
}
// ✅ Parallel (fast)
async function ParallelPage() {
const [user, posts] = await Promise.all([
fetchUser(),
fetchPosts(),
])
return <div>{/* ... */}</div>
}
Related: Next.js 15 Server Components Performance Best Practices, Fix Next.js Slow Page Load Times Step by Step
5. Static Generation and ISR#
Static Site Generation (SSG)#
// app/posts/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await fetch('https://api.example.com/posts').then(res => res.json())
return posts.map((post) => ({
slug: post.slug,
}))
}
export default async function Post({ params }) {
const post = await fetch(`https://api.example.com/posts/${params.slug}`)
.then(res => res.json())
return <article>{post.content}</article>
}
Incremental Static Regeneration (ISR)#
// Revalidate every 60 seconds
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 60 }
})
return res.json()
}
export default async function PostsPage() {
const posts = await getPosts()
return <div>{/* ... */}</div>
}
On-Demand Revalidation#
// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache'
import { NextRequest } from 'next/server'
export async function POST(request: NextRequest) {
const path = request.nextUrl.searchParams.get('path')
if (path) {
revalidatePath(path)
return Response.json({ revalidated: true, now: Date.now() })
}
return Response.json({ revalidated: false, now: Date.now() })
}
Related: Implement Next.js Incremental Static Regeneration ISR, Next.js Edge Runtime vs Node Runtime When to Use
6. Caching Strategies#
Fetch Caching#
// Cache indefinitely
fetch('https://api.example.com/data', {
cache: 'force-cache'
})
// Never cache
fetch('https://api.example.com/data', {
cache: 'no-store'
})
// Revalidate after 60 seconds
fetch('https://api.example.com/data', {
next: { revalidate: 60 }
})
React Cache#
import { cache } from 'react'
export const getUser = cache(async (id: string) => {
const user = await db.user.findUnique({ where: { id } })
return user
})
// Called multiple times but only executes once per request
const user1 = await getUser('123')
const user2 = await getUser('123') // Uses cached result
Memoization#
import { unstable_cache } from 'next/cache'
const getCachedPosts = unstable_cache(
async () => {
return await db.post.findMany()
},
['posts'],
{
revalidate: 3600, // 1 hour
tags: ['posts'],
}
)
7. Database Query Optimization#
Efficient Queries#
// ❌ N+1 query problem
const posts = await db.post.findMany()
for (const post of posts) {
const author = await db.user.findUnique({ where: { id: post.authorId } })
}
// ✅ Single query with join
const posts = await db.post.findMany({
include: {
author: true,
},
})
Pagination#
// Cursor-based pagination (efficient)
const posts = await db.post.findMany({
take: 10,
skip: 1,
cursor: {
id: lastPostId,
},
orderBy: {
createdAt: 'desc',
},
})
Indexing#
-- Add indexes for frequently queried columns
CREATE INDEX idx_posts_author_id ON posts(author_id);
CREATE INDEX idx_posts_created_at ON posts(created_at DESC);
CREATE INDEX idx_posts_slug ON posts(slug);
Related: Supabase Database Query Optimization, Supabase Database Indexing Strategies
8. Font Optimization#
Next.js Font Optimization#
import { Inter, Roboto_Mono } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
})
const robotoMono = Roboto_Mono({
subsets: ['latin'],
display: 'swap',
variable: '--font-roboto-mono',
})
export default function RootLayout({ children }) {
return (
<html lang="en" className={`${inter.variable} ${robotoMono.variable}`}>
<body>{children}</body>
</html>
)
}
Custom Fonts#
import localFont from 'next/font/local'
const myFont = localFont({
src: './my-font.woff2',
display: 'swap',
variable: '--font-my-font',
})
9. Reducing First Contentful Paint (FCP)#
Critical CSS#
// app/layout.tsx
export default function RootLayout({ children }) {
return (
<html>
<head>
<style dangerouslySetInnerHTML={{
__html: `
/* Critical CSS for above-the-fold content */
body { margin: 0; font-family: system-ui; }
.hero { min-height: 100vh; }
`
}} />
</head>
<body>{children}</body>
</html>
)
}
Preload Critical Resources#
export default function RootLayout({ children }) {
return (
<html>
<head>
<link
rel="preload"
href="/fonts/inter.woff2"
as="font"
type="font/woff2"
crossOrigin="anonymous"
/>
</head>
<body>{children}</body>
</html>
)
}
Remove Render-Blocking Resources#
// next.config.mjs
export default {
compiler: {
removeConsole: process.env.NODE_ENV === 'production',
},
}
Related: Reduce Next.js First Contentful Paint FCP, Next.js Bundle Size Optimization
10. Monitoring and Measuring Performance#
Real User Monitoring (RUM)#
// app/layout.tsx
'use client'
import { useReportWebVitals } from 'next/web-vitals'
export function WebVitals() {
useReportWebVitals((metric) => {
// Send to analytics
fetch('/api/analytics', {
method: 'POST',
body: JSON.stringify(metric),
})
})
return null
}
Performance API#
if (typeof window !== 'undefined') {
const perfData = window.performance.getEntriesByType('navigation')[0]
console.log('DNS lookup:', perfData.domainLookupEnd - perfData.domainLookupStart)
console.log('TCP connection:', perfData.connectEnd - perfData.connectStart)
console.log('Request time:', perfData.responseStart - perfData.requestStart)
console.log('Response time:', perfData.responseEnd - perfData.responseStart)
console.log('DOM processing:', perfData.domComplete - perfData.domLoading)
}
Lighthouse CI#
## .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [push]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- run: npm ci
- run: npm run build
- run: npm install -g @lhci/cli
- run: lhci autorun
Related: Monitor Next.js Application Performance in Production, Next.js Performance Optimization Complete Guide
11. Edge Runtime Optimization#
Edge Functions#
// app/api/edge/route.ts
export const runtime = 'edge'
export async function GET(request: Request) {
return new Response('Hello from the edge!', {
headers: {
'content-type': 'text/plain',
},
})
}
Edge Middleware#
// middleware.ts
export const config = {
matcher: '/api/:path*',
}
export function middleware(request: Request) {
const country = request.geo?.country || 'US'
return new Response(JSON.stringify({ country }), {
headers: {
'content-type': 'application/json',
},
})
}
Related: Next.js Edge Runtime vs Node Runtime When to Use, Deploy Next.js Supabase App to Vercel Production
12. Common Performance Pitfalls#
Avoid Client-Side Data Fetching#
// ❌ Bad: Client-side fetching
'use client'
import { useEffect, useState } from 'react'
export function Posts() {
const [posts, setPosts] = useState([])
useEffect(() => {
fetch('/api/posts')
.then(res => res.json())
.then(setPosts)
}, [])
return <div>{/* ... */}</div>
}
// ✅ Good: Server-side fetching
async function getPosts() {
const res = await fetch('https://api.example.com/posts')
return res.json()
}
export default async function Posts() {
const posts = await getPosts()
return <div>{/* ... */}</div>
}
Avoid Large Client Bundles#
// ❌ Bad: Import entire library
import _ from 'lodash'
// ✅ Good: Import only what you need
import debounce from 'lodash/debounce'
Avoid Layout Shifts#
// ❌ Bad: No dimensions
<img src="/image.jpg" alt="Image" />
// ✅ Good: Explicit dimensions
<Image
src="/image.jpg"
alt="Image"
width={800}
height={600}
/>
Related Articles#
- Complete Guide to Building SaaS with Next.js and Supabase
- Deploying Next.js + Supabase to Production
- Next.js 15 Server Components Performance Best Practices
- Optimize Next.js Bundle Size Under 100KB Guide
Conclusion#
Performance optimization is an ongoing process. Start with the basics—optimize images, reduce bundle size, and leverage server components. Then move to advanced techniques like ISR, edge functions, and fine-tuned caching strategies.
Remember: measure first, optimize second. Use tools like Lighthouse and Web Vitals to identify bottlenecks, then apply targeted optimizations.
Fast sites win. Start optimizing today.