Ways to Optimise & Improve the Performance of Next.js Website: A Deep Dive

Ways to Optimise & Improve the Performance of Next.js Website: A Deep Dive

In the fast-paced world of web development, delivering a high-performance website is crucial for user satisfaction and search engine rankings. If you’ve built your website using Next.js, a React framework, there are several optimisation techniques you can employ to ensure a seamless user experience. In this guide, we’ll explore key strategies to optimise Next.js website performance.

Understanding Code Splitting in Next.js

Code splitting is a technique that involves breaking down your JavaScript code into smaller, more manageable chunks or modules. Instead of loading the entire JavaScript bundle at once, code splitting allows you to load only the code that is necessary for the current page or user interaction.

This helps in optimizing the initial page load and improving the overall performance of your web application.

Automatic Code Splitting in Next.js: Next.js comes with built-in support for automatic code splitting. This means that when you build your Next.js application, the framework automatically analyzes your project and creates separate bundles for different pages. Each page gets its own JavaScript bundle, and only the code necessary for that specific page is included in the bundle. This is especially beneficial for large applications with multiple pages, as it ensures that users only download the code they need when navigating through the site.

Utilizing Dynamic Imports

Dynamic Imports: Dynamic imports in JavaScript allow you to load modules or components asynchronously at runtime. In the context of Next.js, dynamic imports are commonly used for code splitting.

Instead of importing a module or component directly at the top of your file, you import it using the import() function, which returns a Promise. This allows the module to be loaded on-demand, improving the efficiency of your application.

Example of Dynamic Import in Next.js:

JavaScript
// Regular import
// import MyComponent from './MyComponent';

// Dynamic import
const MyComponent = dynamic(() => import('./MyComponent'));

In this example, dynamic() is a utility provided by Next.js that wraps around the import() function. It tells Next.js that the import is dynamic and should be code-split.

Fine-Grained Control for Performance

By using dynamic imports strategically, you gain fine-grained control over when and how different parts of your application are loaded. This is particularly useful for components or libraries that are not needed during the initial page load.

For example, a complex charting library or a large form validation library might only be necessary when a user navigates to a specific page. thus resulting in faster page loading time, as users only download the essential code upfront. Additional code is loaded on-demand as users navigate through the application.

Practical Scenario: Loading a Component Only When Needed

Consider a scenario where you have a page that includes a complex and resource-intensive component, such as a data visualization chart. Instead of loading the chart component when the page loads, you can dynamically import it, ensuring that it’s only loaded when the user navigates to that specific page:

JavaScript
// Dynamic import for a chart component
const ChartComponent = dynamic(() => import('./ChartComponent'));

function MyPage() {
  // Other page content

  // Load the chart component only when needed
  const renderChart = async () => {
    const chartModule = await import('./ChartComponent');
    const ChartComponent = chartModule.default;
    return <ChartComponent />;
  };

  // Button or link to trigger the chart component loading
  return (
    <div>
      {/* Other page content */}
      <button onClick={renderChart}>Load Chart</button>
    </div>
  );
}

In this example, the ChartComponent is only loaded when the user clicks the “Load Chart” button. This ensures that users who don’t interact with the chart feature don’t need to download the associated code during the initial page load.

Benefits of Code Splitting and Dynamic Imports in Next.js

  1. Faster Initial Page Load: By loading only the necessary code for the current page, you reduce the initial payload, resulting in faster page load times.
  2. Improved User Experience: Users can start interacting with the main content of the page more quickly, even if some features or components are loaded asynchronously.
  3. Efficient Resource Usage: Resources are loaded on-demand, preventing unnecessary downloads and conserving bandwidth.
  4. Optimized for Scalability: As your application grows, automatic code splitting allows you to maintain a scalable and performant architecture.

How to Optimize Images with next/image in Next.js?

Images play a crucial role in web performance, and to optimise Next.js website, that is a key step in ensuring a fast and efficient user experience. The next/image module in Next.js provides an easy and effective way to handle image optimization, lazy loading, and responsiveness.

With the next/image module, automatic optimization is a built-in process during the build phase. This process encompasses compression, resizing, and serving images in modern formats to ensure optimal performance.

Simultaneously, lazy loading is activated by default. This means that images are loaded only when they enter the user’s viewport, strategically reducing the initial page load time. The combination of automatic optimization and lazy loading offers a dual advantage, delivering visually optimise Next.js website images while conserving bandwidth and accelerating the overall user experience.

  • Usage in Next.js Component: you can use the Image component in your Next.js components.

Here’s an example

JavaScript
// Import the Image component from next/image
import Image from 'next/image';

const OptimizedImageComponent = () => {
  return (
    <div>
      {/* Use the Image component with the src, alt, width, and height attributes */}
      <Image
        src="/path/to/optimized-image.jpg"
        alt="Optimized Image"
        width={1000}
        height={500}
      />
    </div>
  );
};

