How to Implement Infinite Scroll in Next.js with Intersection Observer

Infinite scroll has become a ubiquitous design pattern across the web, especially on content-heavy sites like social media feeds and e-commerce product listings. When implemented well, infinite scroll can boost engagement metrics like time on site and pages per visit.

According to a study by the Nielsen Norman Group, pages with infinite scrolling have better usability than standard pagination. However, infinite scroll also poses unique challenges around performance, accessibility, and user experience that developers must carefully consider.

In this in-depth tutorial, we‘ll explore how to implement infinite scroll in a Next.js app using the Intersection Observer API. We‘ll cover the key concepts, walk through a full code example, and discuss best practices and potential pitfalls. Let‘s dive in!

Understanding Infinite Scroll

Infinite scroll is a technique for loading content continuously as the user scrolls down the page, creating the illusion of an endlessly growing list. It eliminates the need for traditional pagination, where users have to click a "Next" button or page number to see more items.

The main benefits of infinite scroll include:

However, infinite scroll does have some potential downsides:

  • Harder to reach the footer – If the list grows very long, users may struggle to reach content in the site footer. Consider a "scroll to top" button or keeping the footer visible.
  • Performance concerns – Continuously adding content can lead to a bloated DOM and excess memory usage, especially on low-powered devices. Pagination may be more efficient for very large datasets.
  • SEO impact – Search engine bots may not crawl infinitely scrolled content as effectively. Ensure each item has a unique URL that can be directly linked to.

As with any design pattern, the key is to evaluate if infinite scroll aligns with your users‘ goals and your site‘s content structure. It excels for browsing image-heavy feeds and collections of related items, but may be less ideal for goal-oriented finding tasks.

The Intersection Observer API

To trigger the loading of the next page at the right moment, we need a way to detect when the user has scrolled to the bottom of the currently loaded content. This is where the Intersection Observer API comes into play.

Intersection Observer is a browser API that enables tracking the visibility of DOM elements relative to the viewport or another containing element. It provides an efficient way to detect when an element enters or leaves the visible area and what percentage is visible.

Here‘s a basic example of creating an Intersection Observer:

const options = {
  root: null,
  rootMargin: ‘0px‘, 
  threshold: 1.0
}

const callback = (entries, observer) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      console.log(‘Target element is fully visible‘)
      observer.unobserve(entry.target)
    }
  })
}

const observer = new IntersectionObserver(callback, options)

