Optimizing CLS Score in NextJS

Introduction

In previous articles, I provided guidance on Core Web Vitals and how to optimize metrics such as LCP and INP. Now, we will continue to explore the final metric, which is CLS (Cumulative Layout Shift). Receiving data from multiple API requests at different times can easily cause sudden layout changes. Here are the solutions to address this:

  • Explicit Dimensions: This involves declaring exact width and height for elements (especially images, videos and iframes) in HTML or CSS before they are downloaded.
    • This helps the browser know the element size to build the layout beforehand. Without this, the browser will default to treating the element as 0px X 0px before assets are loaded.
    • Once assets finish loading, they will suddenly expand in size, pushing surrounding content to different positions and creating Layout Shift errors.
  • Aspect Ratio: Instead of setting hard pixels, using Aspect Ratio is a modern solution to solve Explicit Dimensions by defining the ratio between the width and height of an element.
    • When used, you only need to provide one size dimension like width or height and the browser can calculate the remaining value based on that ratio.
    • The browser will use this value to reserve space. Once assets are fully loaded, they are simply placed here, ensuring the layout does not change.
  • Skeleton Screens: Instead of displaying a single loading spinner, use Skeletons with dimensions that exactly match the content after loading. This reserves space for the content and eliminates CLS.

  • Default State: Define the initial state for data variables so that the layout structure is formed from the beginning.

Detail

Create file app/cls/types.ts

export interface BannerData {
  id: string
  imageUrl: string
  title: string
}

export interface ProductData {
  id: number
  title: string
  price: number
  thumbnail: string
}

export interface DashboardData {
  banner: BannerData | null
  products: ProductData[]
}

Create file app/cls/loading.tsx

'use client'

import {Skeleton} from 'antd'

export default function DashboardLoading() {
  return (
    <div className="p-8 max-w-6xl mx-auto space-y-10 bg-gray-50 min-h-screen">
      <div className="space-y-4">
        <Skeleton.Input active size="large" style={{width: 300}} />
        <Skeleton.Input active size="small" style={{width: 500}} />
      </div>

      <div className="relative w-full aspect-[21/9] md:aspect-[21/7] rounded-3xl overflow-hidden shadow-sm bg-white">
        <Skeleton.Button active block className="!h-full" />
      </div>

      <div className="space-y-6">
        <Skeleton.Input active size="large" style={{width: 200}} />

        <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
          {Array.from({length: 4}).map((_, i) => (
            <div
              key={i}
              className="flex flex-col space-y-4 p-4 bg-white rounded-2xl shadow-sm"
            >
              <div className="relative w-full aspect-square rounded-xl overflow-hidden">
                <Skeleton.Button active block className="!h-full" />
              </div>

              <div className="space-y-3">
                <Skeleton.Input
                  active
                  block
                  style={{width: '90%'}}
                  size="small"
                />
                <Skeleton.Input
                  active
                  block
                  style={{width: '50%'}}
                  size="small"
                />
              </div>
            </div>
          ))}
        </div>
      </div>
    </div>
  )
}
  • Unlike other metrics, the content in this loading file is the main factor that helps avoid frequent layout changes.
  • You can see that I defined Skeletons for components that will show on the UI such as title, banner, section text and product list. This will be the display part before actual content is returned.
  • Explicit Dimensions & Aspect Ratio: aspect-square, aspect-[21/9] and md:aspect-[21/7] are classes to determine the ratio of internal content before assets finish loading for display.

Create file app/cls/Dashboard.tsx

'use client'

import {Badge, Typography} from 'antd'
import Image from 'next/image'
import type {DashboardData} from './types'

const {Title, Text} = Typography

interface Props {
  initialData: DashboardData
}

export default function Dashboard({initialData}: Props) {
  const {banner, products} = initialData

  return (
    <div className="space-y-12">
      {banner && (
        <section className="relative w-full aspect-[21/9] md:aspect-[21/7] overflow-hidden rounded-3xl shadow-xl border border-white">
          <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-r from-black/60 to-transparent flex items-center p-12">
            <Title className="!text-white !m-0 !text-5xl font-black max-w-2xl drop-shadow-md">
              {banner.title}
            </Title>
          </div>
        </section>
      )}

      <section className="space-y-6">
        <Title level={3}>Featured Products</Title>

        <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
          {products.map(product => (
            <div key={product.id} className="group flex flex-col space-y-4">
              <div className="relative w-full aspect-square bg-white rounded-2xl overflow-hidden shadow-sm border border-gray-100">
                <Image
                  src={product.thumbnail}
                  alt={product.title}
                  fill
                  sizes="(max-width: 768px) 100vw, 25vw"
                  className="object-contain p-4 group-hover:scale-105 transition-transform duration-300"
                />
              </div>

              <div className="px-1">
                <Text className="font-bold text-lg block truncate text-gray-800">
                  {product.title}
                </Text>
                <Badge
                  status="success"
                  text={
                    <span className="font-extrabold text-green-600 text-base">
                      ${product.price}
                    </span>
                  }
                />
              </div>
            </div>
          ))}
        </div>
      </section>
    </div>
  )
}

In here, you only need to reuse the classes as defined in the loading.tsx file like aspect-square, aspect-[21/9] and md:aspect-[21/7] to ensure the banner and product images will show in the correct ratio after loading.

Create file app/cls/page.tsx to simulate returned data for the Dashboard component, so I will not explain much.

import Dashboard from './Dashboard'
import type {DashboardData} from './types'

async function getDashboardData(): Promise<DashboardData> {
  await new Promise(resolve => setTimeout(resolve, 2500))

  const res = await fetch('https://dummyjson.com/products?limit=8', {
    next: {revalidate: 60},
  })
  if (res.ok) {
    const data = await res.json()
    return {
      banner: {
        id: 'b1',
        imageUrl:
          'https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe',
        title: 'Banner Image',
      },
      products: data.products,
    }
  }
  return {banner: null, products: []}
}

export default async function OptimizedCLSPage() {
  const data = await getDashboardData()

  return (
    <main className="p-8 max-w-6xl mx-auto space-y-10 bg-gray-50 min-h-screen">
      <div className="space-y-2">
        <h1 className="text-3xl font-black text-gray-900">CLS Dashboard</h1>
        <p className="text-gray-500">Description</p>
      </div>
      <Dashboard initialData={data} />
    </main>
  )
}


The result will be as follows.



Happy coding!

See more articles here.

Comments

Popular posts from this blog

All Practice Series

Kubernetes Deployment for Zero Downtime

Deploying a NodeJS Server on Google Kubernetes Engine

Setting up Kubernetes Dashboard with Kind

Sitemap

React Practice Series

Monitoring with cAdvisor, Prometheus and Grafana on Docker

DevOps Practice Series

A Handy Guide to Using Dynamic Import in JavaScript

Using Kafka with Docker and NodeJS