DEV Community

Cover image for Implementing infinite scroll in Next.js with Server Actions
Megan Lee for LogRocket

Posted on • Originally published at blog.logrocket.com

Implementing infinite scroll in Next.js with Server Actions

Written by Rahul Chhodde✏️

Infinite scrolling is a common strategy on content-heavy platforms that prioritizes data pagination in API development. This strategy loads massive datasets gradually in small manageable chunks, improving the UX, especially for slower internet connections.

Previously, integrating features like infinite scrolling in Next.js required external libraries such as SWR or Tanstack Query, formerly React Query.

However, with newer Next.js versions — notably, Next.js 13 and beyond — Server Actions empower us to fetch initial data directly on the server. This enhances perceived performance by rendering content immediately without external dependencies.

In this post, we’ll explore how to implement infinite scrolling using Next.js Server Actions, covering server-side initial data fetching and client-side paginated data retrieval. We won't delve into CSS for this article, but note that the finished project utilizes Tailwind CSS.

Setting up a Next.js app

Let's start by setting up our Next.js app. I'm using Create Next App with pnpm, but feel free to choose a different method and package manager if you prefer:

pnpm create next-app
Enter fullscreen mode Exit fullscreen mode

Once you run this command, you should see something like the below: Developer Console Showing Process Of Setting Up Next Js App For Infinite Scroll Project With Prompts To Select Project Settings If you use the same app setup as mine, simply run pnpm dev to start the app in development mode. This will generate the basic Next.js starter UI.

Since building Next.js apps with TypeScript is now the standard approach, I'm choosing to use it with this app. I’m also keeping the traditional src directory, though you may not necessarily have to do that. Below is an overview of the project structure:

.
└── nextjs-infinite-scroll
    ├── node_modules
    ├── public
    ├── src
    │   ├── actions
    │   ├── app
    │   ├── components
    │   ├── config
    │   ├── types
    │   └── utils
    ├── package.json
    ├── tsconfig.json
    └── ᠎...
Enter fullscreen mode Exit fullscreen mode

I've added some subdirectories within the src directory — like config, types, and utils — to organize different types of data effectively. We'll delve deeper into this structure later in the article.

Declaring types

To implement data loading, we'll utilize a dummy REST API called TypiCode, which offers various types of dummy data for development and testing purposes. We'll fetch some dummy blog posts using this service. The URL structure provided by this API is as follows:

http://jsonplaceholder.typicode.com/posts?_start=5&_limit=10
Enter fullscreen mode Exit fullscreen mode

Upon requesting this URL, the response you'll receive will be something like the following:

[
  {
    "userId": 1,
    "id": 1,
    "title": "...",
    "body": "..."
  },
  ...
]
Enter fullscreen mode Exit fullscreen mode

Each of our posts will contain four fields. It's important to set up a type data model for our post data in advance so that we can easily use it throughout the rest of the application. Managing these types in a separate types folder is a good way to keep things organized:

// types/Post.ts
export interface Post {
  postId: number;
  id: number;
  title: string;
  body: string;
}
Enter fullscreen mode Exit fullscreen mode

Separating constants

Based on the structure of our API URL, we may want to set it up in a reusable manner so that we can easily use it whenever needed.

To ensure the security and organization of specific data, such as API keys, URLs, query arguments, and other API-related information, it's crucial to store them in environment variables. However, since this post uses an open API with no confidential data, we will manage our constant values in a separate TypeScript file:

// config/constants.ts
export const API_URL = "https://jsonplaceholder.typicode.com/posts";
export const POSTS_PER_PAGE = 10;
Enter fullscreen mode Exit fullscreen mode

In the above file, we've defined separate variables for the API URL and the number of posts per page, which we will use repeatedly later.

Setting up utility functions

Now, we'll create a utils folder to define a utility function for constructing the API URL with two query parameters, offset and limit:

// utils/getApirUrl.ts
import { API_URL } from "@/config/constants";

export const getApiUrl = (offset: number, limit: number): string => {
  return `${API_URL}?_start=${offset}&_limit=${limit}`;
};
Enter fullscreen mode Exit fullscreen mode

Creating an error helper function to read different response codes and throw better error messages into the console would also be a good idea. We’ll use a lookup table and output different messages for different response codes:

// utils/handleResponseError.ts
export async function handleError(response: Response): Promise<Error> {
  const responseBody = await response.text(); 
  const statusCode = response.status;

  const errorMessages: { [key: number]: string } = {
    400: `Bad request.`,
    ...,
    ...,
  };

  const errorMessage = ...;

  console.error("Error fetching data:", errorMessage);

  return new Error(errorMessage);
}
Enter fullscreen mode Exit fullscreen mode

