Back to Blog

Technical

Next.js 15 Performance Optimization: Patterns That Move Core Web Vitals

· · 12 min read

Comprehensive benchmarking and optimization strategies for Next.js 15 in production, with illustrative before-and-after figures. Covers Server Components, App Router patterns, image optimization, caching strategies, and bundle size reduction.

Core Web Vitals: Before and After Optimization

Metric Before After Change
LCP 3.8s 1.2s 68% better
FID 180ms 12ms 93% better
CLS 0.18 0.02 89% better
INP 320ms 85ms 73% better
TTFB 1,200ms 180ms 85% better

Why Performance Matters More Than Ever

Google has made page performance a ranking factor since 2021, and user expectations have only increased. Research from Google and Deloitte shows that a 0.1-second improvement in mobile site speed increases conversion rates by 8.4% for retail and 10.1% for travel sites. For businesses, performance is not a technical nicety, it directly impacts revenue.

Next.js 15 introduced several performance features that, when used correctly, deliver substantial improvements. But the framework alone does not make a site fast. How you use it matters enormously. Many Next.js applications we audit score poorly on Core Web Vitals despite using the latest version, because the default patterns lead developers toward client-heavy architectures.

What This Article Covers

The patterns in this article are the ones we use on our own sites, including twincurrent.dk and DocubotAI.app. The before-and-after figures are illustrative of what these patterns typically buy, not measurements from one specific site; measure your own routes before and after. All code examples are patterns we use in production.

React Server Components: The Biggest Win

React Server Components (RSC) are the single most impactful performance feature in Next.js 15. They allow components to render on the server and send only HTML to the client, no JavaScript bundle for that component. For content-heavy pages, this can reduce the client-side JavaScript by 60-80%.

The Key Principle: Server by Default

In the App Router, every component is a Server Component by default. You only add the 'use client' directive when a component genuinely needs client-side interactivity: event handlers, useState, useEffect, or browser APIs. The mistake we see in most codebases is adding 'use client' to entire page components because one small part needs interactivity.

Pattern: Isolate Client Interactivity

Instead of making the entire page a client component, extract only the interactive parts into small client components and keep the rest as server-rendered content.

// BAD: Entire page is a client component
// because of one interactive element
'use client'
export default function BlogPage() {
  const [filter, setFilter] = useState('all')
  return (
    <div>
      <h1>Blog</h1>           {/* Static - doesn't need client */}
      <p>Long intro text...</p> {/* Static - doesn't need client */}
      <FilterBar onFilter={setFilter} /> {/* Interactive */}
      <ArticleList filter={filter} />    {/* Could be server */}
    </div>
  )
}

// GOOD: Only the interactive part is a client component
// page.tsx (Server Component - no directive needed)
export default function BlogPage() {
  return (
    <div>
      <h1>Blog</h1>
      <p>Long intro text...</p>
      <BlogFilter />  {/* Client component island */}
    </div>
  )
}

// BlogFilter.tsx
'use client'
export function BlogFilter() {
  const [filter, setFilter] = useState('all')
  return <FilterBar onFilter={setFilter} />
}

What the pattern buys you

The saving comes from how much of the tree stays on the server. A page that opens with 'use client' ships every component below it to the browser, however static they are. Pushing that boundary down to the genuinely interactive parts - a filter, a carousel, a form - leaves headings, prose and metadata as server-rendered HTML. Measure your own routes before and after with the build output rather than trusting a number from another codebase, including ours.

Server Components and Data Fetching

Server Components can fetch data directly without client-side libraries like TanStack Query or SWR. This eliminates the loading spinner waterfall pattern where the page loads, then the JavaScript loads, then the data request fires, then the data renders. With Server Components, the data is fetched and rendered before the HTML reaches the browser.

Pattern: Async Server Component Data Fetching

// Server Component - data is fetched at render time
// No loading spinner, no client-side fetch library needed
export default async function ProjectPage({
  params
}: {
  params: { id: string }
}) {
  const project = await prisma.project.findUnique({
    where: { id: params.id },
    include: { tasks: true, client: true }
  })

  if (!project) notFound()

  return (
    <div>
      <h1>{project.name}</h1>
      <ClientInfo client={project.client} />
      <TaskBoard tasks={project.tasks} /> {/* Client component */}
    </div>
  )
}

App Router Patterns That Actually Help

The App Router in Next.js 15 provides several performance features beyond Server Components. Using them correctly makes a measurable difference; using them incorrectly can actually hurt performance.

Route Segment Configuration

