Next.js Performance Optimization for Indie Developers
Ship a fast Next.js app: Core Web Vitals, image and font optimization, bundle trimming, caching, and the RUM checks that catch regressions.
Photo by Adhitya Sibikumar on Unsplash
Optimizing a Next.js app in 2026 comes down to five levers, in order of impact: render as much as possible on the server (Server Components are the default for a reason), get images and fonts through next/image and next/font, cache deliberately — because since Next.js 15 fetch requests are no longer cached by default — split heavy client code with dynamic(), and measure the three Core Web Vitals (LCP ≤ 2.5 s, INP ≤ 200 ms, CLS ≤ 0.1) with real-user data rather than one-off Lighthouse runs.
That caching sentence is the one that trips up most migrated codebases: guides written for Next.js 14 assume fetch is cached unless you opt out, and Next.js 15 flipped the default the other way. This guide covers each lever with the current (Next 15/16) semantics, and flags where Next 14 behaves differently.
Why Performance Matters#
User Experience:
- Fast sites feel more professional and trustworthy
- Better performance = better user retention
SEO Impact:
- Core Web Vitals are part of Google's page experience signals
- A fast, stable page loses fewer visitors before the content renders
1. Understanding Core Web Vitals#
The Three Key Metrics#
Largest Contentful Paint (LCP)
- Measures loading performance
- Target: ≤ 2.5 seconds (at the 75th percentile of page loads)
- Largest visible element in viewport
Interaction to Next Paint (INP)
- Measures responsiveness — INP is the successor metric to First Input Delay (FID), which is retired
- Per web.dev: ≤ 200 ms is good, 200–500 ms needs improvement, above 500 ms is poor
- Unlike FID (input delay of the first interaction only), INP observes the full duration of all interactions on the page
Cumulative Layout Shift (CLS)
- Measures visual stability
- Target: ≤ 0.1
- Unexpected layout shifts
Measuring Performance#
Use the hook Next.js ships for this, useReportWebVitals, in a small client component — don't turn your root layout into a Client Component just to measure vitals:
// app/_components/web-vitals.tsx
'use client'
import { useReportWebVitals } from 'next/web-vitals'
export function WebVitals() {
useReportWebVitals((metric) => {
console.log(metric) // { name: 'LCP' | 'INP' | 'CLS' | 'FCP' | 'TTFB', value, rating, ... }
})
return null
}// app/layout.tsx (stays a Server Component)
import { WebVitals } from './_components/web-vitals'
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<WebVitals />
{children}
</body>
</html>
)
}Note on the web-vitals npm package: onFID was deprecated and has been removed in current major versions, so old snippets importing it no longer build. Measure onINP instead.
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 /settingsComponent-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', // explicit opt-in — since Next.js 15 fetch is NOT cached by default
})
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#
Next.js 14 vs 15/16: The Defaults Flipped#
If you learned App Router caching on Next.js 14, unlearn three defaults. The Next.js 15 release notes list all three as breaking changes:
| Behaviour | Next.js 14 | Next.js 15/16 |
|---|---|---|
fetch in Server Components | Cached by default | Not cached by default — opt in with cache: 'force-cache' |
GET Route Handlers | Cached by default (unless dynamic) | Not cached by default — opt in with export const dynamic = 'force-static' |
| Client Router Cache (Page segments) | Reused for 30 s | staleTime: 0 — every navigation reflects fresh page data |
Metadata routes (sitemap.ts, opengraph-image.tsx, icons) stay static by default, and shared layouts are still not refetched on navigation. If you actually want the Next.js 14 router-cache behaviour back, it's experimental.staleTimes: { dynamic: 30 } in next.config.
The practical consequence: on Next.js 15+, a page whose data never changes is not automatically fast — an unconfigured fetch runs on every request. Slow TTFB after an upgrade is usually this, not a regression in your code. Next.js 16 additionally introduces the opt-in Cache Components model (cacheComponents flag with the use cache directive); everything below describes the standard model without that flag.
Fetch Caching#
// Next.js 15+: not cached unless you say so
fetch('https://api.example.com/data', {
cache: 'force-cache' // cache indefinitely (until revalidated)
})
// Explicitly never cache (also the 15+ default)
fetch('https://api.example.com/data', {
cache: 'no-store'
})
// Cache, revalidate after 60 seconds
fetch('https://api.example.com/data', {
next: { revalidate: 60 }
})
// Cache with tags for on-demand invalidation via revalidateTag()
fetch('https://api.example.com/data', {
next: { tags: ['posts'] }
})Two rules worth memorising from the caching guide: a route-segment export const revalidate = 600 must be a literal, statically analyzable number (60 * 10 is invalid), and the lowest revalidate of any layout/page in a route decides the revalidation frequency of the whole route.
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 resultunstable_cache for Non-fetch Data (ORMs, Supabase queries)#
fetch caching only covers fetch. Database calls through an ORM or @supabase/supabase-js need unstable_cache (or React cache for per-request deduplication):
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 autorunRelated: 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
import { NextResponse, type NextRequest } from 'next/server'
export const config = {
matcher: '/api/:path*',
}
export function middleware(request: NextRequest) {
// Keep middleware thin: header checks, rewrites, redirects.
// Every matched request pays its latency.
const response = NextResponse.next()
response.headers.set('x-request-path', request.nextUrl.pathname)
return response
}Version note: on Next.js 14 you could read request.geo on Vercel; Next.js 15 removed the geo/ip fields from NextRequest — on Vercel that data now comes from the geolocation()/ipAddress() helpers in @vercel/functions, and on other hosts from your CDN's request headers (for example Cloudflare's cf-ipcountry).
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
- 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.
One email a month — no fluff
RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.
Related Guides
Caching Strategies for Next.js + Supabase Applications
Caching patterns for Next.js + Supabase at scale: Redis integration, ISR optimization, SWR patterns, and cache invalidation.
Next.js 15 Partial Prerendering: Guide
Next.js 15 Partial Prerendering: the static shell / dynamic holes model, Suspense boundaries, streaming, caching, and migration paths.
Next.js App Router Guide: From Basics to Advanced Patterns
Master the Next.js App Router: routing, layouts, server components, data fetching, and advanced patterns for modern web apps.