Optimizing NextJS Performance with Core Web Vitals
Introduction
Core Web Vitals are Google's real-world metrics used to measure user experience regarding loading speed, interactivity and visual stability of a website. Optimizing these metrics not only helps retain users longer but also serves as an important ranking factor in SEO, helping your website achieve higher positions on search engines.
This set of metrics includes 3 main components:
- LCP (Largest Contentful Paint): Measures loading performance, specifically the time it takes for the largest content element to become visible.
- FID (First Input Delay): Measures interactivity, evaluating the response time when a user first interacts.
- CLS (Cumulative Layout Shift): Measures visual stability, preventing situations where elements unexpectedly jump positions.
- SEO and Ranking (Search Signal): Google has officially made Core Web Vitals (CWV) one of its ranking signals.
- If two websites have equivalent content (Relevance), the one with better CWV metrics will be prioritized for a higher rank.
- Note that while good CWV cannot rescue poor content, great content with poor CWV will unfortunately suffer a drop in rankings.
- User Experience (Retention Rate): From a product development standpoint, CWV directly impacts the user funnel:
- High LCP: Users rarely have the patience to wait and will abandon the page immediately, leading to an increased bounce rate.
- High INP: This creates a perception that the system is slow to respond. For example, if a user clicks "Add to Cart" and the UI only responds a second later, it makes them suspect the system is glitching.
- High CLS: When the UI is unstable and shifts too frequently, it creates a frustrating user experience. For instance, if a user intends to click a certain button but a sudden layout shift causes them to misclick another, it severely damages their trust in the product—especially if this happens during critical actions like payments.
- Infrastructure and Resources: Optimizing CWV typically requires comprehensive coordination across the client, server, and DevOps:
- Improving LCP: Forces you to implement CDNs, optimize the size of static assets (bundle size, CSS, images), or correctly utilize Server-Side Rendering (SSR) to reduce Time to First Byte (TTFB).
- Optimizing INP: Requires you to manage the Main Thread and avoid long-running JavaScript tasks (Long Tasks) that block the Event Loop.
- Optimizing CLS: Requires minimizing layout thrashing to prevent DOM mutations from causing continuous reflows, keeping the CLS metric to a minimum. Always set dimensions (width/height) for assets that take time to load (such as images and videos).
Detail
Because a specific guide on how to optimize all 3 metrics would make the article very long, I will first guide you through LCP, while the remaining metrics will be discussed specifically in subsequent articles.
Methods to optimize for LCP include:
- Parallel Fetching and Caching: Use Route Handlers to load data concurrently using
Promise.allSettledorPromise.all. stale-while-revalidateis a caching mechanism to minimize server response wait times, applied to static data or data that changes infrequently to prevent server overload when multiple requests occur simultaneously.- Streaming with Suspense: Use Server Components to return data directly to the client, while heavy data requiring long processing times utilizes Re-Partial Rendering to stream that data gradually to the client.
- Image Optimization: Use the
priorityattribute innext/imagefor the main banner so the browser prioritizes immediate loading, while usingloading="lazy"for products below to save resources. - Note that using priority is only applied to main images directly visible to the user.
- Applying it to every image will lead to bandwidth resource contention.
Create file app/api/dashboard-summary/route.ts
import {NextResponse} from 'next/server'
export interface DashboardSummaryResponse {
stats: {
totalUsers: number
activeNow: number
} | null
banner: {
imageUrl: string
title: string
} | null
errors: string[]
}
async function fetchUserStatsFromThirdParty() {
await new Promise(resolve => setTimeout(resolve, 1000))
return {totalUsers: 24500, activeNow: 1230}
}
async function fetchBannerFromThirdParty() {
await new Promise(resolve => setTimeout(resolve, 800))
return {
imageUrl: 'https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe',
title: 'Next-Gen Technology Solutions',
}
}
export async function GET() {
const errors: string[] = []
const [statsResult, bannerResult] = await Promise.allSettled([
fetchUserStatsFromThirdParty(),
fetchBannerFromThirdParty(),
])
const stats =
statsResult.status === 'fulfilled'
? statsResult.value
: (errors.push(`Stats API Error: ${statsResult.reason}`), null)
const banner =
bannerResult.status === 'fulfilled'
? bannerResult.value
: (errors.push(`Banner API Error: ${bannerResult.reason}`), null)
return NextResponse.json<DashboardSummaryResponse>(
{stats, banner, errors},
{
status: 200,
headers: {
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=30',
},
}
)
}
Use Promise.allSettled to perform parallel fetching. This helps optimize performance because requests do not have to wait for each other.
Create file app/dashboard/types.ts
export interface Product {
id: number
title: string
price: number
thumbnail: string
}
export interface UserStats {
totalUsers: number
activeNow: number
}
Create file app/dashboard/loading.tsx to define Skeletons for components during the data streaming process.
import {Card, Skeleton} from 'antd'
export default function DashboardLoading() {
return (
<div className="p-6 max-w-7xl mx-auto space-y-6">
<div className="h-10 w-48 bg-gray-200 rounded animate-pulse mb-6" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<Skeleton active paragraph={{rows: 1}} />
</Card>
<Card>
<Skeleton active paragraph={{rows: 1}} />
</Card>
</div>
<div className="w-full h-[400px] bg-gray-200 rounded-lg animate-pulse" />
</div>
)
}
Create file app/dashboard/ProductList.tsx
import {Badge, Card} from 'antd'
import {Meta} from 'antd/es/list/Item'
import Image from 'next/image'
import type {Product} from './types'
async function getProducts(): Promise<Product[]> {
await new Promise(resolve => setTimeout(resolve, 2500))
const res = await fetch('https://dummyjson.com/products?limit=4', {
next: {revalidate: 60},
})
if (res.ok) {
const data = await res.json()
return data.products
}
return []
}
export default async function ProductList() {
const products = await getProducts()
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold text-gray-800 tracking-tight">
Featured Products
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{products.map(product => (
<Card
key={product.id}
hoverable
className="overflow-hidden border-gray-100 shadow-sm transition-all duration-300 hover:shadow-lg"
cover={
<div className="relative w-full h-52 bg-gray-50">
<Image
src={product.thumbnail}
alt={product.title}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 25vw"
className="object-cover"
loading="lazy"
/>
</div>
}
>
<Meta
title={
<span className="text-lg font-semibold text-gray-900 block truncate">
{product.title}
</span>
}
description={
<div className="mt-2">
<Badge
status="success"
text={
<span className="text-green-600 font-bold">
${product.price}
</span>
}
/>
</div>
}
/>
</Card>
))}
</div>
</div>
)
}
Cache data for 60 seconds so even with a large volume of simultaneous access, NextJS only needs to return cached results instead of calling data too many times.
Create file app/dashboard/page.tsx
import {DashboardSummaryResponse} from '@/app/api/dashboard-summary/route'
import {ArrowUpOutlined, UserOutlined} from '@ant-design/icons'
import {Alert, Card, Skeleton, Statistic} from 'antd'
import Image from 'next/image'
import {Suspense} from 'react'
import ProductList from './ProductList'
async function getDashboardSummary(): Promise<DashboardSummaryResponse> {
const baseUrl = process.env.HOST
const res = await fetch(`${baseUrl}/api/dashboard-summary`, {
next: {revalidate: 60},
})
if (!res.ok) {
throw new Error('Failed to fetch dashboard aggregate data')
}
return res.json()
}
export default async function DashboardPage() {
const {stats, banner, errors} = await getDashboardSummary()
return (
<div className="p-8 max-w-7xl mx-auto space-y-10 antialiased min-h-screen bg-gray-50/30">
<header className="flex flex-col space-y-4">
<h1 className="text-4xl font-black text-gray-900 tracking-tight">
System Dashboard
</h1>
{errors.length > 0 && (
<div className="space-y-2">
{errors.map((err, idx) => (
<Alert
key={idx}
title={err}
type="error"
showIcon
closable
className="rounded-lg shadow-sm"
/>
))}
</div>
)}
</header>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<Card className="border-none shadow-md rounded-2xl">
<Statistic
title={
<span className="text-gray-500 font-medium">Total Members</span>
}
value={stats?.totalUsers || 0}
prefix={<UserOutlined className="text-blue-500 mr-2" />}
loading={!stats}
/>
</Card>
<Card className="border-none shadow-md rounded-2xl">
<Statistic
title={
<span className="text-gray-500 font-medium">Active Users</span>
}
value={stats?.activeNow || 0}
styles={{content: {color: '#10b981', fontWeight: 800}}}
prefix={<ArrowUpOutlined className="mr-2" />}
loading={!stats}
/>
</Card>
</div>
{banner && (
<div className="relative w-full h-[400px] rounded-3xl overflow-hidden shadow-2xl border border-white/20">
<Image
src={banner.imageUrl}
alt={banner.title}
fill
priority
sizes="(max-width: 1280px) 100vw, 1280px"
className="object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent flex items-end p-10">
<h2 className="text-white text-4xl md:text-6xl font-extrabold max-w-3xl leading-tight drop-shadow-lg">
{banner.title}
</h2>
</div>
</div>
)}
<div className="pt-4">
<Suspense
fallback={
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
{[1, 2, 3, 4].map(i => (
<Skeleton
key={i}
active
className="p-4 bg-white rounded-xl shadow-sm"
/>
))}
</div>
}
>
<ProductList />
</Suspense>
</div>
</div>
)
}
- Parallel Fetching and Caching: At the Route Handler, data is loaded in parallel using
Promise.allSettledand applies astale-while-revalidatestrategy to minimize server response wait times. - Streaming with Suspense: The Dashboard page separates critical data (Stats, Banner) to render on the server side and uses
Suspensefor the product list which has high latency. This helps display the page layout faster, improving the perception of speed. - Image Optimization: Use the
priorityattribute innext/imagefor the main banner (LCP element) so the browser prioritizes immediate loading, while usingloading="lazy"for products below to save resources.
Before you can use it, you need to configure the settings to allow loading images from cross-origins in next.config.ts
import type {NextConfig} from 'next'
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '**.dummyjson.com',
},
{
protocol: 'https',
hostname: 'images.unsplash.com',
},
],
},
}
export default nextConfig
You can check Core Web Vitals metrics in the Performance tab of Chrome Dev Tools as follows:
Happy coding!
Comments
Post a Comment