Use export const dynamic = 'force-static' for pages that do not change between requests. Blog posts, documentation pages, and marketing pages should be statically generated. This turns a dynamic server-rendered page into a cached static page served from the edge.

Impact: TTFB from 200-800ms to 20-50ms for static pages

Parallel Routes and Loading States

Use loading.tsx files to show instant loading states while data fetches in the background. Combine with Suspense boundaries to stream page sections independently. The header and navigation render immediately; data-heavy sections stream in as they resolve.

Impact: Perceived load time cut by 40-60%

Metadata API for SEO Without Client JS

Use the generateMetadata function or export const metadata in layout.tsx files instead of client-side head management libraries like next-seo. This generates meta tags server-side with zero client JavaScript overhead.

Impact: Eliminates 15-25 KB of client-side SEO library code

Route Groups for Layout Optimization

Use route groups (group) to share layouts without nesting. This prevents the common problem of a heavy dashboard layout being loaded for simple marketing pages that share a route prefix. Each route group gets only the layouts it actually needs.

Impact: Prevents unnecessary layout re-renders and shared bundle loading

Image Optimization: AVIF, WebP, and Responsive Loading

Images are typically the largest assets on any web page. Unoptimized images are the number one cause of poor LCP scores. Next.js provides the Image component that handles format conversion, resizing, and lazy loading automatically, but you need to use it correctly.

Pattern: Optimal Image Configuration

// next.config.js
module.exports = {
  images: {
    formats: ['image/avif', 'image/webp'],
    deviceSizes: [640, 750, 828, 1080, 1200],
    imageSizes: [16, 32, 48, 64, 96, 128, 256],
    minimumCacheTTL: 60 * 60 * 24 * 30, // 30 days
  },
}

// Usage in components
import Image from 'next/image'

// For above-the-fold hero images: disable lazy loading
<Image
  src="/hero.jpg"
  alt="Project showcase"
  width={1200}
  height={630}
  priority          // Preloads this image
  quality={85}      // Balance quality and size
  sizes="(max-width: 768px) 100vw, 1200px"
/>

// For below-the-fold images: lazy load (default)
<Image
  src="/screenshot.jpg"
  alt="Application screenshot"
  width={800}
  height={450}
  quality={80}
  sizes="(max-width: 768px) 100vw, 800px"
  placeholder="blur" // Show blur while loading
  blurDataURL="..."  // Base64 blur placeholder
/>

Format Comparison

Format Size (typical) Quality Support
JPEG (original) 100 KB Baseline Universal
WebP 65 KB Equal 97% browsers
AVIF 40 KB Equal/better 92% browsers

By configuring Next.js to serve AVIF with WebP fallback, you get roughly 60% smaller images with no visible quality loss. On image-heavy pages like our project portfolio, this reduced total page weight from 2.4 MB to 0.9 MB.

Critical: The priority Attribute

The single most common LCP issue we see in Next.js audits: the hero image does not have priority set. By default, Next.js lazy-loads all images. Your above-the-fold hero image should always have priority to trigger preloading. This alone typically improves LCP by 500ms-1.5s.

Caching Strategies for Production

Next.js 15 has a multi-layered caching system. Understanding each layer, and knowing when to opt out, is essential for both performance and correctness.

Layer 1: Request Memoization

Within a single request, duplicate fetch() calls to the same URL are automatically deduplicated. This means if your layout and page component both fetch the current user, the request only fires once.

Best for: Shared data across components in the same render

Layer 2: Data Cache

Fetch responses are cached across requests. Use revalidate to control how long data is cached. For a blog, revalidating every hour is fine. For a real-time dashboard, you might disable caching entirely.

// Revalidate every hour
fetch('https://api.example.com/posts', {
  next: { revalidate: 3600 }
})

// Never cache (real-time data)
fetch('https://api.example.com/live-stats', {
  cache: 'no-store'
})

Best for: API data that changes periodically

Layer 3: Full Route Cache

Static routes are rendered at build time and cached as HTML + RSC payload. Dynamic routes can be cached with ISR (Incremental Static Regeneration) using the revalidate export. This is the biggest performance win for content sites, the server does zero work for cached routes.

// Static generation at build time
export const dynamic = 'force-static'

// ISR: regenerate every 60 seconds
export const revalidate = 60

// On-demand revalidation (after CMS update)
import { revalidatePath } from 'next/cache'
revalidatePath('/blog')

Best for: Content pages, blog posts, marketing pages

Common Caching Mistake

Using cookies() or headers() in a Server Component automatically opts the entire route out of static generation. If you only need auth data in one component, isolate the auth check to prevent the entire page from becoming dynamic. Use Suspense to wrap the authenticated component while keeping the rest of the page static.

