Improve your Next.js website Core Web Vitals

In this post, we share how to optimize the performance of your Next.js website and improve your Core Web Vitals

·7 min read
Cover Image for Improve your Next.js website Core Web Vitals

Next.js has long been praised for its great performance, especially when used with Vercel's fast hosting.

Recently, though, many other frameworks have popped up, offering better performance thanks to the smaller payloads sent to the browser: among these are SvelteKit, Remix and SolidStart, which are effectively incredibly fast.

To get great performance and very high Core Web Vitals (to me, that means a score of at least 80) with a Next.js website, we need to apply some tweaks.

In this article, I want to share the tips and tricks to get your mobile Core Web Vitals above 80 on low-powered mobile devices, a score that most websites do not achieve.

NB: the score is calculated by PageSpeed.dev, the most realistic tool to test your Core Web Vitals. In fact, the browser's Lighthouse extension is usually more benevolent.

Next.js's Link component can detect and prefetch the links on the current page to increase the routing speed for your users: this is great for UX but can contribute to slower initial navigations due to the JSON files being downloaded by the framework.

In fact, when you navigate to a page with many links, you will notice from your network tab that your website is downloading many .json files: these files are the content of the pages linked, pre-fetched and enabling a quick navigation.

If you have excessive links on a page which is slowing down your page, you should consider disabling prefetching links.

To do so with the Next.js Link component, you should be able to pass a parameter and set it to false:

<Link href={`/link`} prefetch={false}>Link</Link>

NB: Next.js will still prefetch links when being hovered, but of course, this will not be an issue with GoogleBot.

Images and Media

Images and other media types can be performance killers: Javascript developers are often criticized for building huge bundles of one or more MBs, but we forget that sometimes a single image on a web page can be much larger than that!

Fortunately, we have a few ways to solve issues caused by large media files that destroy our website's Core Web Vitals.

Use the built-in Image Component

The Next.js component has been created to automatically optimize the application's images and the User Experience of the end users.

If you don't want to do anything and automatically optimize your images, then simply use the Image component exported from next/image or next/future/image (the new experimental version).

NB: Vercel's free plan allows up to 1000 free image optimizations per month; if you're going to exceed that, we recommend going the manual way.

Lazy-loading images

This Next.js Image component will also automatically lazy-load images without having to add the attribute loading="lazy".

With that said, lazy-loading is not always the best practice!

In fact, when the image is above the fold (i.e. within the viewport of the user's screen), even partly, lazy-loading will destroy your LCP metric (largest contentful paint).

In fact, anything that is above the fold should never be lazy-loaded, as it will increase the LCP metric (which is directly related to the user's UX). As you can imagine, users won't appreciate images popping up seconds after loading the content.

Convert images to webp

The webp format is highly supported and is the most recommended image format for converting your images. In fact, your images' size will drop 60-80% with negligible differences from the original.

To convert your images, you can use Squoosh as a web application or as an NPM package.

Convert GIFs and videos to mp4

If you have GIFs or other video types on your web page, consider converting them to mp4, which is a lighter format.

If you want to remove the video controls for a seamless visual integration, use the following attributes:

<video
  width={`100%`}
  height="auto"
  playsInline
  autoPlay
  muted
  loop
>
  <source src={src} />
</video>

Lazy Load videos with the LazyRender component

If your videos are below the fold, it makes sense to lazy load them using the LazyRender component that uses the Intersection Observer API.

The Makerkit SaaS Starter for Next.js uses the component below to lazy-load videos in our blog and documentation.

You can find more details about the LazyRender component in the Makerkit's documentation.

import { createRef, useEffect, useMemo, useState } from 'react';

const LazyRender: React.FCC<{
  threshold?: number;
  rootMargin?: string;
  onVisible?: () => void;
}> = ({ children, threshold, rootMargin, onVisible }) => {
  const ref = useMemo(() => createRef<HTMLDivElement>(), []);
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    if (!ref.current) {
      return;
    }

    const options = {
      rootMargin: rootMargin ?? '0px',
      threshold: threshold ?? 1,
    };

    const isIntersecting = (entry: IntersectionObserverEntry) =>
      entry.isIntersecting || entry.intersectionRatio > 0;

    const observer = new IntersectionObserver((entries, observer) => {
      entries.forEach((entry) => {
        if (isIntersecting(entry)) {
          setIsVisible(true);
          observer.disconnect();

          if (onVisible) {
            onVisible();
          }
        }
      });
    }, options);

    observer.observe(ref.current);

    return () => {
      observer.disconnect();
    };
  }, [threshold, rootMargin, ref, onVisible]);

  return <div ref={ref}>{isVisible ? children : null}</div>;
};

export default LazyRender;

