Skip to main content
Next.js performance optimization — code editor with Lighthouse score overlay
EngineeringMarch 20, 2026·14 min read

Next.js Performance Optimization Guide 2026: Speed Up Your App

From Core Web Vitals and rendering strategies to Edge Runtime, Redis caching, and React Server Components — everything you need to hit Lighthouse 95+ in production.

CE

Codazz Engineering

Engineering Team, Codazz

Share:

A slow website is a business problem. A one-second delay in page load time reduces conversions by 7%, and Google's ranking algorithm now penalises pages with poor Core Web Vitals scores.

Next.js ships with the building blocks for exceptional performance — but they only work if you know how and when to use them. This guide walks through every major lever: rendering strategies, image and font pipelines, bundle analysis, caching, database query patterns, Edge Runtime, and React Server Components.

At Codazz we've optimised 60+ Next.js apps to Lighthouse 95+. Here's the playbook we follow every time.

LCP target
≤2.5s
Good — green zone
INP target
≤200ms
Good — green zone
CLS target
≤0.1
Good — green zone
Lighthouse
95+
Achievable in production

Core Web Vitals: What Google Actually Measures

Core Web Vitals (CWV) are a set of real-user metrics that Google uses as a ranking signal. They replaced the older Lab-only metrics in 2021 and have grown in weight every year since. In 2026, pages in the bottom quartile of CWV scores can lose up to 15% of organic ranking positions compared to equivalent competitors.

Largest Contentful Paint (LCP)

LCP measures the time from navigation start until the largest visible content element (usually a hero image or H1) is rendered. Target: under 2.5 seconds. The single biggest cause of poor LCP is an unoptimised hero image — either too large, wrong format, or not prioritised.

  • Always add priority prop on the hero <Image> so Next.js injects a <link rel="preload">
  • Use fetchpriority="high" on images above the fold
  • Serve images from a CDN with global edge caching
  • Avoid render-blocking CSS or fonts in the <head>

Interaction to Next Paint (INP)

INP replaced First Input Delay (FID) as the interactivity metric in 2024. It measures the latency of all interactions (clicks, taps, key presses) throughout the page lifetime, not just the first. Target: under 200ms.

  • Move heavy computations off the main thread with Web Workers
  • Break up long tasks using scheduler.yield() or setTimeout(fn, 0)
  • Avoid excessive React re-renders with useMemo / useCallback where profiling shows a real cost
  • Use React 19's useTransition to keep interactions responsive during state updates

Cumulative Layout Shift (CLS)

CLS measures how much content jumps around as the page loads. A score above 0.1 is noticeable to users and penalised by Google. Common culprits: images without explicit width/height, late-loading fonts (FOUT), and dynamically injected banners.

Quick win: Always pass explicit width and height to Next.js <Image> (or use fill with a sized wrapper) so the browser reserves space before the image loads. This alone can drop CLS from 0.3 to 0.0.

SSR vs SSG vs ISR: Choosing the Right Rendering Strategy

Next.js supports four rendering modes. The right choice per page has a larger impact on performance than almost any other decision.

ModeWhenTTFBUse cases
SSGBuild time~5msBlog posts, docs, marketing landing pages
ISRBuild + re-validate~5ms (cached)Product pages, news, listings
SSREach request50–300msDashboards, real-time data, personalised pages
CSRClient only~5ms (shell)Heavy interactive widgets, behind-auth tools

SSG — Static Site Generation

Pages generated at build time are served directly from a CDN with near-zero TTFB. Use SSG for any page whose data does not change between deployments. In the Next.js App Router, any async Server Component that does not opt into dynamic rendering is automatically statically generated.

app/blog/[slug]/page.tsx — SSG with generateStaticParams
// Next.js 15 App Router — statically generated at build time
export async function generateStaticParams() {
  const posts = await fetchAllPosts();           // runs once at build
  return posts.map(p => ({ slug: p.slug }));
}

export default async function BlogPost({ params }: { params: { slug: string } }) {
  const post = await fetchPost(params.slug);     // cached indefinitely
  return <article>{post.content}</article>;
}

ISR — Incremental Static Regeneration

ISR serves a cached static page instantly, then regenerates it in the background when a request comes in after the revalidation window. This is the sweet spot for content that changes a few times a day.

app/products/[id]/page.tsx — ISR with revalidate
// Revalidate every 60 seconds
export const revalidate = 60;

