Implementing infinite scroll in Next.js with Server Actions
Tags: nextjs, webdev
Published: 2024-04-23 14:18:52

**Written by [Rahul Chhodde](https://blog.logrocket.com/author/rahulchhodde/)✏️**

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](https://blog.logrocket.com/guide-state-management-next-js/#using-swr-state-management-next-js) or [Tanstack Query, formerly React Query](https://blog.logrocket.com/using-tanstack-query-next-js/). 

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](https://github.com/c99rahul/nextjs-infinite-scroll) 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](https://blog.logrocket.com/introducing-the-new-create-next-app/) with pnpm, but feel free to choose a different method and [package manager](https://blog.logrocket.com/javascript-package-managers-compared/) if you prefer:

```bash
pnpm create next-app
```

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](https://blog.logrocket.com/wp-content/uploads/2024/04/img1-Setting-up-Next-js-app-infinite-scroll-project.png) 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](https://blog.logrocket.com/using-next-js-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:

```plaintext
.
└── nextjs-infinite-scroll
    ├── node_modules
    ├── public
    ├── src
    │   ├── actions
    │   ├── app
    │   ├── components
    │   ├── config
    │   ├── types
    │   └── utils
    ├── package.json
    ├── tsconfig.json
    └── ᠎...
```

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](https://jsonplaceholder.typicode.com/), 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:

```plaintext
http://jsonplaceholder.typicode.com/posts?_start=5&_limit=10
```

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

```json
[
  {
    "userId": 1,
    "id": 1,
    "title": "...",
    "body": "..."
  },
  ...
]
```

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:

```typescript
// types/Post.ts
export interface Post {
  postId: number;
  id: number;
  title: string;
  body: string;
}
```

## 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:

```typescript
// config/constants.ts
export const API_URL = "https://jsonplaceholder.typicode.com/posts";
export const POSTS_PER_PAGE = 10;
```

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`:

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

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

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](https://blog.logrocket.com/evaluating-alternatives-typescript-switch-case/#using-object-lookup-table-alternative-typescript-switch-case) and output different messages for different response codes:

```typescript
// 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);
}
```

## 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:

```typescript
// 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>
  );
}
```

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](https://blog.logrocket.com/wp-content/uploads/2024/04/img2-Demo-PostCard-component.png) You can find all the CSS classes required to build the above in [this `PostCard.tsx` file](https://github.com/c99rahul/nextjs-infinite-scroll/blob/main/src/components/PostCard.tsx).

### 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:

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

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

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

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](https://blog.logrocket.com/wp-content/uploads/2024/04/img3-Demo-PostList-component.png)

## Setting up a Server Action

A [Next.js Server Action](https://blog.logrocket.com/diving-into-server-actions-next-js-14/) 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:

```typescript
// 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}`);
  }
};
```

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](https://blog.logrocket.com/guide-state-management-next-js/#using-usestate-hook-state-management-next-js) 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:

```typescript
// 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>
    </>
  );
}
```

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:

```typescript
// 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>
    </>
  );
}
```

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:

```typescript
...

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

  return (
    <>
      <div className="...">
         <PostList initialPosts={initialPosts} />
      </div>
    </>
  );
}
```

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](https://blog.logrocket.com/wp-content/uploads/2024/04/img4-Loading-posts-Load-More-Posts-button.gif) You may find [the component code here](https://github.com/c99rahul/nextjs-infinite-scroll/blob/main/src/components/PostList.tsx) and its application in [the root `page.tsx` file](https://github.com/c99rahul/nextjs-infinite-scroll/blob/main/src/app/page.tsx). 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](https://blog.logrocket.com/lazy-loading-using-the-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](https://blog.logrocket.com/using-react-intersection-observer-create-dynamic-header/) as a normal dependency:

```bash
pnpm install react-intersection-observer
```

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:

```typescript
const [scrollTrigger, isInView] = useInView();
```

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:

```typescript
useEffect(() => {
  if (isInView && hasMoreData) {
    loadMorePosts();
  }
}, [isInView, hasMoreData]);
```

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:

```typescript
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>
    </>
  );
}
```

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](https://blog.logrocket.com/wp-content/uploads/2024/04/img5-Loading-posts-infinite-scroll.gif) Find the implementation in [the `PostListInfiniteRIO` component](https://github.com/c99rahul/nextjs-infinite-scroll/blob/main/src/components/PostListInfiniteRIO.tsx) and [the infinite-scroll-rio directory](https://github.com/c99rahul/nextjs-infinite-scroll/tree/main/src/app/infinite-scroll-rio) 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:

```typescript
// 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>
    </>
  );
}
```

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

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

  // ...
}, [hasMoreData]);
```

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:

```typescript
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]);
```

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](https://blog.logrocket.com/wp-content/uploads/2024/04/img6-Different-approach-infinite-scroll-Next-js-no-dependencies-same-result.gif) You may find all the associated code in [the `PostListInfinite` component](https://github.com/c99rahul/nextjs-infinite-scroll/blob/main/src/components/PostListInfinite.tsx) and [the infinite-scroll directory](https://github.com/c99rahul/nextjs-infinite-scroll/tree/main/src/app/infinite-scroll) 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](https://github.com/c99rahul/nextjs-infinite-scroll). If you have any questions, feel free to ask in the comment section below.

---

##[LogRocket](https://lp.logrocket.com/blg/nextjs-signup): 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](https://lp.logrocket.com/blg/nextjs-signup).

[![LogRocket Signup](https://blog.logrocket.com/wp-content/uploads/2017/03/1d0cd-1s_rmyo6nbrasp-xtvbaxfg.png)](https://lp.logrocket.com/blg/nextjs-signup)

[LogRocket](https://lp.logrocket.com/blg/nextjs-signup) 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](https://lp.logrocket.com/blg/nextjs-signup).