export default OptimizedImageComponent;

In this example, the Image component is used with the required src, alt, width, and height attributes. These attributes provide information about the image and its dimensions.

  • Responsive Image Techniques: To implement responsive image techniques, you can use the layout attribute with the value 'responsive'. This allows the image to adapt its size based on the container’s dimensions.
JavaScript
<div>
  {/* Use the Image component with the layout attribute set to 'responsive' */}
  <Image
    src="/path/to/responsive-image.jpg"
    alt="Responsive Image"
    width={1000}
    height={500}
    layout="responsive"
  />
</div>

The layout="responsive" attribute informs Next.js that the image should be treated as responsive, adjusting its size proportionally to fit the container.

  • Image Prioritisation: You can prioritise the loading of images above the fold to improve the Largest Contentful Paint (LCP).
JavaScript
<div>
  {/* Use the Image component with the priority attribute set to 'true' */}
  <Image
    src="/path/to/responsive-image.jpg"
    alt="Responsive Image"
    width={1000}
    height={500}
    priority={true}
  />
</div>

The priority={true} attribute informs Next.js to specially prioritize the image for loading (e.g. through preload tags or priority hints), leading to a meaningful boost in LCP.

Ways to Choose SSR or SSG Wisely

  1. Server-Side Rendering (SSR)

In SSR, each request to a page is handled by the server, which renders the page with the most up-to-date data. SSR is suitable for content that changes frequently or requires dynamic data on every request. It ensures that users receive the latest information when they access a page.

Use Case: If your content is dynamic and changes frequently, such as real-time updates, user-specific data, or data that updates multiple times a day.

JavaScript
// Example of using Server-Side Rendering (SSR) in a Next.js page
export async function getServerSideProps() {
  // Fetch data from an API or database
  const data = await fetchData();

  return {
    props: {
      data,
    },
  };
}

SSR provides the latest data on every request, making it suitable for dynamic content but it can result in slower initial page loads as it needs to fetch data on every request. It might be less cache-friendly for static content.

  1. Static Site Generation (SSG)

SSG, on the other hand, generates HTML pages at build time. The content is pre-rendered during the build process, and the generated pages are served statically to users. SSG is ideal for content that doesn’t change as frequently, such as marketing pages, blogs, or documentation.

Use Case: If your content is static or updates infrequently, such as blogs, marketing pages, or documentation that doesn’t change with every user request.

JavaScript
// Example of using Static Site Generation (SSG) in a Next.js page
export async function getStaticProps() {
  // Fetch data from an API or database
  const data = await fetchData();

  return {
    props: {
      data,
    },
    // Re-generate the page every 60 seconds during development
    revalidate: 60,
  };
}

SSG generates static HTML files, leading to faster initial page loads and improved performance. It is highly cache-friendly for static content but it may not provide the absolute latest data on every request, making it less suitable for dynamic or frequently changing content. This also a main part to optimise Next.js website for better performance.

  1. Hybrid Approach: Combining SSR and SSG

Suppose you have a scenario where part of your application requires real-time data (SSR), while other parts involve static or less frequently updated content (SSG).

In this example, we’ll create a hybrid page where some sections are rendered using SSR (getServerSideProps) and others using SSG (getStaticProps).

JavaScript
// pages/hybrid-page.js

import React from 'react';

// SSR section: Requires real-time data
export async function getServerSideProps() {
  // Fetch real-time data from an API or database
  const realTimeData = await fetchRealTimeData();

  return {
    props: {
      realTimeData,
    },
  };
}

// SSG section: Static or less frequently updated content
export async function getStaticProps() {
  // Fetch static or less frequently updated data
  const staticData = await fetchStaticData();

  return {
    props: {
      staticData,
    },
    // Re-generate the page every 1 hour during development
    revalidate: 3600, // seconds
  };
}

const HybridPage = ({ realTimeData, staticData }) => {
  return (
    <div>
      <h1>Hybrid Page</h1>

      {/* Render real-time data from SSR */}
      <section>
        <h2>Real-Time Data (SSR)</h2>
        {/* Display real-time data */}
        {JSON.stringify(realTimeData)}
      </section>

      {/* Render static or less frequently updated data from SSG */}
      <section>
        <h2>Static Data (SSG)</h2>
        {/* Display static data */}
        {JSON.stringify(staticData)}
      </section>
    </div>
  );
};

export default HybridPage;

Here, the HybridPage component incorporates both SSR and SSG sections. The getServerSideProps function fetches real-time data using SSR, while the getStaticProps function fetches static or less frequently updated data using SSG.

The revalidate option in getStaticProps specifies how often the page should be re-generated. In this case, the page is re-generated every 1 hour (3600 seconds) during development.