export default async function ProductPage({ params }: { params: { id: string } }) {
  const product = await fetch(`/api/products/${params.id}`, {
    next: { revalidate: 60 },  // per-fetch granularity
  }).then(r => r.json());

  return <ProductView product={product} />;
}

// On-demand revalidation via API route
// POST /api/revalidate  →  revalidateTag('products')

SSR — Server-Side Rendering

Use SSR only when the page must reflect real-time or user-specific data on every request. Keep SSR pages lean — offload heavy computation to background jobs or a caching layer.

app/dashboard/page.tsx — SSR (dynamic)
import { cookies } from 'next/headers';

// 'force-dynamic' opts the entire page out of caching
export const dynamic = 'force-dynamic';

export default async function Dashboard() {
  const token = cookies().get('session')?.value;
  const data  = await fetchUserData(token);       // fresh on every request
  return <DashboardView data={data} />;
}

Image Optimization with next/image

Images account for 50-70% of page weight on most marketing sites. Next.js's built-in <Image> component automates the most impactful optimisations.

Format conversion
Serves AVIF first, WebP as fallback, JPEG as last resort. AVIF is 40-50% smaller than JPEG at the same quality.
Responsive sizing
Generates multiple srcSet breakpoints so mobile users never download a 2000px image.
Lazy loading
Off-screen images are deferred by default using native loading="lazy", saving bandwidth and improving LCP.
CLS prevention
Reserves layout space via aspect-ratio box so the page does not shift when the image loads.
Correct next/image usage for hero, thumbnail, and fill patterns
import Image from 'next/image';

// ✅ Hero image — LCP element, always prioritise
<Image
  src="/hero.jpg"
  alt="Hero"
  width={1200}
  height={630}
  priority                  // generates <link rel="preload">
  quality={85}              // 75 default; raise for photographic content
  sizes="100vw"
/>

// ✅ Card thumbnail — lazy by default, hint responsive sizes
<Image
  src={product.image}
  alt={product.name}
  width={400}
  height={300}
  sizes="(max-width: 768px) 100vw, 400px"
/>

// ✅ Fill pattern for unknown aspect ratios (use a sized wrapper)
<div style={{ position: 'relative', width: '100%', aspectRatio: '16/9' }}>
  <Image
    src={post.cover}
    alt={post.title}
    fill
    style={{ objectFit: 'cover' }}
    sizes="(max-width: 1024px) 100vw, 800px"
  />
</div>

External Images & Remote Patterns

To optimise images from third-party domains, whitelist them in next.config.ts. Use exact domain allowlists in production — never use wildcard hostnames.

next.config.ts — remote image patterns
import type { NextConfig } from 'next';

const config: NextConfig = {
  images: {
    remotePatterns: [
      { protocol: 'https', hostname: 'cdn.yourdomain.com',     pathname: '/uploads/**' },
      { protocol: 'https', hostname: 'images.unsplash.com' },
    ],
    formats: ['image/avif', 'image/webp'],   // prefer AVIF
    deviceSizes: [640, 750, 828, 1080, 1200, 1920],
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
  },
};

export default config;

Code Splitting & Bundle Size Reduction

JavaScript is the most expensive resource on the web — byte for byte it takes longer to parse and execute than equivalent HTML or CSS. Next.js splits bundles automatically per route, but there are still common pitfalls that bloat the client bundle.

Analysing Your Bundle

Before optimising, measure. Install @next/bundle-analyzer and run a production build to visualise what is eating your JS budget.

next.config.ts — bundle analyzer setup
import bundleAnalyzer from '@next/bundle-analyzer';

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

export default withBundleAnalyzer({
  // ... your config
});

// Run: ANALYZE=true npm run build
// Opens an interactive treemap in your browser

Dynamic Imports for Heavy Components

Any component not needed for the initial render should be dynamically imported. This defers the parse cost until the user actually needs it.

Lazy-loading a heavy chart library
import dynamic from 'next/dynamic';

// This entire chunk (~140 KB) loads only when the component mounts
const RevenueChart = dynamic(() => import('@/components/RevenueChart'), {
  loading: () => <div style={{ height: 300, background: 'rgba(255,255,255,0.03)', borderRadius: 12 }} />,
  ssr: false,   // charts often rely on window — skip SSR
});