const target = document.querySelector(‘#end-of-list‘)
observer.observe(target)

In this snippet, we create a new IntersectionObserver instance with a configuration object and a callback function. The configuration options include:

  • root: The containing element to check visibility against. If null, defaults to the browser viewport.
  • rootMargin: Specifies margins around the root element‘s bounding box, effectively shrinking or expanding the area that triggers the callback.
  • threshold: A number or array of numbers between 0.0 and 1.0, specifying what percentage of the target‘s visibility should trigger the callback.

The callback function receives an array of IntersectionObserverEntry objects, one for each observed target element. Each entry provides properties like:

  • boundingClientRect: The target‘s bounding rectangle relative to the root
  • intersectionRatio: The fraction of the target that is visible, from 0.0 to 1.0
  • isIntersecting: A boolean indicating if the target intersects with the root
  • target: A reference to the actual target DOM element

In the example callback, we loop through the entries and check the isIntersecting flag. If an entry is intersecting (i.e., comes into view), we log a message and then stop observing that target element.

To start observing an element, we first select it from the DOM using a CSS selector string, then pass the element to the observer‘s observe method. To stop observing, we call unobserve on the target.

Intersection Observer has broad support in modern browsers. It offers a more performant and battery-efficient approach than listening for scroll events or polling element positions. Plus, it can be treated as a progressive enhancement, falling back to older techniques if needed.

Setting Up a Next.js Project

Now that we have a grasp on infinite scroll and Intersection Observer, let‘s get our hands dirty with some code! We‘ll build an infinite scroll demo in Next.js, fetching data from an API.

First, make sure you have Node.js installed, then create a new Next.js project:

npx create-next-app infinite-scroll-demo
cd infinite-scroll-demo

We‘ll use the swr library for data fetching, so install it as a dependency:

npm install swr

SWR is a powerful React hook library for remote data fetching. It provides a simple, declarative API for loading data from URLs and handles caching, revalidation, and deduping out of the box.

Next, clean up the default starter files. For simplicity, we‘ll build everything in the index.js page component. Open pages/index.js and update it with the following imports:

import useSWRInfinite from ‘swr/infinite‘

const PAGE_SIZE = 6

const fetcher = url => fetch(url).then(res => res.json())

Here, we import the useSWRInfinite hook, which allows paginated data fetching. We declare how many items to fetch per page with the PAGE_SIZE constant. Finally, we define a fetcher function that useSWRInfinite will use to load data from our API.

Fetching Paginated Data

For this example, we‘ll fetch fake post data from the JSONPlaceholder API. Update the main page component like so:

export default function Home() {
  const { data, error, size, setSize } = useSWRInfinite(
    index => 
      `https://jsonplaceholder.typicode.com/posts?_page=${index + 1}&_limit=${PAGE_SIZE}`,
    fetcher
  );

  const posts = data ? [].concat(...data) : [];
  const isLoadingInitialData = !data && !error;
  const isLoadingMore = 
    isLoadingInitialData ||
    (size > 0 && data && typeof data[size - 1] === "undefined");
  const isEmpty = data?.[0]?.length === 0;
  const isReachingEnd = 
    isEmpty || (data && data[data.length - 1]?.length < PAGE_SIZE);

  return (
    <div>

      {posts.map(post => (
        <div key={post.id} className="post">
          <h2>{post.title}</h2>
          <p>{post.body}</p>
        </div>
      ))}

      <button
        disabled={isLoadingMore || isReachingEnd}
        onClick={() => setSize(size + 1)}
      >
        {isLoadingMore
          ? "Loading..."
          : isReachingEnd
          ? "No more posts"
          : "Load more"}
      </button>
    </div>
  );
}

Let‘s break this down:

  • useSWRInfinite accepts a function that returns the URL for each page, based on the page index. It also takes the fetcher function to make the actual API requests.
  • Destructure the key values from useSWRInfinite, including the fetched data, any error, and the current page size.
  • Derive some booleans indicating the current loading state, if there‘s no data, and if we‘ve reached the end of the list. This will help with conditional rendering.
  • In the JSX, render the loaded posts by mapping over the data array. Each nested array contains the posts for that page, so we concatenate them into a single array.
  • Render a "Load more" button that‘s disabled if already loading the next page or there are no more posts. Clicking it increments the size, triggering the next page fetch.

With this setup, we now have an initial implementation of paginated post loading. As the user clicks the "Load more" button, useSWRInfinite fetches the next page of data, which is appended to the existing list.

However, we still need a way to automatically trigger the next page load when the user scrolls to the bottom. That‘s where Intersection Observer comes in.

Implementing Infinite Scroll

To observe when the last loaded post scrolls into view, let‘s create a reusable <ObservedElement> component:

import { useEffect, useRef } from ‘react‘

function ObservedElement({ children, onVisible }) {
  const ref = useRef()

  useEffect(() => {
    const observer = new IntersectionObserver(entries => {
      const entry = entries[0]
      if (entry.isIntersecting) {
        onVisible()
      }
    })

    observer.observe(ref.current)

    return () => observer.disconnect()
  }, [onVisible])

  return <div ref={ref}>{children}</div>
}

This component handles instantiating an IntersectionObserver and observing its root element. It expects a callback function onVisible that is invoked when the element comes into view.

In the useEffect hook, we create a new IntersectionObserver instance with a threshold of 1.0, meaning the callback is triggered when 100% of the target is visible. Inside the callback, we simply call the onVisible prop.

To start observing, we pass the component‘s root ref to observe. The useEffect cleanup function disconnects the observer when the component unmounts.

Now, update the Home component to utilize <ObservedElement>:

export default function Home() {
  // ...

  return (
    <div>

      {posts.map((post, index) => (
        <ObservedElement
          key={post.id}
          onVisible={() => {
            if (index === posts.length - 1) {
              setSize(size + 1)
            }
          }}
        >
          <div className="post">
            <h2>{post.title}</h2>
            <p>{post.body}</p>
          </div>
        </ObservedElement>
      ))}

      {isLoadingMore && <div>Loading...</div>}
      {isEmpty && <div>No posts found.</div>}
    </div>
  )
}

Each rendered post is now wrapped with an <ObservedElement>. The onVisible callback checks if the current post is the last one in the posts array (i.e., the last loaded item). If so, it increments the size state, triggering the next page fetch.

We‘ve also extracted the loading and empty states into standalone conditional blocks for cleaner rendering.

And with that, we have a working implementation of infinite scroll in Next.js using Intersection Observer! As the user scrolls, the next page of posts will automatically load. The useSWRInfinite hook efficiently manages the fetched data, returning cached values whenever possible.

Best Practices and Considerations

While this tutorial covered the fundamentals of infinite scroll, there are additional factors to keep in mind when deploying to production:

Performance

Infinite scroll can potentially degrade performance if not implemented carefully. As more and more content is loaded, the size of the DOM grows, consuming additional memory and CPU resources.

To mitigate this, consider:

  • Virtualizing the list, so only the visible subset of elements is actually rendered. Libraries like react-window can help.
  • Unloading or destroying far off-screen elements to keep the DOM manageable.
  • Throttling the scroll handler to avoid rapid firing of API requests.
  • Implementing a debounce or "load more" button to give users control over loading additional content.

Accessibility

Infinite scroll can be problematic for keyboard navigation and screen readers. Some tips to improve accessibility:

  • Ensure the next page can be loaded without using the scroll wheel, such as via keyboard shortcuts or a "load more" button.
  • Provide an option to disable infinite scroll and use traditional pagination instead.
  • Use correct semantic HTML elements and ARIA attributes to convey meaning and state.
  • Avoid hijacking the scrollbar or disrupting normal keyboard scrolling behavior.

Error Handling

Be sure to handle common error states gracefully:

  • Display user-friendly error messages if the API request fails or returns unexpected data.
  • Allow users to retry failed requests or provide a "reload" button.
  • Implement empty states to avoid showing a blank screen when no data is available.
  • Set reasonable limits on the maximum number of items to load to prevent running out of memory.

Browser Support

While Intersection Observer has good support in modern browsers, it may not be available in older versions. Consider providing a fallback using traditional techniques like scroll event listeners if you need to support a wider range of browsers.

Analytics

Infinite scroll can make it trickier to measure user engagement and track which content is actually being viewed. Consider using tools like heatmaps or scroll depth tracking to gain insights into how far users are scrolling and what content is most popular.

Conclusion

Infinite scroll offers an intuitive, app-like experience for browsing large collections of related content. When paired with an API like Intersection Observer, we can efficiently detect when to trigger the next page load, creating a seamless scrolling experience.

As we‘ve seen, Next.js and SWR provide a powerful foundation for building high-performing infinite scroll implementations. By leveraging useSWRInfinite for paginated data fetching and caching, we can minimize unnecessary network requests and keep our application speedy.

However, infinite scroll is not a one-size-fits-all solution. It‘s crucial to weigh the benefits and drawbacks for your specific use case and audience. When implementing, be sure to follow best practices around performance, accessibility, error handling, and browser support.

Equipped with this knowledge, you‘re now ready to level up your Next.js apps with infinite loading lists. Try extending the demo further with your own optimizations and features, like skeleton loading states, "scroll to top" buttons, or virtualized rendering. The sky‘s the limit!

I‘ll leave you with this quote from web performance expert Harry Roberts:

Infinite scroll is not a pattern to be applied lightly. Test with real users, and base your decisions on data, not anecdotes.

Remember, always prioritize your users‘ needs and measure the impact on engagement and performance. With careful thought and execution, infinite scroll can be a powerful addition to your UX toolbox.

Happy coding!

Similar Posts