DEV Community

Cover image for Next.js Performance Optimization for Indie Developers
Mahdi BEN RHOUMA
Mahdi BEN RHOUMA

Posted on Originally published at iloveblogs.blog

Next.js Performance Optimization for Indie Developers

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
}
Enter fullscreen mode Exit fullscreen mode
// 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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

Responsive Images

<Image
  src="/hero.jpg"
  alt="Hero image"
  fill
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
  style={{ objectFit: 'cover' }}
/>
Enter fullscreen mode Exit fullscreen mode

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],
  },
}
Enter fullscreen mode Exit fullscreen mode

External Image Optimization

// next.config.mjs
export default {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'your-cdn.com',
        port: '',
        pathname: '/images/**',
      },
    ],
  },
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

Bundle Analysis

## Install bundle analyzer
npm install @next/bundle-analyzer

## Analyze bundle
ANALYZE=true npm run build
Enter fullscreen mode Exit fullscreen mode
// next.config.mjs
import bundleAnalyzer from '@next/bundle-analyzer'

const withBundleAnalyzer = bundleAnalyzer({
  enabled: process.env.ANALYZE === 'true',
})

export default withBundleAnalyzer({
  // Your Next.js config
})
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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() })
}
Enter fullscreen mode Exit fullscreen mode

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'] }
})
Enter fullscreen mode Exit fullscreen mode

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 result
Enter fullscreen mode Exit fullscreen mode

unstable_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'],
  }
)
Enter fullscreen mode Exit fullscreen mode

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,
  },
})
Enter fullscreen mode Exit fullscreen mode

Pagination

// Cursor-based pagination (efficient)
const posts = await db.post.findMany({
  take: 10,
  skip: 1,
  cursor: {
    id: lastPostId,
  },
  orderBy: {
    createdAt: 'desc',
  },
})
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

Custom Fonts

import localFont from 'next/font/local'

const myFont = localFont({
  src: './my-font.woff2',
  display: 'swap',
  variable: '--font-my-font',
})
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

Remove Render-Blocking Resources

// next.config.mjs
export default {
  compiler: {
    removeConsole: process.env.NODE_ENV === 'production',
  },
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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',
    },
  })
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

Avoid Large Client Bundles

// ❌ Bad: Import entire library
import _ from 'lodash'

// ✅ Good: Import only what you need
import debounce from 'lodash/debounce'
Enter fullscreen mode Exit fullscreen mode

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}
/>
Enter fullscreen mode Exit fullscreen mode

Related Articles

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.


Originally published at https://www.iloveblogs.blog

Top comments (0)