// Use it like a normal component
export default function Dashboard() {
  return <RevenueChart data={data} />;
}

Common Bundle Bloat Culprits

  • moment.js — 300 KB. Replace with date-fns (tree-shakable) or native Intl API
  • lodash — import only what you use: import debounce from "lodash/debounce"
  • Full icon libraries — import individual icons, not entire packs
  • Client-only data fetching — move to Server Components so fetch code never ships to the browser
  • Barrel files (index.ts) — can prevent tree-shaking; import directly from the module file

Enabling optimizePackageImports in Next.js 15 automatically tree-shakes popular packages like lucide-react, @heroicons/react, and react-icons without any config changes in your components.

Font Optimization with next/font

Fonts are a silent CLS and LCP killer. The browser must download the font before text can render (FOUT/FOIT), causing a visible flash or blank text. next/font solves this at the framework level.

  • Downloads and self-hosts Google Fonts at build time — zero network request to Google at runtime
  • Inlines a size-adjust CSS descriptor so the fallback system font matches the web font metrics exactly, eliminating FOUT CLS
  • Automatically adds <link rel="preload"> for the correct font files
  • Respects display: swap by default to keep text visible during load
app/layout.tsx — next/font setup
import { Inter, Syne } from 'next/font/google';
import localFont from 'next/font/local';

// Google Font — self-hosted, zero runtime DNS lookup
const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter',
  preload: true,
});

// Heading font — subset to only the weights you use
const syne = Syne({
  subsets: ['latin'],
  weight: ['700', '800'],
  variable: '--font-syne',
  display: 'swap',
});

// Local variable font (best performance)
const brandFont = localFont({
  src: [
    { path: '../public/fonts/Brand-Regular.woff2', weight: '400', style: 'normal' },
    { path: '../public/fonts/Brand-Bold.woff2',    weight: '700', style: 'normal' },
  ],
  variable: '--font-brand',
  display: 'swap',
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={`${inter.variable} ${syne.variable} ${brandFont.variable}`}>
      <body style={{ fontFamily: 'var(--font-inter)' }}>{children}</body>
    </html>
  );
}

Caching Strategies: HTTP, ISR & Redis

Caching is the highest-leverage performance optimisation. A cache hit can reduce TTFB from 200ms to under 5ms. Next.js provides several caching layers; understanding which one to use where is key.

HTTP Cache Headers

For static assets and ISR pages, set aggressive Cache-Control headers. The CDN in front of your app (Vercel Edge, CloudFront, Cloudflare) will serve subsequent requests without hitting your origin.

Setting cache headers in a Route Handler
import { NextResponse } from 'next/server';

export async function GET() {
  const data = await fetchPublicData();

  return NextResponse.json(data, {
    headers: {
      // CDN caches for 1 hour, allows stale for 24h while revalidating
      'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
      // Allow Vercel Edge Cache to store the response
      'CDN-Cache-Control': 'public, max-age=3600',
    },
  });
}

ISR On-Demand Revalidation

Tag your data fetches with cache tags, then invalidate them precisely when your CMS or database changes. This replaces time-based ISR for data that changes unpredictably.

On-demand revalidation via webhook
// app/api/revalidate/route.ts  — called by your CMS webhook
import { revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(req: NextRequest) {
  const secret = req.headers.get('x-webhook-secret');
  if (secret !== process.env.WEBHOOK_SECRET) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const { tag } = await req.json();  // e.g. { tag: 'products' }
  revalidateTag(tag);

  return NextResponse.json({ revalidated: true, tag });
}

// In your data fetch, attach the tag:
const products = await fetch('/api/products', {
  next: { tags: ['products'] },
}).then(r => r.json());

Redis for Database Query Caching

For SSR pages or API routes that run expensive database queries, Redis is the standard caching layer. Cache query results with a TTL and invalidate on write operations.

Redis caching wrapper for Prisma queries
import { createClient } from 'redis';

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

export async function cachedQuery<T>(
  key: string,
  ttl: number,
  fetcher: () => Promise<T>
): Promise<T> {
  // 1. Try cache
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached) as T;

  // 2. Cache miss — run the query
  const data = await fetcher();

  // 3. Store in Redis with expiry
  await redis.setEx(key, ttl, JSON.stringify(data));
  return data;
}

// Usage
const products = await cachedQuery(
  'products:featured',
  300,   // 5-minute TTL
  () => prisma.product.findMany({ where: { featured: true }, take: 12 })
);

