Skip to main content

Improving / Optimizing Performance

As we have discovered in the Analyzing Performance section, our web app for this topic may have some room for improvement when it comes to performance:

Lighthouse Report Detailed

Fortunately, Next.js has a few techniques that we can use to improve these numbers before doing a production build:

Using the <Image /> Component

The custom Image component included with Next.js is an alternative to the native <img /> element and offers a number of optimizations, including:

  • Improved Performance: Always serve correctly sized image for each device, using modern image formats
  • Visual Stability: Prevent Cumulative Layout Shift automatically
  • Faster Page Loads: Images are only loaded when they enter the viewport, with optional blur-up placeholders
  • Asset Flexibility: On-demand image resizing, even for images stored on remote servers

Let's update our app to use the Image component and look at some of its main features:

  1. Add the correct "import" statement for the Image component
import Image from 'next/image';
  1. Remove the current <img ... /> component and replace it with the following:
<Image
src="/theatre-bkrd.jpg"
alt="theatre background"
layout="responsive"
width={800}
height={232}
priority
/>
  1. Remove the (now unused) style import:
// import styles from '../styles/Home.module.css';

Notice that we have provided a number of additional properties to the "Image" element, specifically:

layout='responsive';
  • This indicates that the image should be scaled to fit the width of the container. In this case, this is used to replace the "headerImage" class, which was defined in "Home.module.css" to set the width to 100%.
width={800}
height={232}
  • These properties represent the original width in pixels, so it will only affect the aspect ratio. This is required when using either the "responsive" or "fill" options for layout.

    When using "intrinsic" or "fixed" values, the width and height properties represent the rendered height in pixels, so it will affect how large the image appears.

priority
  • When set, priority will mark the image as "priority" causing it to preload. Using "priority" in this case was recommended as this image was detected as the "Largest Contentful Paint (LCP)" element, as seen in the browser console:

    "Image with src "/theatre-bkrd.jpg" was detected as the Largest Contentful Paint (LCP). Please add the "priority" property if this image is above the fold"

If you inspect the image in the browser console, you should see that it now has a number of extra properties, including

  • style: This essentially enables the "responsive" layout as specified above.

  • sizes: This property provides information on how wide the image should be at different breakpoints. In this case, it is simply set to: "100vw", since we set the layout to "responsive".

  • srcset: This is the "source set", which identifies different urls for images to be served at different viewport widths (breakpoints). By default the following device sizes are used: 640, 750, 828, 1080, 1200, 1920, 2048, 3840.

    You can see how Next.js has associated each device size with a url based on our original url, ie: the 640 width is set to serve: "/_next/image?url=%2Ftheatre-bkrd.jpg&w=640&q=75", whereas the 750 width is set to serve: "_next/image?url=%2Ftheatre-bkrd.jpg&w=750&q=75". If you try opening each of these images, you will see that Next.js has correctly scaled them to match the widths.

    NOTE: Next.js will only scale images down in size (not up), therefore the image for the 2048 width: "_next/image?url=%2Ftheatre-bkrd.jpg&w=2048&q=75", simply renders our original image (800px x 232px).

    You will also notice that the source images have additional query parameter: "q". This represents the "quality" of the image, as Next.js will automatically optimize the original image to provide varying levels of quality. By default the quality setting is set to 75, however it can be changed using the quality property.

  • decoding: Next.js sets the decoding value to "async", which is done to "reduce delay in presenting other content".

Remote Images

If you attempt to use a remote image with the Image component, ie:

<Image
src="https://www.senecacollege.ca/content/dam/projects/seneca/campus-photos/magna-hall_tile.jpg"
width={600}
height={386}
/>

You will see the following error:

Error: Invalid src prop (https://www.senecacollege.ca/content/dam/projects/seneca/campus-photos/magna-hall_tile.jpg) on `next/image`, hostname "www.senecacollege.ca" is not configured under images in your `next.config.js`
See more info: https://nextjs.org/docs/messages/next-image-unconfigured-host

If we navigate to the link in "more info", we will see that the error occurred because the "src" value uses a hostname that isn't defined in "images.domains" in "next.config.js". This is done to ensure that only images from approved domains are able to use the Next.js image optimization API.

To solve this problem, open the "next.config.js" file, and update the nextConfig object to include an "images" property with a list of approved domains:

File: "next.config.js"

/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
images: {
domains: ['www.senecacollege.ca'],
},
};

module.exports = nextConfig;

Dynamically Importing Libraries

Next.js supports "Lazy Loading" for external libraries with "import" as well as images and pages. In larger apps, this can have an impact on metrics such as Largest Contentful Paint (LCP) and First Input Delay (FID) due to the smaller bundle size that is required on the first page load.

You will notice that our example includes the library "lodash" near the top of the index.js file as:

import _ from 'lodash';

However, the only lodash function that is used is within the "filterResults(data, searchText)" function:

function filterResults(data, searchText) {
setFilteredResults(
_.filter(data, (movie) => movie.title.toLowerCase().includes(searchText.toLowerCase()))
);
}

This function only gets invoked once the user starts typing in the "search" field. As such, we can potentially improve our performance by only loading the "lodash" library when it's required (ie: once the user starts typing).