When a user visits this page, they will experience a blend of real-time data fetched on every request (SSR) and static data that is pre-rendered at build time and revalidated periodically (SSG).

Leveraging Caching in Next.js

Caching is a crucial performance optimization strategy that involves storing copies of frequently accessed data to reduce the need for repetitive computations or fetching data from the server.

In the context of a web application, caching helps improve response times, reduce server load, and enhance the overall user experience.

  1. Browser Caching with Headers:

To control browser caching, you can use HTTP headers to convey caching directives to the client’s browser. Common headers for controlling caching include:

  • Cache-Control: Specifies caching directives, such as whether to cache the response, for how long, and whether to revalidate.
  • Expires: Indicates an expiration date for the cached content. However, it’s often recommended to use Cache-Control for more granular control.
  • ETag: A unique identifier for a specific version of a resource. The server can use the ETag to check if the client’s cached version is still valid.
JavaScript
// pages/api/data.js

export default async function handler(req, res) {
  // Simulate fetching dynamic data from a database
  const dynamicData = await fetchDynamicData();

  // Set caching headers
  res.setHeader('Cache-Control', 'public, max-age=3600'); // Cache for 1 hour
  res.setHeader('Last-Modified', new Date().toUTCString());

  // Return the dynamic data
  res.status(200).json({ data: dynamicData });
}

In this example, the Cache-Control header is set to allow public caching for a maximum of 1 hour (max-age=3600). Additionally, the Last-Modified header indicates when the resource was last modified, helping browsers determine if the cached version is still valid.

  1. Considerations while implementing caching

  • When implementing caching, consider the nature of your content and how frequently it changes. For dynamic content, you may need to balance the benefits of caching with the need for real-time data.
  • Utilize CDN services: If your application serves a global audience, consider using Content Delivery Networks (CDNs) to cache and deliver static content closer to users, reducing latency.
  • Test and monitor: Regularly test and monitor your caching strategy to ensure it aligns with your application’s requirements. Adjust caching headers as needed based on performance and user experience metrics.
  • Cache Invalidation: For dynamic content, consider implementing cache invalidation strategies, such as using Cache-Control directives like must-revalidate or utilizing unique identifiers like ETag to check for updates.

Optimizing CSS in Next.js

  1. Minimise and Bundle CSS Files

  • Minimisation: Minimising CSS involves reducing the file size of your stylesheets by removing unnecessary characters, whitespace, and comments. This process helps in faster downloading and parsing of CSS files by browsers. Tools like cssnano can be integrated into your build process to automatically minimise your CSS.
  • Bundling: Bundling CSS files means combining multiple stylesheets into a single file. Fewer HTTP requests are made when a single bundled file is loaded, leading to improved performance. This can be achieved using build tools like Webpack.
  1. Eliminate Unused Styles with PostCSS and PurgeCSS

  • PostCSS: PostCSS is a tool for transforming CSS with JavaScript plugins. It can be utilized for various tasks, including optimizing stylesheets. Plugins like postcss-preset-env enable the use of modern CSS features and automatically apply necessary polyfills based on your target browsers.
  • PurgeCSS: PurgeCSS is a tool that scans your project for used CSS classes and removes the unused styles, reducing the size of your CSS files. It’s particularly beneficial when working with frameworks like Tailwind CSS. Integrating PurgeCSS into your build process ensures that only the styles actually used in your application are included in the final CSS bundle.
  1. Critical CSS for Initial View

Critical CSS is the subset of styles required for the initial view of a page. By inlining critical CSS directly into the HTML document or loading it asynchronously, you can expedite the rendering of the initial content.

This technique is especially useful for improving perceived loading speed, as users can see meaningful content sooner. Tools like Critical can automate the extraction and inlining of critical CSS for your pages.

Below is an example of how you might use PostCSS and PurgeCSS in a Next.js project:

JavaScript
// postcss.config.js

module.exports = {
  plugins: [
    'postcss-preset-env',
    process.env.NODE_ENV === 'production'
      ? [
          '@fullhuman/postcss-purgecss',
          {
            // Specify the paths to all of the template files in your project
            content: ['./src/pages/**/*.{js,ts,jsx,tsx}', './src/components/**/*.{js,ts,jsx,tsx}'],
            // Include any additional paths
            defaultExtractor: (content) => content.match(/[\w-/:]+(?<!:)/g) || [],
          },
        ]
      : undefined,
  ],
};

In this example, PostCSS is configured with the postcss-preset-env plugin. Additionally, PurgeCSS is applied in production mode to remove unused styles by analyzing your project’s template files.

Font Optimizing in Next.js:

  1. Minimize Font Variants and Weights

  • Font Variants: Minimizing the number of font variants involves using only the font styles and variations that are essential for your design. Each additional variant or style adds to the overall size of the font file, so it’s important to include only the weights (e.g., regular, bold) and styles (e.g., italic) that your project requires.
  • Font Weights: Similarly, reducing the number of font weights can significantly impact load times. Consider whether you truly need all available font weights for a particular font family. Eliminating unnecessary weights can result in smaller font files.
  1. Load Fonts Asynchronously