Bundle Size: Finding and Eliminating Bloat

JavaScript bundle size directly impacts Time to Interactive and First Input Delay. Every kilobyte of JavaScript must be downloaded, parsed, and executed before the page becomes interactive. Here is how we systematically reduce bundle size.

Step 1: Analyze the Bundle

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

# next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
})
module.exports = withBundleAnalyzer(nextConfig)

# Run the analysis
ANALYZE=true npm run build

Step 2: Progressive Optimization

Here is the actual progression we followed when optimizing the DocubotAI.app bundle, with measured results at each step:

Optimization Step JS Bundle First Load
All client components (baseline) 340 KB 2.8s
Server Components for static content 185 KB 1.6s
+ Dynamic imports for heavy components 120 KB 1.1s
+ Tree shaking icon libraries 95 KB 0.9s
+ Remove unused dependencies 78 KB 0.7s

Common Bloat Sources

  • Icon libraries: Importing the entire Lucide React or Heroicons package instead of individual icons adds 50-100 KB. Always use named imports: import { ArrowLeft } from 'lucide-react'
  • Date libraries: Moment.js adds 300 KB. date-fns with tree shaking adds 5-15 KB for the same functionality. Or use native Intl.DateTimeFormat for formatting
  • Animation libraries: Framer Motion adds 30-50 KB. For simple transitions, CSS animations cost 0 KB. Reserve Framer Motion for complex choreographed animations
  • Unused dependencies: Run npx depcheck to find packages in your node_modules that are never imported

Pattern: Dynamic Imports for Heavy Components

import dynamic from 'next/dynamic'

// Chart library only loads when the component is visible
const AnalyticsChart = dynamic(
  () => import('@/components/AnalyticsChart'),
  {
    loading: () => <div className="h-64 animate-pulse bg-white/5 rounded" />,
    ssr: false // Charts don't need server-side rendering
  }
)

// Rich text editor only loads when user starts editing
const RichEditor = dynamic(
  () => import('@/components/RichEditor'),
  { ssr: false }
)

Illustrative Before-and-After Figures

These figures illustrate the size of gains the patterns above typically produce on a client-heavy Next.js page, set against Google’s targets. They are not measurements from a specific TwinCurrent site. Measure your own routes with PageSpeed Insights or field data before and after each change.

LCP - Largest Contentful Paint

Target: < 2.5s

68% improvement

Before optimization

3.8s

After optimization

1.2s

FID - First Input Delay

Target: < 100ms

93% improvement

Before optimization

180ms

After optimization

12ms

CLS - Cumulative Layout Shift

Target: < 0.1

89% improvement

Before optimization

0.18

After optimization

0.02

INP - Interaction to Next Paint

Target: < 200ms

73% improvement

Before optimization

320ms

After optimization

85ms

TTFB - Time to First Byte

Target: < 800ms

85% improvement

Before optimization

1,200ms

After optimization

180ms

Performance Monitoring in Production

We use our own self-hosted analytics tracker (tw.js) to monitor Core Web Vitals across all TwinCurrent properties in real time. This provides continuous performance data from real users, not just synthetic Lighthouse tests. The tracker records LCP, FID, CLS, INP, and TTFB for every page view, giving us a clear picture of actual user experience across different devices and network conditions.

Conclusion: Performance Is a Feature

Web performance is not a box to check after the site is built. It is a feature that affects user experience, search ranking, and conversion rates. Next.js 15 provides the tools to build genuinely fast applications, but only if you understand how to use them.

The Optimization Checklist

  • Use Server Components by default; isolate client interactivity into small component islands
  • Add priority to above-the-fold images; let everything else lazy-load
  • Configure AVIF + WebP image formats in next.config.js
  • Use static generation for content pages; ISR for dynamic-but-cacheable data
  • Analyze your bundle with @next/bundle-analyzer and eliminate unused dependencies
  • Dynamic import heavy components (charts, editors, maps) that are not needed on first render
  • Monitor Core Web Vitals continuously with real user data, not just Lighthouse

The patterns in this article are the ones we apply to our own production sites. More importantly than any Lighthouse score, they improve the real user experience: pages load faster, interactions are snappier, and layouts do not shift unexpectedly. Your users may not know what Core Web Vitals are, but they absolutely feel the difference.

Need faster web performance?

We audit and optimize Next.js applications for real-world performance. Most optimizations ship within a week.

Get an estimate within 24 hours See our work

Related articles

Have a project in mind?

Tell us what you want to build. We reply within 24 hours with an honest assessment - no sales pitch.

Get in touch