By using the component above, we can lazy-load heavy videos with the following snippet, and in turn, increases the speed and the Core Web Vitals metrics of our website:

<LazyRender>
  <video
    width={`100%`}
    height="auto"
    playsInline
    autoPlay
    muted
    loop
  >
    <source src={src} />
  </video>
</LazyRender>

Use SSG for your static pages

If you are not rendering dynamic pages, we suggest using SSG (or Static Site Generation) for your Next.js website and static pages (such as your blog).

SSG is the process of pre-compiling your web pages directly to HTML files on your favorite hosting provider, rather than rendering the pages at runtime on the server. It's clear that downloading a static HTML page will be faster than rendering it on the server, especially considering cold starts, which can occur anytime.

Not only this will make your web pages faster, but it will also allow you to save money on server runtime seconds.

If you want to know more about it, we wrote an article to understand when to use SSR and when to use SSG.

Add fonts to _document.tsx

Next.js can automatically optimize the loading of Google Fonts; to let Next.js take care of it, remember to add your fonts to _document.tsx within the Head component.

Below is an example of this website's _document.tsx:

import Document, { Html, Head, Main, NextScript } from 'next/document';

export default class MyDocument extends Document {
  render() {
    return (
      <Html className="dark">
        <Head>
          <link
            href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;700&family=Inter:wght@300;400;500;700;800&display=swap"
            rel="stylesheet"
          />
        </Head>

        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}

Lazy-Load Components using Next.js Dynamic Imports

Next.js offers one of the simplest ways to dynamically import components and therefore only load what is needed, thus decreasing the initial Javascript bundle's size of your website.

Thanks to the next/dynamic package, we can seamlessly load components we want to render dynamically:

import dynamic from `next/dynamic`;

const VeryHeavyComponent = dynamic(() => import('./VeryHeavyComponent'));

function Page() {
  const displayComponent 
    = shouldDisplayVeryHeavyComponent();

  return (
    <div>
      {
        displayComponent ? <VeryHeavyComponent /> : null
      }
    </div>
  );
}

Deferring the loading of VeryHeavyComponent will decrease the size of your initial bundle because the component will not be included.

Using Dynamic Components in combination with React Suspense

From the Next.js documentation: when used in combination with React Suspense, components can delay hydration until the Suspense boundary is resolved.

The example below comes from the Next.js documentation:

import dynamic from 'next/dynamic'
import { Suspense } from 'react'

const DynamicHeader = dynamic(() => import('../components/header'), {
  suspense: true,
})

export default function Home() {
  return (
    <Suspense fallback={`Loading...`}>
      <DynamicHeader />
    </Suspense>
  )
}

This can be ideal when rendering heavy components, not just in terms of size, but also in terms of runtime execution; for example, loading iframe components that render other websites or third-party embedded views (Youtube, CodeSandbox, Stackblitz, and so on...).

Conclusion

If you follow the steps above, getting a good score (80+ on mobile) for your Next.js is totally feasible.

There will be roadblocks you cannot avoid (for example, using Google Analytics, which is heavy) that will inevitably reduce your score, but remember that what's most important is the UX of your website and how users perceive it, not only a score from a website.

When it comes to real-world results, Next.js websites will easily beat most other frameworks out there (but yes, not all).

Our Results

  • At the time of writing, this website's landing page score on mobile is 94, and 100 on desktop
  • Instead, this page's score is 73 on mobile and 100 on desktop. This is probably due to loading Firebase on the page (which can be avoided as we do not use any authentication). We want (and will) do better!

Stay informed with our latest resources for building a SaaS

Subscribe to our newsletter to receive updatesor

Read more about

Cover Image for Next.js 13: complete guide to Server Components and the App Directory

Next.js 13: complete guide to Server Components and the App Directory

·11 min read
A tutorial on how to use Next.js 13 with server components and the app directory.
Cover Image for Pagination with React.js and Supabase

Pagination with React.js and Supabase

·6 min read
In this article, we learn how to paginate data with Supabase and React.js
Cover Image for How to sell code with Lemon Squeezy and Github

How to sell code with Lemon Squeezy and Github

·7 min read
Sell and monetize your code by giving private access to your Github repositories using Lemon Squeezy
Cover Image for Writing clean React

Writing clean React

·9 min read
Learn how to write clean React code using Typescript with this guide.
Cover Image for How to use MeiliSearch with React

How to use MeiliSearch with React

·12 min read
Learn how to use MeiliSearch in your React application with this guide. We will use Meiliseach to add a search engine for our blog posts
Cover Image for Setting environment variables in Remix

Setting environment variables in Remix

·3 min read
Learn how to set environment variables in Remix and how to ensure that they are available in the client-side code.