Database Query Optimization

Server response time (TTFB) is dominated by database query time. Even a perfectly optimised Next.js app will feel slow if the underlying queries are inefficient.

Select only needed columns
Never SELECT * in production. Fetching unused columns wastes bandwidth and prevents index-only scans.
Add indexes on filter columns
Any column in a WHERE, ORDER BY, or JOIN ON clause should be indexed. Use EXPLAIN ANALYZE to verify.
Avoid N+1 queries
Use Prisma's include/select nesting or DataLoader batching. N+1 patterns are the #1 cause of slow SSR pages.
Paginate large result sets
Never fetch unbounded lists. Use cursor-based pagination for infinite scroll — offset pagination degrades with large offsets.
Fixing an N+1 with Prisma's include
// ❌ N+1 — fires 1 query for orders + 1 per order for the user
const orders = await prisma.order.findMany();
const enriched = await Promise.all(
  orders.map(o => prisma.user.findUnique({ where: { id: o.userId } }))
);

// ✅ Single JOIN query
const orders = await prisma.order.findMany({
  include: { user: { select: { id: true, name: true, email: true } } },
  where: { status: 'PENDING' },
  orderBy: { createdAt: 'desc' },
  take: 50,
});

Connection Pooling in Serverless Environments

Serverless functions (Vercel, AWS Lambda) create a new database connection on each cold start. Without connection pooling, your database hits its connection limit under load. Use PgBouncer, Prisma Accelerate, or Neon's built-in pooling.

Prisma singleton for connection reuse
// lib/prisma.ts — prevents multiple instances in dev HMR
import { PrismaClient } from '@prisma/client';

const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({ log: process.env.NODE_ENV === 'development' ? ['query'] : [] });

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

Edge Runtime: Ultra-Low Latency at Scale

The Edge Runtime runs your code in lightweight V8 isolates at CDN points-of-presence worldwide — often within 10ms of the user — rather than in a single Node.js server region. This is transformative for latency-sensitive logic.

  • Best for: Authentication middleware, geo-routing, A/B testing, personalised redirects, rate limiting, feature flags
  • Not suitable for: Node.js built-ins (fs, crypto), most native npm packages, long-running operations
  • Cold starts: Under 1ms (vs 100-400ms for Lambda cold starts)
middleware.ts — geo-personalisation at the edge
import { NextRequest, NextResponse } from 'next/server';

export const config = { matcher: ['/', '/pricing', '/products/:path*'] };

export function middleware(req: NextRequest) {
  const country = req.geo?.country ?? 'US';
  const url     = req.nextUrl.clone();

  // Redirect CA users to localised pricing without hitting origin
  if (country === 'CA' && !url.pathname.startsWith('/ca')) {
    url.pathname = `/ca${url.pathname}`;
    return NextResponse.redirect(url);
  }

  // A/B test — assign bucket at edge, cookie for consistency
  const bucket = req.cookies.get('ab-bucket')?.value
    ?? (Math.random() < 0.5 ? 'control' : 'variant');

  const res = NextResponse.next();
  res.headers.set('x-ab-bucket', bucket);
  res.cookies.set('ab-bucket', bucket, { maxAge: 86400 * 30 });
  return res;
}

React Server Components: Reducing Client JavaScript

React Server Components (RSC) are the most significant architectural shift in Next.js since the App Router launched. They allow entire component trees to render on the server without shipping any of their code to the browser.

  • The component itself — including its imports (ORM, SDK, utilities) — never enters the client bundle
  • Data fetching happens on the server, co-located with the component, with no waterfall round-trips
  • Secrets (API keys, DB credentials) stay server-side by default
  • Only add 'use client' at the boundary where interactivity starts — not at the top of every file
Splitting server and client components correctly
// ✅ Server Component — fetches data, renders static shell
// No 'use client' → runs only on the server
import { prisma } from '@/lib/prisma';
import ProductCard from './ProductCard';     // also a Server Component
import AddToCartButton from './AddToCartButton'; // Client Component

export default async function ProductGrid() {
  // This Prisma call never touches the client bundle
  const products = await prisma.product.findMany({
    where: { published: true },
    select: { id: true, name: true, price: true, image: true },
    orderBy: { createdAt: 'desc' },
  });

  return (
    <ul style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 24 }}>
      {products.map(p => (
        <li key={p.id}>
          <ProductCard product={p} />
          {/* 👇 Only this leaf component ships client JS */}
          <AddToCartButton productId={p.id} />
        </li>
      ))}
    </ul>
  );
}

