- Lab
- Core Tech

Guided: Optimizing an E-commerce Product Page in Next.js 14
As the holiday season approaches, the pressure mounts to ensure your product pages are performing at their peak. This lab guides you through the process of optimizing a Next.js E-commerce product page, starting with a simple client-side rendered page, and advancing to sophisticated server-side rendering and React Server Components (RSC) strategies. You will learn how to take advantage of server-side data fetching, optimize images, prefetch links, prioritize critical data, and implement lazy loading for less crucial content. Each step is focused on a specific aspect of the product page and is designed to directly improve your page’s Lighthouse score, demonstrating how each optimization contributes to increased performance and SEO scores.

Path Info
Table of Contents
-
Challenge
Introduction
In this Guided Code Lab, you will optimize a Next.js e-commerce product page. You'll learn specific Next.js optimization techniques on both the client and the server that power different parts of the page to make the experience even better for the end-user.
What to Expect
You should have foundational experience with React and Next.js and have built a full application before.
The demo uses Next.js 14, app router, and JavaScript written in idiomatic React, which is typically ES2015+ syntax. You should be able to work with async/await, arrow functions, Promises, ES modules, and let/const.
info> Stuck on a task? Check out the
solutions
folder in the project directory for full solution files and reference code for each step. Files begin with a number corresponding with the task order in the step, followed by a directory path structure using~
. For example,step-2/01-app~page.jsx
corresponds to Step 2, Task 1 for the module located atapp/page.jsx
.Getting Started
To make sure the lab environment is set up and working, you'll start by running the Pluralsight Commerce demo application. Once set up, you can visit {{localhost:3000}} to view the app in your browser, or in the Web Browser tab in the editor to the right.
info> Web Browser not working? Sometimes while reading through the lab, the dev server will shut down and you'll have to reconnect to the Terminal. Just run
npm run dev
again to start the server back up. Other times, the Web Browser may not always reflect the latest code due to the auto-save feature of the lab. Just click the Refresh button next to the address bar in the tab to force-refresh the page.Pluralsight Commerce Demo
The demo app is displaying a mock product page with product images, data, and related products. It also supports routing between three different products, plus clicking the cart displays a modal drawer.
As you play around with the interface, it might feel... slow. This is on purpose! Normally an e-commerce site fetches product data from an API, but in this lab, the API is mocked and is artificially slowed down to showcase potential performance bottlenecks.
Web Vitals
Web Vitals were introduced by Google to measure the "real user experience" of web applications. These metrics are a way to better understand how users will experience your app. Google developed the Lighthouse auditing tool to help you measure and create actionable reports.
info> If you want to run Lighthouse yourself, in Chrome you can open up the
F12
Developer Tools and find the Lighthouse tab. Screenshots of the audit are provided throughout the lab so you don't have to run it yourself.A Lighthouse audit on the demo app in its initial state displays a 43 out of 100 on its Performance score:
Even though this is not great, it's not terrible either. That's because Next.js tries to provide good out-of-the-box performance without you having to do anything.
It could be much better though. As you work through the lab, you'll make improvements to each part of the page and ultimately get that Performance score to a perfect 100.
info> This lab will mostly focus on the mobile Lighthouse performance, as that will simulate slower and more device-constrained performance. If you can make the mobile experience better, you will almost certainly make the desktop experience better. Note that the lab scores shown may vary from your own machine as Lighthouse runs locally.
-
Challenge
Optimizing Product Data Fetching
One of the first things you'll notice when trying to load the demo is that there is a loading message before any data on the page can be displayed.
Most e-commerce sites need to fetch product data from an API. In a traditional React application, you would need to fetch this data on the client-side, which is what this demo is doing by default. Client-side data fetching makes your code much more complex as you need to deal with caching, invalidation, and state management. It also makes the user experience worse by having to display intermediate loading states.
In some cases, client-side fetching can make sense, but for an e-commerce product page, it's critical to optimize the initial page load as it directly impacts revenue. Fetching the product data on the server and including it in the initial rendered HTML will eliminate excessive loading and layout shifts that are hurting your performance metrics.
React Server Components
In Next.js 14, React components run on the server by default. Within a React Server Component (RSC), you can use
async/await
safely and the data will be fetched on the server-side:export default async function ProductPage({ params }) { const product = await getProduct(params.handle); // ... the rest of the component }
In addition, React Server Components are automatically optimized for loading on the client-side.
Using RSC for the product page will simplify the code and make the user experience much better, improving your Lighthouse score. Refactoring the
ProductPage
simplifies the code and removes the need foruseState
anduseEffect
. However, Next.js is now displaying this error message:You're importing a component that needs useEffect. It only works in a Client Component but none of its parents are marked with "use client", so they're Server Components by default.
When you mark a module as
"use client";
it applies to all the components in the module. Since theProductPage
component includes other components likeRelatedProducts
andProductReviews
in the module, each one needs to be converted to server components by removing effect and state hooks. The demo now loads the page; however, there is an error:Error: Hydration failed because the initial UI does not match what was rendered on the server.
This error occurs when React cannot "reconcile" the initial client-side HTML with the server HTML. It means there is likely a client-side component that is returning different HTML on mount.
If you expand your viewport so you can see the entire loading experience, refresh the page, and you may notice there is a slight delay before the footer pops in.
Rendering Client Components from Server Components
The
Footer
component is currently a client component, so it is loaded separately from theProductPage
on the server.A server component may render a client component; however, it needs to be separated into its own
"use client";
module. This allows you to compose server and client components together for optimal performance.Usually client components are components that need to use state, effects, or manipulate the UI. If they only require data fetching, they can be safely made into server components.
In this case, the hydration error is caused by the
Footer
component referencingprocess.env
variables that are only defined on the server, so the rendered copyright HTML at runtime differs from what's pre-rendered on the server. You could adjust the client-side build to inject these variables to fix the hydration error but since theFooter
only needs server data, it's a better option to convert it to an async server component and avoid the extra hassle! Now that all the components in the product page hierarchy are server components, you will notice how the page loads with all the data shown at once on the initial load.Async SEO Metadata
There's another small change you can make to enhance the SEO of the product page now that you are leveraging RSC. Currently, Lighthouse is reporting a reduced SEO score due to missing metadata, which will hurt its search ranking:
Next.js supports data fetching in an exported
generateMetadata
function, which you cannot do using client components.Next.js will use the structured metadata returned from the
generateMetadata
function and apply it to the page<head>
. The page should now display a title in the Web Browser tab, as well as, metadata for search engines to index.How did these changes impact the Lighthouse score?
Performance seems to be about the same or even worse! The report details show that the Speed Index has increased dramatically:
Sure, you have a perfect SEO score now, but why didn't this improve your performance score?
-
Challenge
Optimizing Reviews
When you converted the
ProductReviews
component to a server component, you moved the data fetching to happen during the server request.The time for the initial request to the server to render the page is also called the "Time to First Byte" and the Lighthouse audit reports calls this out explicitly as a problem:
Since reviews are not included in the
getProduct
API call, they need to be loaded separately with thegetProductReviews
call. Unfortunately, fetching reviews takes much longer than product data (about 4 seconds).You could switch the
ProductReviews
back to a client component, but there is a better option using React Server Components that maintains the simplerasync/await
code.React Suspense
React Suspense is a way to suspend a portion of the rendering until a component is ready to render. Next.js uses Suspense to support "streaming" rendering to the browser so that the user receives the page faster without having to wait for slower components to render.
When you wrap components in a
Suspense
boundary, it signals to Next.js to defer rendering that component until its ready to render, effectively waiting on the data fetching promise.import { Suspense } from 'react'; import SlowComponent from './slow'; function Page() { return ( <p>Render this immediately</p> <Suspense fallback={<p>Still waiting to render...</p>}> <SlowComponent /> </Suspense> ) }
This example will immediately display the "Render this immediately" message and then after a delay, the
SlowComponent
will be rendered.The result will be an experience that feels like the fetching is happening on the client, but in reality Next.js is injecting the server-side response once it's ready. Notice how the page loads almost instantly, but now there's a loading message for the reviews while it waits for the fetch to be done.
This all happens seamlessly without any extra state management, which makes Suspense a powerful optimization technique with Next.js.
How does Suspense know when to wait?
Suspense relies on Promises to know when a component needs to be deferred for later rendering. Since React Server Components allow you to use
async/await
at the top-level of the function, Suspense can detect when a component is returning a Promise and will render it separately while continuing on with the rest of the render work. It's like when you cook a big meal, dessert can be served last after the main course.The Lighthouse score is much improved with a reduced Speed Index score, but there's still an issue with that pesky Largest Contentful Paint metric.
-
Challenge
Optimizing the Product Image Gallery
One of the first things you see as an end-user when loading a product page is the imagery. In fact, it's also one of the first things the browser sees too.
Whatever the largest thing shown in the viewport on load is what is referred to as the largest "contentful" element.
Largest Contentful Paint (LCP)
For an E-commerce product page, the product images will usually be the cause of increased Largest Contentful Paint (LCP) timing. This web vital measures the time it took for the browser to paint the largest element in the viewport.
The current LCP of the demo app is very high, over 20 seconds:
The report will tell you exactly what element it detected as the largest contentful element along with the loading details:
Unsurprisingly, it's the main gallery image.
The Lighthouse report is also suggesting the following:
Serve images in next-gen formats -- Potential savings of 6,022 KiB
Properly size images -- Potential savings of 6,645 KiB
Yikes! Over 6MB of potential savings! Massive images take a long time to load, especially on mobile devices.
You will need to optimize these images so they are smaller and more responsive to the device.
However, for a typical e-commerce site, images usually are uploaded by a content management team and you as the engineer may not have a lot of control over how optimized the source image is.
The Next.js
Image
ComponentNext.js provides a built-in image optimization component from the
next/image
module.It is used similarly to the
<img>
HTML element, but supports more optimization-specific props:import Image from "next/image"; const ProductImage = ({ product }) => { return ( <Image src={product.image.src} alt={product.image.altText} /> ); };
It can effectively take an original raw image source and then compress it automatically so it is optimized for the desired screen size(s) using the WebP image format. It supports both local and remote images.
Powered by the
srcset
attributeBehind the scenes, Next.js is using the native
img
HTML element'ssrcset
attribute.srcset
accepts multiple "source to size" mappings of image URLs to screen-size combinations (powered by media query syntax).Using the raw
srcset
attribute would mean you need to manually create and upload compressed variations of the original photo for different sizes so what Next.js does is handle compression and optimization at build-time, even for remote images. You can customize this behavior using theloader
prop.Gallery
component, the main product image should now be usingnext/image
.There's still an error though:
Error: Image with src "/images/t-shirt-1.png" is missing required "width" property.
Previously the
<img>
element didn't specify a size, but the Next image component requires a size to be specified.Cumulative Layout Shift
Another Core Web Vital metric is Cumulative Layout Shift (CLS), which measures how stable the layout is over time. Images are one of the biggest contributors to poor layout shift because if not explicitly handled, their dimensions are only known once they load, which can end up "shifting" elements down the page.
The Lighthouse report called this out in the initial report:
Dynamic Image Sizing
The Next
Image
component requires you to provide sizing information. If you cannot provide awidth
andheight
explicitly, you can use thefill
prop along with thesizes
prop to have the image fill its container. Thesizes
prop accepts a list of CSS media queries that Next.js will use to determine the different sizes:<Image src="..." alt="..." fill sizes="(min-width: 1024px) 66vw, 100vw" />
In this example, by default the image will fill the screen (100% viewport width) on mobile, but after the screen width exceeds 1024 pixels, the image will be sized to 66% of the viewport width.
Implementing the
fill
andsizes
props will mean you need to provide extra styling in CSS to handle the different layouts.The demo uses TailwindCSS, a popular CSS utility library. The gallery image will use the
aspect-square
utility class, which will maintain a square aspect ratio on the container element of the gallery image. This should work because all the product images are normalized to be square. In addition, you will limit the max height of the container so large images don't look awkward. Now the product page loads and if you inspect the main image element, you'll see different sizes as you modify the width of the viewport (opening the demo in a new tab makes this easier).Prioritize LCP Images
Since the Lighthouse audit identified the main product image as the LCP element, one additional optimization you can do is to add the
priority
prop to theImage
component to tell Next.js to prioritize loading the image to enhance the LCP metric. This should only be applied to a single image on the page. Re-running the Lighthouse report with these optimizations results in a surprising boost to the score:As you can see, LCP greatly affects the overall Lighthouse score.
My score is way different!
Lighthouse scores can vary wildly between different environments and even between test runs, so if you're following along, you may see different scores. This is one of the major downsides of relying on simulated testing. For example, if your CPU is under load while testing or your laptop battery is low, your Total Blocking Time (TBT) will be higher, lowering your score.
However, you aren't done yet as there's still warnings related to image sizes:
-
Challenge
Optimizing Related Products
In addition to the main product image you optimized, there are other images displayed on the page affecting performance.
For example, the
RelatedProducts
component is lower down on the page and uses a grid to display images.In the Google Lighthouse report, there is a suggestion to take the following action:
Defer offscreen images -- Potential savings of 2,783 KiB
When the browser loads the page, the related products are "off-screen" meaning the end-user can't see them.
Deferring Off-screen Images
One of the automatic optimizations you'll get with the Next.js
Image
component is lazy loading, where images won't be loaded until they are visible within the viewport.By converting the
GridTileImage
component to use theImage
component, not only will you address the compression and sizing issues, you'll also defer off-screen images. Now that all of the product images on the page are usingnext/image
, how is your Lighthouse score doing?A perfect 100 score? Now that's impressive.
While a perfect score is admirable, the Lighthouse audit is only measuring page load. It is not yet measuring navigation between pages, nor user interactions on the page. It's time to see what else you can optimize beyond the initial page load.
-
Challenge
Optimizing Page Navigation
With the Lighthouse Node.js API, you can measure multi-page flows. In this case, you want to measure not only the initial page load, but navigating to another page to see if there are any performance issues.
In this multi-page navigation audit against the lab so far, Lighthouse calls out the slow initial server response when navigating to the next product page:
You can test this flow yourself by clicking on a product in the Related Products carousel.
Hard vs. Soft Navigations
The second product page takes an additional 2 seconds to respond. This is because currently the browser is performing a "hard page navigation", which is when the browser needs to perform a full GET request lifecycle where the server renders an entirely new HTML document.
It would be ideal to only hit the server once initially and then subsequent navigations would be handled on the client-side, sometimes called a "soft navigation."
This is possible in Next.js using the
Link
component.The Next.js
Link
ComponentThe Next
Link
component can be used to optimize page navigations.- It supports a "soft" navigation, which doesn't require the browser to perform a full page request lifecycle.
- It automatically prefetches the route in production when the
Link
is visible on-screen.
/* before */ <a href={`/product/${product.handle}`} /> /* after */ import Link from 'next/link'; <Link href={`/product/${product.handle}`} />
Currently, the
RelatedProducts
component is using a traditional HTML anchor tag, which doesn't provide any built-in optimizations, but it can safely be swapped out for the NextLink
component. In order to experience the full benefit of theLink
component, the app needs to be served in production mode. After compiling and running the server, a multi-page audit no longer calls out the initial page load when navigating to the second product:info> Since the browser no longer hits the server to GET the
/product/product-2
page, the Lighthouse flow needs to use a "Timespan" audit, which doesn't collect initial page load vitals like FCP and LCP.The warning about initial page load time is gone and this can further be confirmed by observing a network request that prefetches the
/products/product-2
route, which is happening due to theLink
component.info> The Next.js router attaches a
Next-Router-Prefetch
HTTP header to the request with a value of1
, which you can use to identify prefetches. -
Challenge
Optimizing the Cart Modal
For an e-commerce site, a critical user interaction is with the cart.
When you add a product to the cart, a modal drawer slides out and displays your basket (the data is mocked in the demo):
The Cart modal (and other modals) are powered by third-party packages that can increase your bundle size. These "vendor" scripts can slowly add up over time, making your initial JavaScript footprint very large and it's important you audit them regularly.
Optimizing Scripts
Next.js provides optimized "chunking" of JavaScript out-of-the-box. But there may be times when you want to take more control over what gets delivered to the browser on the initial page load.
The
@next/bundle-analyzer
tool will generate reports of the bundles and chunks expected for your app during a production build. This is installed in the lab environment, but needs to be manually added to thenext.config.js
file. Now you can generate a bundle analysis for the production build, which will show up under a newanalyze
folder in the lab environment. Running the script should output stats like this:The First Load JS refers to the initial payload that will be cached during the first page load.
Diagnosing Your JavaScript Footprint
The output does not include other chunks and scripts that may be loaded during a full page load. For that information, you will need to refer to both the bundle analysis reports and observe the traffic in the browser's developer tools Network tab.
If you want to follow along, launch the production server. A set of analysis HTML files should have been created in the
analyze
directory in the lab environment. Find theanalyze/client.html
, then click the ... button and click Download file.Open it and you should see a bundle map visualization like this:
The highlighted portion is referred to as a "vendor chunk" and it contains two third-party packages,
@headlessui/react
andsonner
.Now open the {{localhost:3000}} URL in a new tab and press F12 to open the Developer Tools. In the Network tab, refresh the page and you will see all the initial JavaScript that is loaded.
According to both the network and the bundle analysis, the highlighted vendor chunk is loaded on the client during the first page load. It represents about 22KB of the gzipped bundle size.
Overall, the whole JS footprint to load the page is about 136KB. However, the only code that uses
@headlessui/react
are the two modal dialogs in the app, the cart drawer and the mobile menu.info> It's important to note that the
next build
output's First Load JS is confusingly named. As you can see, the page load includes more chunks and scripts than the output lists. The First Load JS is the base JS shared by every route. The modal dialogs are not shown on the_not-found
route, so these vendor chunks are not included in the output. This is why it's important to rely on the bundle analysis report and network observation to understand the true footprint of your application.Lazy Loading Components
One potential optimization is lazily loading large third-party packages like this to save on the initial page load JS. This reduces the initial JavaScript parsing time and saves on some bandwidth.
Similar to images and routes, you can lazy load JavaScript modules and React components at runtime, which can reduce unused JavaScript.
Next.js supports the dynamic
import
function to break your code into "chunks" and load them dynamically. However, for this demo you will usenext/dynamic
, which usesimport
to support lazy loading React components:# Before: import CartModal from './modal'; # After: import dynamic from 'next/dynamic'; const DynamicCartModal = dynamic( () => import('./modal'), { ssr: false } );
CartModal
is the default export from thecomponents/cart/modal.jsx
module and is a React component.next/dynamic
will return its own wrapped React component that handles the internal module loading behavior of usingimport
.info> Only client components can be lazy-loaded from server components. Server components are automatically code-split and optimized for server-side execution. Passing the
ssr: false
option todynamic
ensures the modal (which is a client component) is not pre-rendered on the server. You can now measure the impact of this change to the bundle and chunking behavior by re-running the bundle analysis and refreshing the app in the browser to see the new Network traffic behavior.In the bundle analysis, the
@headlessui/react
has been pulled into a new 15KB vendor chunk. Additionally, usingnext/dynamic
has made themodal.jsx
modules be code split into separate client component chunks too.On the initial page load, only 120KB is transferred initially. Try clicking the cart icon in the top-right, and two new chunks are downloaded and the modal pops open.
This is lazy loading in action with conditional rendering.
Performance Trade-offs
If you are following along, you might have seen your Lighthouse performance score degrade with this change.
Plus, the modal drawer now simply appears instead of sliding out smoothly like a drawer.
These are symptoms of injecting the rendered React component on-demand, which uses up more client CPU time. You saved 18KB of JS, but made the UX slightly worse.
This is a trade-off you may decide is worth it, you might decide to refactor how modals work, or just maintain what you had before despite the larger initial footprint. Performance optimization is full of trade-off decisions like this. For an e-commerce site especially, it depends on how optimizations impact bounce rate and checkout conversion.
The important part is that you now know how to measure after you make changes so you can make data-driven decisions.
-
Challenge
Recap and Next Steps
Congratulations, after all the optimizations you implemented, the demo app's Lighthouse score is a perfect 100 and navigations between pages are smooth as silk:
This is very impressive considering you started at 43. Even though this is a simulated lab environment, you can use these exact same Next.js optimization techniques for your own e-commerce site to achieve a similar improvement.
Here's everything you just learned:
- Web Vitals are used for reflecting "real user experience" and can be measured using the Google Lighthouse tool.
- React Server Components and Suspense help you greatly improve the initial page loading experience of your e-commerce product pages.
- The Next.js
Image
component provides automatic compression and sizing for image optimization, which can improve your LCP and CLS web vital metrics. - LCP and CLS are the biggest contributors to your Lighthouse performance score.
- The Next.js
Link
component automatically prefetches routes and allows for "soft" navigations, creating a smoother page navigation experience. - Lazy loading with
next/dynamic
andimport
is not always a one-size-fits-all technique. It has trade-offs you need to carefully consider and measure after implementing. - The Next.js Bundle Analyzer is a useful tool to understand your app's footprint, but it also needs to be paired with real-world network traffic observation.
Next Steps
If you are interested in diving deeper into performance topics and Next.js, check out these Pluralsight courses and guided labs:
- Next.js 14: Foundations Course
- Guided: Optimization in React Lab
- React Performance Playbook Course
- React Debugging Playbook Course
Links
What's a lab?
Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.
Provided environment for hands-on practice
We will provide the credentials and environment necessary for you to practice right within your browser.
Guided walkthrough
Follow along with the author’s guided walkthrough and build something new in your provided environment!
Did you know?
On average, you retain 75% more of your learning if you get time for practice.