Loading fonts asynchronously means fetching and rendering the font files without blocking the rendering of the entire page. This helps in delivering a more responsive user experience.

In Next.js, you can use the react-async-script library or load fonts using the link tag with the rel attribute set to "stylesheet" and the as attribute set to "font".

JavaScript
// Example of loading fonts asynchronously in Next.js
import Head from 'next/head';

const FontPage = () => {
  return (
    <div>
      <Head>
        {/* Load fonts asynchronously */}
        <link
          rel="stylesheet"
          as="font"
          type="font/woff2"
          crossOrigin="anonymous"
          href="/path/to/font.woff2"
        />
      </Head>
      <p>This is the content of the page.</p>
    </div>
  );
};

export default FontPage;
  1. Use font-display

The font-display: swap; CSS property is used to control how fonts are displayed and rendered. When set to swap, the browser will use a system font to display text until the custom font is fully loaded.

This helps in avoiding the “flash of invisible text” (FOIT) or “flash of unstyled text” (FOUT) and ensures that the content remains visible during font loading.

JavaScript
// Example of using fonts-display: swap in Next.js
import { Roboto } from 'next/font/google';

export const roboto = Roboto({
  subsets: ['latin'],
  display: 'swap',
  weight: ['100', '400', '500', '700'],
});

export default function RootLayout({ children }) {
  return (
    <html lang='en'>
      <head>
      </head>
      <body className={roboto.className} >
        {children}
      </body>
    </html>
  );
}

A few considerations while working with fonts:

  • Web Font Formats: Choose the appropriate font format for web usage. WOFF2 is currently the recommended format for modern browsers, as it provides better compression and faster loading times compared to other formats.
  • Font Subsetting: Consider using font subsetting to include only the characters that your website uses. This further reduces the font file size, especially for multilingual websites.
  • CDN Usage: Hosting fonts on a Content Delivery Network (CDN) can improve loading times, as users can download fonts from a server geographically closer to them.

Reducing Server-Side Processing in Next.js:

Minimize server-side processing by offloading tasks to client-side JavaScript when appropriate. Optimize API calls and database queries for efficiency, reducing server load.

  1. Minimize Server-Side Processing

  • Client-Side Offloading: Consider offloading tasks to client-side JavaScript when it’s appropriate. Some computations or operations that don’t require server-side involvement can be efficiently handled on the client side. This reduces the load on the server and allows for a more responsive user experience.
  • Balancing Responsibilities: Strive to strike a balance between server-side and client-side processing. Evaluate whether a task is better suited for execution on the server or can be delegated to the client to optimize overall performance.
  1. Optimizing API Calls

  • Batching Requests: When making multiple API calls, consider batching them to minimize the number of requests. Batching reduces latency and improves efficiency by combining multiple requests into a single call.
  • Use Efficient Endpoints: Optimize your API endpoints for efficiency and improve Next.js website performance. Ensure that the data returned by API calls is tailored to the specific needs of your application. Avoid fetching unnecessary data to reduce the payload size and improve response times.
  • Caching: Implement caching mechanisms for API responses to avoid redundant requests. Caching helps reduce the load on your server and speeds up subsequent requests for the same data.
  1. Optimize Database Queries

  • Indexing: Ensure that your database queries are optimized by using appropriate indexes. Indexing helps speed up query execution by allowing the database engine to quickly locate the relevant data.
  • Query Efficiency: Write efficient database queries. Avoid unnecessary joins, and only select the columns that are needed for a particular operation. Consider using database query optimization tools and techniques.
  • Connection Pooling: Implement connection pooling to reuse database connections, reducing the overhead of establishing a new connection for each query. Connection pooling enhances the efficiency of handling database queries.

Conclusion

Optimizing the performance of a Next.js website involves a holistic approach, incorporating strategies such as efficient bundling, image and font optimization, lazy loading, and careful consideration of server-side processing.

By implementing these techniques, developers can achieve faster page load times, enhance user experiences, and ensure scalability. Regular monitoring, testing, and adherence to best practices contribute to maintaining a high-performance Next.js application that aligns with the evolving standards of web development.

💡
Here’s a recap of essential takeaways:
  • Implementing these optimisation strategies enhances website performance, leading to faster page load times, improved user experience, and better scalability.
  • Regularly monitor and test your application to identify and address performance bottlenecks.
  • Strive for a balance between server-side and client-side processing for an efficient and responsive web application.
Good Luck!

Delhi
Greetings! I am Amit Singh, an experienced software developer, innovative web designer, and proficient technical SEO specialist.

You might also like