3 Easy Performance Optimization Techniques for Next.js Applications

·

6 min read

  1. Code Splitting with dynamic Import:

    Code splitting with dynamic import is a crucial technique for optimizing Next.js applications, especially larger projects where bundling all the code into a single bundle can lead to slower initial loading times. I do personally use this technique in almost all my applications, big or small, to implement route guards for pages that require auth to be accessed. We’ll see how to use it shortly. It’s essentially a technique that allows you to split your JavaScript code into smaller chunks and load them on demand, resulting in faster initial page loads and improved performance.

    Here's a simplified example of how you can implement dynamic imports to protect authenticated routes in a Next.js application:

// Inside your protected route component file
// This example is a profile page

import React from "react";
import { useRouter } from "next/router";
import { useSelector } from "react-redux";

const ProtectedRoute = () => {
  const { isAuthenticated } = useSelector((state) => state.user);
  const router = useRouter();

  const handleLoadRoute = async () => {
    if (isAuthenticated) {
      const AuthenticatedRoute = await import("../components/profile");
      // Render the authenticated route component
      return <AuthenticatedRoute />;
    } else {
      // Redirect the user to the login page if not authenticated
      router.push("/login");
      return null;
    }
  };

  return <div>{handleLoadRoute()}</div>;
};

export default ProtectedRoute;

In this example, the ProtectedRoute component dynamically imports the profile page component (as the value AuthenticatedRoute) only if the user is authenticated. Otherwise, it redirects the user to the login page. This ensures that the authenticated route component is only loaded when necessary, helping to protect sensitive routes and improve overall application performance.

Since it allows our applications to load pages only when needed, it is a very valuable technique for improving large and small application performance to ensure overall excellent user experience. So next time you're building a Next app, try dynamically importing pages that are not required immediately.

  1. Caching:

Caching is basically the process of storing copies of your application on the browser. Caching plays a crucial role in Next.js performance optimization by reducing the need for repetitive server requests. By storing frequently accessed data or page responses, your application can deliver content faster to users. Proper caching mechanisms can significantly reduce latency, improve response times, and enhance the overall user experience.

Next.js automatically caches the HTML of statically generated pages (getStaticProps) for a specific duration. This cached response is served directly to subsequent requests for the same page, bypassing the need for server-side rendering again. This default caching behavior significantly improves performance for frequently accessed pages with static content.

However, in a situation where the content on a particular page changes often, a more granular control over the page's caching mechanism will help improve performance and overall user experience. To do this, we can control the caching behavior using getStaticProps. Next.js refers to this as Incremental Static Regeneration (ISR), you can read more about it on their official page. Here's a code example:

export async function getStaticProps() {
  const res = await fetch('https://.../posts')
  const posts = await res.json()

  return {
    props: {
      posts,
    },
    // Next.js will attempt to re-generate the page:
    // - When a request comes in
    // - At most once every 60 seconds
    revalidate: 60, // In seconds
  }
}

In this example, the revalidate option instructs the browser to cache the response for a certain duration (60 seconds in this case) and then check with the server to see if there's a newer version before using the cached response again. GRACIOUS!

By effectively utilizing custom caching mechanisms, you can significantly reduce server load and improve page load times for your Next.js applications. Remember to strike a balance between caching and data freshness to deliver an optimal user experience. As a general rule of thumb, only do this when you need to render data that changes periodically.

  1. Image Optimization with Third-Party Libraries:

    Next.js offers built-in image optimization that resizes and optimizes images based on the user's device and viewport. However, when you're working on an image-intensive page or application (not sure if that's a word), it may help improve your app's performance if you used advanced image optimization techniques. For this, you can leverage third-party libraries like sharp, which provides a powerful and flexible library for image processing.

    Some of the benefits of using sharp for image optimization in Next.js include:

    - Wider Format Support: sharp supports a vast range of image formats, including WebP, AVIF, JPEG, PNG, and more. You can choose the optimal format based on desired quality and file size.

    - Granular Control: sharp offers fine-grained control over image processing tasks like resizing, cropping, color manipulation, and format conversion. This allows you to tailor the optimization process for specific needs.

    - Performance: sharp is a performant library that can handle image processing tasks efficiently.

    Now let's see how we can use sharp to turn a JPEG image to WebP format to improve performance.

    After installing sharp (npm i sharp) Lets create a handler to turn a JPEG image to WebP:

// Server-side API route (optimizeImg.js)
import sharp from 'sharp';

export default async function handler(req, res) {
  const imageUrl = req.query.imageUrl; // Encoded image URL from client-side

  try {
    const response = await fetch(decodeURIComponent(imageUrl)); // Decode URL
    if (!response.ok) {
      throw new Error(`Failed to fetch image from ${imageUrl}`);
    }

    const buffer = await response.blob();
    const optimizedBuffer = await sharp(buffer)
      .webp() // Convert to WebP format
      .toBuffer();

    res.setHeader('Content-Type', 'image/webp');
    res.send(optimizedBuffer);
  } catch (error) {
    console.error(error);
    res.status(500).json({ message: 'Error processing image' });
  }
}

In the above code, we basically used sharp to convert the image to WebP format and then the response headers are set to indicate the content type (WebP in this case). You can read the sharp docs for more info on how to use it.

Back in our component's file where we'll use this, let's handle processing the image by passing it to the sharp handler we defined above:

// MyComponent.jsx
import Image from 'next/image';

export default function MyComponent() {
  const handleProcessImg = async (imageUrl) => {
    const response = await fetch(`/api/optimizeImg?imageUrl=${encodeURIComponent(imageUrl)}`);
    const blob = await response.blob();
    return URL.createObjectURL(blob);
  };

  return (
    <Image
      src="/original-image.jpg" // Original image source in JPEG format
      alt="My Image"
      width={800}
      height={600}
      loader={handleProcessImg} // Use custom loader function
    />
  );

In the code above, we define a handleProcessImg function passed to the loader prop of our Image component -- Which sends a request with the original image, to the sharp image processing handler we created earlier, it then returns the converted image as an object URL for our component to display. GGs!

Remember to experiment with different approaches and formats to find the optimal balance between image quality and file size for your use case... And also read the docs :)

Using sharp for custom image optimization could undeniably improve the performance of your Next.js application. However, it is also important to know when to use a third party library for such use case, as Next.js built it image optimization is efficient and just okay for most applications out there. Applications that deal with potentially undesirable image formats coming from user inputs or the likes, could implement this to ensure optimized function and improved performance and scalability.

CONCLUSION:

In this post, we've explored several key techniques for optimizing Next.js applications, ensuring a smooth and performant user experience. We covered:

  1. Code Splitting with Dynamic Imports: A powerful strategy for loading code only when necessary, especially for protecting authenticated routes.

  2. Caching Mechanisms: Leveraging Next.js's built-in caching and exploring getStaticProps with revalidate for granular control over page caching behavior.

  3. Image Optimization with Third-Party Libraries: Utilizing sharp for advanced image processing, format conversion (like WebP), and achieving optimal file sizes without sacrificing quality.

By effectively implementing these techniques, you can significantly improve the performance of your Next.js applications. Remember to tailor your approach based on your application's specific needs and content update frequency. Always strive for a balance between caching, data freshness, and optimal image quality to deliver a delightful user experience.

Thanks for reading :)