NOTE: Technically, lodash is not required in this case, as the native "filter()" method would also work here. However, this example highlights the syntax for dynamic imports, so we'll keep it in.

To dynamically import "_" from lodash we first remove it from the top of the file:

// import _ from 'lodash';

and insert it in our "filterResults" function using "await" and "default", ie:

async function filterResults(data, searchText) {
const _ = (await import('lodash')).default;
setFilteredResults(
_.filter(data, (movie) => movie.title.toLowerCase().includes(searchText.toLowerCase()))
);
}

Notice how we updated our filterResults to use "async" - this was required as we must use "await" to wait for "lodash" to finish importing before we can use it in the "setFilterResults" function.

You can confirm that this is working if you open the "network" tab in the Developer Tools and refresh the app. You should see "node_modules_lodash_lodash_js.js" appear in the list once you start typing:

Lodash Dynamically Imported

Dynamically Importing Components

Components can also be dynamically imported to reduce the initial bundle size and improve your performance.

In our application we use a number of components to render the UI, primarily from 'react-bootstrap": "Container", "Row", "Col", "Card" and "Accordion". In addition to these, we also include a custom component: "StarRating" that is only visible once a user clicks on an accordion header to view the content (Rating and Plot Summary). Like our above "lodash" example, this is a perfect candidate for dynamic loading, as it is not visible / required until a user initiates the an action.

If we wish to dynamically load the "StarRating" component, we must update our code as follows:

  1. As before, remove the initial import:
// import StarRating from '../components/StarRating';
  1. Import both the "dynamic" function from 'next/js' and the "Suspense" component from 'react'
import dynamic from 'next/dynamic';
import { Suspense } from 'react';
  1. Import the "StarRating" component using the "dynamic" function (included above), making sure to set the ssr flag to false, and the suspense flag to true:
const StarRating = dynamic(() => import('../components/StarRating'), {
ssr: false,
suspense: true,
});
  1. Create a flag in the "state" to track when the accordion has been opened:
const [accordionOpened, setAccordionOpened] = useState(false);
  1. Add an "onSelect" event to the <Accordion className="mt-4">...</Accordion> element so that we can execute code once the user opens the accordion.
<Accordion className="mt-4" onSelect={accordionSelected}>
...
</Accordion>
  1. Write the "accordionSelected" function to set the "accordionOpened" flag to true (after 200 seconds, to ensure that the animation is completed)
function accordionSelected(eventKey, e) {
setTimeout(() => {
setAccordionOpened(true);
}, 200); // allow for the accordion animation to complete
}
  1. Ensure that the "StarRating" component is only shown once the "accordionOpened" flag has been set. Additionally use the React Suspense component to show a "Loading..." indicator while the component is loading:
<strong>Rating:</strong> {accordionOpened && <Suspense fallback={`Loading...`}><StarRating rating={movie.rating} /></Suspense>}

Once again, you can confirm that this is working if you open the "network" tab in the Developer Tools and refresh the app. You should see "components_StarRating_js.js" appear in the list once you open the first accordion section:

Component Dynamically Imported

Additionally, you should temporarily see the text "Loading..." in place of the star rating the first time this component is loaded.

Refactoring to use SSR

If at all possible, we would ideally like to pre-render as much of the page as we can. This can help reduce the time to first render and improve application performance. The home page for our practice "app" is a good candidate for SSR, since this is simply a static list of movies that isn't likely to change frequently. We have seen how this works when discussing Handling Events & Rendering Data, so let's take what we have learned there and apply it to our Film Collection app as a final optimization before going to production:

  1. Remove the import for useSWR as we will no longer need it:
//import useSWR from 'swr';
  1. Remove the 'fetcher' definition used by SWR:
// const fetcher = (...args) => fetch(...args).then((res) => res.json());
  1. Import the same "getMovieData()" function that your API uses to fetch the movie data. This will be used by "getStaticProps", since this function is also executed on the server.
import getMovieData from '../lib/movieData';
  1. Add a "getStaticProps" function above the "Home" component definition:
export function getStaticProps() {
const data = getMovieData();
return { props: { staticMovies: data } };
}
  1. Update the "Home" component function definition to accept "props" (specifically, the "staticMovies" prop)
export default function Home({staticMovies})
  1. Remove the "useSWR" function call (since we will no longer be needing it to obtain the data):
//const { data, error } = useSWR(`/api/movies`, fetcher);
  1. Update the "useState()" hook for "filteredResults" to use "staticMovies" as the default value:
const [filteredResults, setFilteredResults] = useState(staticMovies);
  1. Update our "useEffect()" hook to only watch for changes in "searchText" (since we no longer have "data" from SWR)
useEffect(() => {
if (searchText) filterResults(staticMovies, searchText);
}, [searchText]);

To confirm this is working, once again refresh the page. You can either view the Page Source directly to see all of the movie details in HTML, or view the "localhost" entry in the "network tab" of the Developer Tools. If you "Preview" the results, you will see an unstyled version of the page with the details for each movie visible.

Final Lighthouse Run

As a final check before our production build, let's re-run Lighthouse to confirm that our optimizations have helped to improve the Core Web Vitals of our app:

Final Lighthouse Run