Configuring UI components

Next, we'll create and configure UI components to showcase the fetched data. We'll create two main patterns: a PostCard that will be responsible for displaying individual Post listings, and a PostList containing multiple PostCard components.

The PostCard component

The PostCard component is straightforward and can be made to use all four parameters offered by the Post type. I’m using only the title and body to keep things simple. We won't specify it as a client-specific component, as we'll need to utilize it both on the client and server:

// components/PostCard.tsx
import { Post } from "@/types/Post";

type PostProps = {
  post: Post;
};

export default function PostCard({ post }: PostProps) {
  return (
    <div className="...">
      <h2 className="...">
        {post.title}
      </h2>
      <p className="...">{post.body}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Below is a quick preview of the PostCard component. We took care of its look and feel using Tailwind CSS: Demo Of A Post Card Component For Next Js Infinite Scroll Project Styled With Tailwind Css You can find all the CSS classes required to build the above in this PostCard.tsx file.

The PostList component

The PostList component won't be too challenging either. However, it may not make much sense at the moment, as we'll need to iterate through the fetched data and provide appropriate data to PostCard for each index.

For now, let's create it like this, and we'll optimize it later:

// components/PostList.tsx
import { Post } from "@/types/Post";

type PostListProps = {
  initialPosts: Post[];
};

export default function PostList({ initialPosts }: PostListProps) {
  return (
    <>
      <div className="...">
        ...
      </div>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

The above component takes an array of posts as a prop, which can be considered initial posts fetched from the server. We’ll work on further data loading in the next few segments.

The PostList component would appear something like the below. We’ll follow the same pattern in all the lists of posts we’ll be implementing: Demo Of A Post List Component For Next Js Infinite Scroll Project Styled With Tailwind Css

Setting up a Server Action

A Next.js Server Action is basically a specialized function that enables us to execute code on the server side in response to user interactions on the client side. This capability facilitates tasks such as data fetching, user input validation, and other server-side operations.

Let's set up a Server Action to load our posts on the server. We’ll use this action directly to load initial data to PostList and then delegate the ongoing responsibility of loading additional content on the client side to the PostList component, triggered by specific events:

// actions/getPosts.ts
"use server";
import { getApiUrl } from "@/utils/getApiUrl";
import { handleError } from "@/utils/handleError";

export const getPosts = async (
  offset: number,
  limit: number
): Promise<Post[]> => {
  const url = getApiUrl(offset, limit);

  try {
    const response = await fetch(url);
    const data = (await response.json()) as Post[];
    if (!response.ok) {
      throw await handleError(response);
    }
    return data;
  } catch (error: unknown) {
    console.error(error);
    throw new Error(`An error occurred: ${error}`);
  }
};
Enter fullscreen mode Exit fullscreen mode

The action utilizes the two utility functions we defined earlier, getApirUrl and handleError. Also, note that every Next.js Server Action starts with a "use server" directive.

In the next step, we'll enhance the PostList component to enable it to load data using the getPosts Server Action we just defined.

Loading data in the PostList component

Now, we‘ll use the useState Hook to manage existing posts and page numbers received from the server. Using the offset and POST_PER_PAGE properties, we‘ll establish the necessary logic to load the next cargo of posts.

Note that we need to designate this PostList component as a client component for user-driven data updates triggered by specific events, such as scrolling down the page or clicking a button:

// components/PostList.tsx
"use client";
import { useState } from 'react';
...

export default function PostList({ initialPosts }: PostListProps) {
  const [offset, setOffset] = useState(POSTS_PER_PAGE);
  const [posts, setPosts] = useState<Post[]>(initialPosts);
  const [hasMoreData, setHasMoreData] = useState(true);

  const loadMorePosts = async () => {
    if (hasMoreData) {
      const apiPosts = await getPosts(offset, POSTS_PER_PAGE);

      if (apiPosts.length == 0) {
        setHasMoreData(false);
      }

      setPosts((prevPosts) => [...prevPosts, ...apiPosts]);
      setOffset((prevOffset) => prevOffset + POSTS_PER_PAGE);
    }
  };

  return (
    <>
      <div className="...">
        {posts?.map((post) => (
          <PostCard key={post.id} post={post} />
        ))}
      </div>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

In the loadMorePosts function, we have three state variables working together to deliver different responsibilities: offset, posts, and hasMorePosts.

The posts variable holds the initial posts we expect to receive from the server in the PostList component whenever the page loads. We append new data to this array based on the hasMoreData boolean, which is set to true by default.

Let’s say we use the getPosts action and receive an empty response. In that case, we set the hasMorePosts boolean to false. This will stop making requests to load more content. Otherwise, we append the newer posts to the posts variable, and the POST_PER_PAGE value increments the current offset value.

To ensure the loadMorePosts function works as expected, we should trigger it through an event like clicking a button or scrolling down. For now, let's add a button to the PostList component that the user can click to load more posts. Eventually, this click-based loading will be replaced with an infinite scroll feature.

Finally, the visibility of this trigger button is controlled by the hasMoreData boolean. Here’s the resulting code:

// components/PostList.tsx
export default function PostList({ initialPosts }: PostListProps) {
  const [offset, setOffset] = useState(POSTS_PER_PAGE);
  const [posts, setPosts] = useState<Post[]>(initialPosts);
  const [hasMoreData, setHasMoreData] = useState(true);
  ...

  return (
    <>
      <div className="...">
        {posts?.map((post) => (
          <PostCard key={post.id} post={post} />
        ))}
      </div>
      <div className="...">
        {hasMoreData ? (
          <button
            className="..."
            onClick={loadMorePosts}
          >
            Load More Posts
          </button>
        ) : (
          <p className="...">No more posts to load</p>
        )}
      </div>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

The final step is to integrate the PostList component into the page.tsx file at the app directory’s root.

As previously discussed, the PostList component requires an argument named initialPosts to populate the list with some initial data. We fetch this data using the getPosts Server Action and load the posts from 0 up to the value we specified for our POST_PER_PAGE constant:

...

export default async function Home() {
  const initialPosts = await getPosts(0, POSTS_PER_PAGE);

  return (
    <>
      <div className="...">
         <PostList initialPosts={initialPosts} />
      </div>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

That's it! Now we can load additional posts by clicking the Load More Posts button. Below is a glimpse of how the implementation would look like in action: Demo Loading Posts On Scroll In Next Js By Pressing Load More Posts Button You may find the component code here and its application in the root page.tsx file. In the next segment, we'll extend our current implementation to include infinite scrolling for loading data instead of the load more button.

Implementing infinite scroll

The basic idea of implementing infinite scroll here involves replacing the button implemented in the PostList component with a scroll-trigger element, such as a spinner or text indicating the loading.

When this element comes into view within the viewport, we trigger the loading of the next batch of data. This is basically how the infinite scroll feature works. We can detect the intersection of an element using the JavaScript Intersection Observer API.

We'll cover two methods for implementing infinite scroll. One involves using a small dependency that simplifies using the Intersection Observer API in React. The other method involves directly using the Intersection Observer API, which is slightly more complex to implement in React.

Using the react-intersection-observer package

Add the react-intersection-observer package as a normal dependency:

pnpm install react-intersection-observer
Enter fullscreen mode Exit fullscreen mode

Let's create a copy of the PostList component and name it PostListInfiniteRIO or any other suitable name. The main loadMorePosts loading function will remain unchanged.

We'll utilize the useInView Hook provided by the react-intersection-observer library, which destructures the values it returns into the variables we provided below:

const [scrollTrigger, isInView] = useInView();
Enter fullscreen mode Exit fullscreen mode

The scrollTrigger variable is a reference object we’ll attach to the element we want to observe. Meanwhile, isInView is a boolean value indicating whether the element is currently visible within the viewport.

Everything was happening synchronously in the PostList component, so we didn't need to use the useEffect Hook. However, since we want to trigger loadMorePosts when scrolling down to our scrollTrigger element asynchronously, we need to use the useEffect Hook to observe our loading element:

useEffect(() => {
  if (isInView && hasMoreData) {
    loadMorePosts();
  }
}, [isInView, hasMoreData]);
Enter fullscreen mode Exit fullscreen mode

Now, when the loading element intersects the viewport, loadMorePosts will be triggered with new values for the hasMoreData and isInView booleans.

Finally, we need to implement our scrollTrigger element as follows:

export default function PostListInfiniteRIO({ initialPosts }: PostListProps) {
  ...  

  return (
    <>
      <div className="...">
        {posts?.map((post) => (
          <PostCard key={post.id} post={post} />
        ))}
      </div>
      <div className="...">
        {(hasMoreData && <div ref={scrollTrigger}>Loading...</div>) || (
          <p className="...">No more posts to load</p>
        )}
      </div>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now, we can easily implement this new component in the page.tsx file, replacing the PostList component. Alternatively, we can create a new route and consume this component in its root page.tsx file instead.

Here’s how it would look like in action in the browser window: Demo Loading Posts On Scroll In Next Js By Scrolling To Bottom Of Visible List Find the implementation in the PostListInfiniteRIO component and the infinite-scroll-rio directory in the app router.

Using the JavaScript Intersection Observer API directly

Instead of using an additional library, let's consume the JavaScript Intersection Observer API directly in our component.

The new component we’re about to create is similar to the PostListInfiniteRIO we set up in the previous section. Only the useEffect portion differs, as it's where the Intersection Observer API implementation goes.

As discussed in the last section, we need to utilize the useEffect Hook because some async tasks will be involved to accomplish the infinite scroll feature.

Since we don’t have any built-in referencing logic as offered by the react-intersection-observer library in the previous component, we need to set a reference for our scrollTrigger element using the useRef Hook:

// components/PostListInfinite.tsx
export default function PostListInfinite({ initialPosts }: PostListProps) {
  const [offset, setOffset] = useState(POSTS_PER_PAGE);
  const [posts, setPosts] = useState<Post[]>(initialPosts);
  const [hasMoreData, setHasMoreData] = useState(true);
  const scrollTrigger = useRef(null);

  // ...

  return (
    <>
      <div className="...">
        {posts?.map((post) => ())}
      </div>
      <div className="...">
        {hasMoreData ? (
          <div ref={scrollTrigger}>Loading...</div>
        ) : (
          <p className="...">No more posts to load</p>
        )}
      </div>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Next, within the useEffect Hook, we should check if the window object exists as well as if the IntersectionObserver object is available:

useEffect(() => {
  if (typeof window === "undefined" || !window.IntersectionObserver) {
    return;
  }

  // ...
}, [hasMoreData]);
Enter fullscreen mode Exit fullscreen mode

The useEffect Hook we used here relies solely on the hasMoreData state. This is because we don't have anything like the isInView boolean available, as we did in the previous component.

The compatibility check introduced above is crucial because window and Web APIs are generally unavailable on the server side during initial rendering, potentially causing errors. If either of these two is found to be unsupported, the useEffect Hook exits early to prevent unnecessary operations.

If supported, the window object creates an IntersectionObserver instance to track the visibility of our scrollTrigger element. The loadMorePosts function is triggered whenever the threshold value reaches 0.5 — in other words, when the scrollTrigger element becomes at least 50 percent visible within the viewport, indicating that the user is scrolling toward it:

useEffect(() => {
  // ...

  const observer = new IntersectionObserver(
    (entries) => {
      if (entries[0].isIntersecting) {
        loadMorePosts();
      }
    },
    { threshold: 0.5 }
  );

  if (scrollTrigger.current) {
    observer.observe(scrollTrigger.current);
  }

  // Cleanup
  return () => {
    if (scrollTrigger.current) {
      observer.unobserve(scrollTrigger.current);
    }
  };
}, [hasMoreData, offset]);
Enter fullscreen mode Exit fullscreen mode

By the end, we introduced a cleanup function to stop observing the element when the component unmounts or a dependency changes. This ensures the observer doesn't leak memory and updates its behavior based on current conditions.

Finally, either replace the PostList component in the page.tsx file at the app's root, or create a separate route and implement it in its own page.tsx file.

As shown below, this would look identical to the previous implementation but with no additional dependencies used: Demo Different Approach To Loading Posts On Scroll In Next Js By Scrolling To Bottom Of Visible List With Same Effect As Previous Approach But With No Dependencies Used You may find all the associated code in the PostListInfinite component and the infinite-scroll directory inside the App Router.

Conclusion

Including an infinite scroll feature in your content-heavy Next.js project is a great way to improve UX by loading large datasets gradually page-by-page. In this tutorial, we explored how to implement infinite scroll using Next.js Server Actions.

We started with loading data on demand first and then covered two different approaches to implementing infinite scroll in Next.js. You should now have a solid understanding of on-demand paginated data loading and setting up an infinite scroll implementation in a content-heavy app.

You can find all the code we discussed above in this GitHub repository. If you have any questions, feel free to ask in the comment section below.


LogRocket: Full visibility into production Next.js apps

Debugging Next applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.

LogRocket Signup

LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your Next.js app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.

The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.

Modernize how you debug your Next.js apps — start monitoring for free.

Top comments (0)