// ✅ Client Component — only the interactive leaf
// AddToCartButton.tsx
'use client';
import { useState } from 'react';
export function AddToCartButton({ productId }: { productId: string }) {
  const [added, setAdded] = useState(false);
  return (
    <button onClick={() => setAdded(true)}>
      {added ? 'Added!' : 'Add to Cart'}
    </button>
  );
}

The "client component boundary" concept is the most common source of RSC confusion. A component with 'use client' can still render Server Component children passed as props — those children still execute on the server. This pattern lets you keep interactive wrappers thin.

Measuring Performance: Lighthouse, PageSpeed & Vercel Analytics

You cannot optimise what you do not measure. Each tool gives you a different view of your application's performance.

ToolData typeBest for
Lighthouse (DevTools)Synthetic lab dataPre-deploy checks, identifying regressions locally
PageSpeed InsightsLab + field (CrUX) dataReal-world CWV from Google's dataset of actual users
Vercel AnalyticsReal user monitoring (RUM)Production CWV per route, p75/p99 breakdowns
WebPageTestSynthetic + filmstripWaterfall analysis, third-party impact, TTFB debugging
Chrome UX Report (CrUX)Field data (28-day rolling)Checking Google's ranking signal data for your domain

Vercel Speed Insights Integration

app/layout.tsx — Real User Monitoring setup
import { SpeedInsights } from '@vercel/speed-insights/next';
import { Analytics }    from '@vercel/analytics/react';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        {/* Sends CWV data from real users to your Vercel dashboard */}
        <SpeedInsights />
        {/* Page view analytics — no cookies, privacy-friendly */}
        <Analytics />
      </body>
    </html>
  );
}

CI Performance Budgets

Prevent regressions by enforcing a performance budget in CI. Lighthouse CI can fail a pull request if the score drops below a threshold.

.lighthouserc.js — CI performance budget
module.exports = {
  ci: {
    collect: {
      startServerCommand: 'npm run start',
      url: ['http://localhost:3000/', 'http://localhost:3000/pricing'],
      numberOfRuns: 3,
    },
    assert: {
      assertions: {
        'categories:performance':   ['error', { minScore: 0.90 }],
        'categories:accessibility': ['warn',  { minScore: 0.95 }],
        'first-contentful-paint':   ['error', { maxNumericValue: 1800 }],
        'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
        'cumulative-layout-shift':  ['error', { maxNumericValue: 0.1  }],
        'total-blocking-time':      ['warn',  { maxNumericValue: 300  }],
      },
    },
    upload: { target: 'temporary-public-storage' },
  },
};

Lighthouse 95+ Checklist

This is the checklist we run on every Codazz project before launch. Complete all items on the High priority column and you will reliably hit 90+ on desktop; complete the full list and 95+ is achievable on most pages.

Critical
hero <Image> has priority prop
All <Image> components have explicit width + height or fill
next/font used for all custom fonts (zero FOUT)
No render-blocking scripts in <head> without defer/async
HTTP/2 or HTTP/3 enabled on your host
High
Bundle analyzer run — no single chunk over 150 KB
Heavy libraries (charts, editors) are dynamic imports
ISR or SSG used for all public content pages
CDN caching headers set (s-maxage) on static responses
Redis or ISR cache in front of expensive DB queries
Medium
Lighthouse CI integrated in GitHub Actions / CI pipeline
Vercel Speed Insights (or equivalent RUM) installed
Edge Middleware used for auth and geo-routing (not SSR)
Prisma (or ORM) singleton to prevent connection pool exhaustion
N+1 queries eliminated — verify with ORM logging in dev
Good to have
Critical CSS inlined (Next.js does this for small pages automatically)
Preconnect hints for third-party origins (fonts, analytics, CDN)
Web Workers for heavy client-side computations (parsing, sorting)
Service Worker + offline fallback for PWA use cases
useTransition for non-urgent state updates that affect many components

Frequently Asked Questions

Get Expert Help

Want a Lighthouse 95+ Score on Your Next.js App?

We run a full performance audit, fix the bottlenecks, and document every change so your team can maintain the gains. Typical turnaround: two weeks.