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

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.

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:

_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!