Next.js Performance: Core Web Vitals & Beyond

Photo by Unsplash

Photo by Unsplash
Next.js performance optimization is more than a nice-to-have — Core Web Vitals are a Google ranking factor, and slow applications lose users at measurable rates. This post covers the full stack of Next.js performance: image and font optimization, React Server Components for reduced JavaScript, caching strategies, bundle analysis, and streaming with Suspense. Every technique comes with TypeScript code you can apply directly to your Next.js 15 app.
Images and fonts are the two most common sources of poor Largest Contentful Paint (LCP) and Cumulative Layout Shift (CLS) scores. Next.js provides built-in components for both that handle the hard parts automatically — format conversion, size hints, lazy loading, and font subsetting.
The next/image component automatically serves WebP or AVIF formats (30-50% smaller than JPEG), enforces explicit width and height to prevent layout shift, and lazy-loads off-screen images. The priority prop on LCP images triggers a preload link tag, reducing time-to-LCP. The sizes prop tells the browser which image size to download at each viewport width, preventing desktop-sized images on mobile.
// Next.js Image optimization — always use next/image
import Image from "next/image";
// Bad: raw <img> tag, no optimization
<img src="/hero.jpg" alt="Hero" />
// Good: automatic WebP/AVIF, lazy loading, size hints
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={630}
priority // LCP image: preload it
sizes="(max-width: 768px) 100vw, 1200px"
quality={85}
/>
// Font optimization with next/font (zero layout shift)
import { Inter } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
});
// next.config.ts — bundle analyzer
import { withNextBundleAnalyzer } from "@next/bundle-analyzer";
export default withNextBundleAnalyzer({ enabled: process.env.ANALYZE === "true" })({
// ... rest of config
});Use Lighthouse CI in your GitHub Actions pipeline to track Core Web Vitals on every pull request. A performance regression caught before merge is free to fix; one caught in production costs user trust and Google rankings.
React Server Components (RSC) render on the server and send HTML to the client — no JavaScript is shipped for the component itself. In a typical Next.js 15 app, using RSC for data fetching and rendering eliminates kilobytes of JavaScript that would otherwise bloat the client bundle. Code splitting via dynamic imports further reduces initial load by deferring non-critical component bundles.
The default in Next.js App Router is Server Components — they can fetch data directly, access server-side resources, and produce HTML without shipping JavaScript. Add 'use client' only when you need browser APIs (useState, useEffect, event handlers, WebSockets). The optimal composition is a Server Component shell that passes data to small, targeted Client Components only where interactivity is needed.
// React Server Components: zero JS on the client by default
// app/blog/[slug]/page.tsx — Server Component
export default async function BlogPost({ params }: { params: { slug: string } }) {
// This fetch runs on the server — no client bundle cost
const post = await fetch(`https://api.example.com/posts/${params.slug}`, {
next: { revalidate: 3600 }, // ISR: revalidate every hour
}).then(r => r.json());
return (
<article>
<h1>{post.title}</h1>
<PostContent content={post.content} /> {/* Server Component */}
<LikeButton postId={post.id} /> {/* Client Component — "use client" */}
</article>
);
}
// Streaming with Suspense for TTFB improvement
import { Suspense } from "react";
export default function Page() {
return (
<>
<HeroSection /> {/* Renders immediately */}
<Suspense fallback={<CommentsSkeleton />}>
<Comments /> {/* Streamed when ready */}
</Suspense>
</>
);
}Use next/dynamic to defer large third-party libraries and heavy components until they're needed. A rich text editor, a charting library, or a map component might add 200KB to your initial bundle. With dynamic imports and ssr: false, that bundle loads only when the component is rendered, improving Time to Interactive for users who never interact with those components.
Next.js 15 provides multiple layers of caching: the fetch cache (per-request, with configurable revalidation), the full-route cache (rendered HTML cached at the CDN layer), and the data cache (request deduplication within a single render). Understanding which cache applies to your data and how to invalidate it correctly is critical for building fast, up-to-date applications.
Fully static pages (generateStaticParams with no revalidation) are the fastest — they're served from CDN edge nodes with zero server processing. Incremental Static Regeneration (ISR) adds a revalidate interval, allowing pages to be refreshed in the background while serving stale-but-fast cached responses. Dynamic rendering runs on the server per-request — use it only when content must be fully fresh or personalized per user.
The most common Next.js performance mistake is adding 'use client' to parent components, which converts their entire subtree to client components and sends all that JavaScript to the browser. If only one leaf component needs interactivity, only that leaf should be a Client Component. Pass server-fetched data down as props rather than re-fetching in a client component.
ISR's time-based revalidation means content can be up to N seconds stale. For content that must update immediately (a published blog post, a price change), use on-demand revalidation via revalidatePath() or revalidateTag(). Call these from a webhook handler triggered by your CMS or database, and Next.js will purge and regenerate only the affected pages.
A large JavaScript bundle is often the root cause of poor Time to Interactive and First Input Delay. Next.js's built-in bundle analyzer (@next/bundle-analyzer) gives you a visual treemap of your bundle, making it easy to spot unexpectedly large dependencies. Third-party scripts — analytics, chat widgets, A/B testing — are often the biggest offenders.
Enable the bundle analyzer with ANALYZE=true npm run build and look for: duplicate packages (two versions of the same library), large dependencies that could be replaced with lighter alternatives (lodash → lodash-es with tree shaking, or native methods), and client-side code that could move to Server Components. Aim to keep your First Load JS under 100KB for the critical path.
Use next/script with strategy='lazyOnload' for non-critical third-party scripts (analytics, chat). This defers them until after the page is interactive. For scripts that are critical but not blocking (Google Tag Manager), use strategy='afterInteractive'. The afterInteractive strategy loads the script after hydration, preventing it from blocking LCP. Audit your third-party scripts regularly — each one adds risk to your Core Web Vitals.
Use the 'use server' directive with Server Actions to handle form submissions and mutations server-side without API route overhead. Server Actions reduce client-side JavaScript and provide automatic CSRF protection, request deduplication, and TypeScript end-to-end type safety.
Set up real-user monitoring (RUM) with Vercel Analytics, Datadog RUM, or the web-vitals JavaScript library to capture Core Web Vitals from real users in production. Lab data from Lighthouse gives you a controlled baseline; RUM data tells you what actual users experience on their devices and connections. Both are necessary. Measure, optimize the highest-impact metric first, deploy, and measure again.
Key Next.js performance concepts in this post include LCP, CLS, INP, ISR, RSC, and code splitting.