How to do infinite scrolling in Next.js with Server Components and Server Actions

With the advent of React Server Components (RSCs) and Server Actions, many new capabilities that were previously difficult to achieve in React, either requiring third-party libraries or a significant amount of boilerplate, are now achievable out of the box. One such capability is infinite scrolling.

In this tutorial, we will see how to implement infinite scrolling in Next.js leveraging Server Components and Server Actions, along with the Intersection Observer browser API.

We are going to build an infinite scrolling list of items with random data that I generated using an LLM. The core idea is to utilize React’s newest features to fetch data when the user scrolls to the bottom of the page just like many social media platforms do.

Prerequisites

If you’re not familiar with RSCs and Server Actions, I recommend reading the official documentation of RSCs and Server Actions to better understand their functionality and use cases.

Let’s get started!

You can simply read through this tutorial to understand how it works, or you can follow along and build the feature as we go. I’ll guide you through the process of creating a Next.js app that implements an infinite scrolling list of items from scratch.

First, we need to bootstrap a Next.js app that is compatible with React 19, as of now, we can do so by running the following command:

Terminal window
npx create-next-app@rc infinite-scroll-app

Create a Post Component

This is just a quick and simple Post component, there’s not so much story to it.

app/_components/Post.tsx
export default function Post(props: { content: string }) {
return (
<div
style={{
height: "300px",
width: "500px",
margin: "10px",
padding: "20px",
borderRadius: "8px",
backgroundColor: "#f9f9f9",
boxShadow: "0 4px 8px rgba(0, 0, 0, 0.1)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexDirection: "column",
}}
>
<h2
style={{
margin: 0,
padding: 0,
color: "#333",
fontSize: "24px",
}}
>
{props.content}
</h2>
</div>
);
}

In a real-world scenario, this would be more complex, including features like buttons and author avatars. But for this tutorial, we’ll keep it simple.

Adding Mock Data

Below is just mock data created with an LLM to simulate a database, again not so much story to it.

app/_data/mock-data.ts
export const posts: Array<{ id: number; content: string }> = [
{ id: 1, content: "1. Getting Started with Vue.js" },
{ id: 2, content: "2. Tips for Efficient Unit Testing" },
{ id: 3, content: "3. The Power of Functional Programming in JavaScript" },
{ id: 4, content: "4. Leveraging WebSockets for Real-Time Apps" },
{ id: 5, content: "5. Introduction to Serverless Architecture" },
{ id: 6, content: "6. Best Practices for API Security" },
34 collapsed lines
{ id: 7, content: "7. The Impact of AI on Modern Web Development" },
{ id: 8, content: "8. Exploring the new features of TypeScript 4.5" },
{ id: 9, content: "9. Why use Docker in your development workflow?" },
{ id: 10, content: "10. An Introduction to GraphQL for Beginners" },
{ id: 11, content: "11. Microservices architecture: Pros and Cons" },
{ id: 12, content: "12. Managing state in React with Redux and Context API" },
{ id: 13, content: "13. A deep dive into Node.js Streams" },
{ id: 14, content: "14. Building RESTful APIs with Express.js" },
{ id: 15, content: "15. Is Angular still relevant in 2024?" },
{ id: 16, content: "16. The rise of Deno and the future of Node.js" },
{ id: 17, content: "17. Advanced CSS Tricks for Modern Web Design" },
{ id: 18, content: "18. Understanding WebAssembly and its implications" },
{ id: 19, content: "19. The evolution of frontend frameworks" },
{ id: 20, content: "20. How to use Webhooks for event-driven integrations" },
{ id: 21, content: "21. Exploring Cloud Native Applications" },
{ id: 22, content: "22. Practical Guide to OAuth and OpenID Connect" },
{ id: 23, content: "23. Enhancing UI with CSS Grid and Flexbox" },
{ id: 24, content: "24. What's new in React 19?" },
{ id: 25, content: "25. Progressive Web Apps: The Complete Guide" },
{ id: 26, content: "26. Understanding Cryptography in Web Development" },
{ id: 27, content: "27. Building Interactive Components" },
{ id: 28, content: "28. How to Use Docker Compose" },
{ id: 29, content: "29. Version Control Best Practices with Git" },
{ id: 30, content: "30. Node.js Performance Optimization" },
{ id: 31, content: "31. Secure Your Node.js Applications" },
{ id: 32, content: "32. Continuous Integration and Deployment Strategies" },
{ id: 33, content: "33. Introduction to Machine Learning with JavaScript" },
{ id: 34, content: "34. Advanced Routing Techniques in Angular" },
{ id: 35, content: "35. Using WebSockets in Angular Applications" },
{ id: 36, content: "36. Building a Chat App with Socket.io" },
{ id: 37, content: "37. Testing Angular Applications with Jasmine" },
{ id: 38, content: "38. Scalable Vector Graphics (SVG) in Web Design" },
{ id: 39, content: "39. Web Accessibility Standards and Best Practices" },
{ id: 40, content: "40. Introduction to Server-Side Rendering with Next.js" },
];

Creating a Component to Display all Posts

Now we’re getting to the juicy stuff!

This component will render all the posts and invoke some 🪄magic✨ to fetch the following posts when the user reaches the bottom of the page.

Step 1. Because this component will interact with a Browser API, it must be a Client Component. To make this component run on the client let’s add the “use client” directive at the top of the file. Otherwise, it would run only on the server, and since the server evidently cannot access the Browser APIs it wouldn’t do anything.

app/_components/Posts.tsx
"use client";
import React from "react";
export default function Posts() {
}

Step 2. Let’s use the Intersection Observer API to detect when the user reaches the bottom of the page.

We could code down the intersection observer, but I prefer just to use a third-party package, just to not deviate ourselves from the topic. So we’re going to install a library that contains useful hooks that performs common tasks:

Terminal window
pnpm add @uidotdev/usehooks

app/_components/Posts.tsx
"use client";
import React from "react";
import { useIntersectionObserver } from "@uidotdev/usehooks";
export default function Posts() {
const [loadMoreRef, entry] = useIntersectionObserver({
threshold: 0,
root: null,
rootMargin: "0px",
});
React.useEffect(() => {
if (entry?.isIntersecting) {
// Magic happens here🪄! Fetch function will be added in the following steps.
}
}, [entry?.isIntersecting]);
return (
<div
ref={loadMoreRef}
style={{
height: "20px",
display: "block",
color: "white",
fontSize: "1rem",
}}
>
Loading more...
</div>
);
}

I won’t explain in depth what an Intersection Observer is, but in a nutshell it allows us to detect when an element is visible in the viewport, and we can set a threshold to determine when it is visible. If you’d like to learn more about it, I suggest you reading this article: JavaScript Intersection Observer Ultimate Guide

Step 4. Adding State to Hold the Posts

For the moment the posts state will be an empty array, but later we’ll fetch the posts from the server.

app/_components/Posts.tsx
"use client";
import React from "react";
import { useIntersectionObserver } from "@uidotdev/usehooks";
export default function Posts() {
const [posts, setPosts] = React.useState([]);
const [loadMoreRef, entry] = useIntersectionObserver({
9 collapsed lines
threshold: 0,
root: null,
rootMargin: "0px",
});
React.useEffect(() => {
if (entry?.isIntersecting) {
// Magic happens here🪄! Fetch function will be added in the following steps.
}
}, [entry?.isIntersecting]);
return (
<>
{posts.length > 0 ? posts : "No posts to show"}
<div
ref={loadMoreRef}
style={{
height: "20px",
display: "block",
color: "white",
fontSize: "1rem",
}}
>
Loading more...
</div>
</>
);
}

The Interesting Part Begins Here! 👀

Let’s create a server action that will fetch posts from the mock data we created earlier.

Step 1. Create a new file and add the “use server” directive at the top of the file. This will tell the bundler that all the code inside this file will run only on the server, so when you import any function from this file, it will not be included in the client bundle, and in place put a endpoint call.

Then, import the mock data we created earlier.

app/_actions/get-posts.tsx
"use server";
import { posts } from "../_data/mock-data";

Step 2. Now, when it comes to infinite scroll, we need to fetch the next piece of data, so we need to add a parameter to the function that will be the offset of the data we want to fetch. This is just one way to do it, you can do it in many other ways, for example, in a real world scenario you would fetch the data from a database and cursor-based infinite scrolling might be better suited for this case, but for the sake of this tutorial we will keep it simple.

app/_actions/get-posts.tsx
"use server";
import { posts } from "../_data/mock-data";
export async function getPosts(offset: number) {
}

Step 3. Now we need to slice the data from the mock data array.

app/_actions/get-posts.tsx
"use server";
import { posts } from "../_data/mock-data";
const LIMIT = 10;
export async function getPosts(offset: number) {
const endIndex = offset + LIMIT;
const newPosts = posts.slice(offset, endIndex);
const hasMore = endIndex < posts.length;
}

Step 4. Finally, we need to map over the new posts and return something that we can use to render in the Posts client component. We could just return an array of objects, something like:

[
{ id: 1, content: "1. Getting Started with Vue.js" },
{ id: 2, content: "2. Tips for Efficient Unit Testing" },
{ id: 3, content: "3. The Power of Functional Programming in JavaScript" },
];

But, to make it more interesting, we can return a component, be it a Client Component, a Server Component or just a <div> for example. In our case, we will return and object that has an array of the Post components we created earlier which is a Server Component, and a boolean that tells us if there are more posts to fetch.

app/_actions/get-posts.tsx
"use server";
import { posts } from "../_data/mock-data";
import Post from "../_components/Post";
const LIMIT = 10;
export async function getPosts(offset: number) {
const endIndex = offset + LIMIT;
const newPosts = posts.slice(offset, endIndex);
const hasMore = endIndex < posts.length;
return { posts: newPosts.map(post => <Post key={post.id} content={post.content} />), hasMore };
}

Fetching Initial Posts

Before we move on to fetching more posts as the user scrolls down, let’s fetch the initial posts when the component first renders.

For this we need to call the getPosts function we created earlier in the app/page.tsx file, which is the entry point of our app.

We can set an initial offset, for example, 5, and fetch the first 5 posts.

Then we pass both the initial posts and the initial offset to the Posts component, so that when the component first renders it will show the initial posts and when the user reaches the bottom of the page it knows from where to start fetching more posts.

app/page.tsx
import { getPosts } from "./_actions/get-posts";
import Posts from "./_components/Posts";
export default async function Home() {
const initialOffset = 0;
const response = await getPosts(initialOffset);
return (
<main
style={{
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
alignItems: "center",
padding: "6rem",
minHeight: "100vh",
}}
>
<Posts initialPosts={response.posts} initialOffset={initialOffset} />
</main>
);
}

Now, let’s go back to the Posts component and render the initial posts.

app/_components/Posts.tsx
"use client";
import React from "react";
import { useIntersectionObserver } from "@uidotdev/usehooks";
interface PostsProps {
initialPosts: JSX.Element[];
initialOffset: number;
}
export default function Posts() {
const [posts, setPosts] = React.useState([]);
export default function Posts(props: PostsProps) {
const [posts, setPosts] = React.useState(props.initialPosts);
const [loadMoreRef, entry] = useIntersectionObserver({
28 collapsed lines
threshold: 0,
root: null,
rootMargin: "0px",
});
React.useEffect(() => {
if (entry?.isIntersecting) {
// Magic happens here🪄! Fetch function will be added in the following steps.
}
}, [entry?.isIntersecting]);
return (
<>
{posts.length > 0 ? posts : "No posts to show"}
<div
ref={loadMoreRef}
style={{
height: "20px",
display: "block",
color: "white",
fontSize: "1rem",
}}
>
Loading more...
</div>
</>
);
}

Let’s call the server action to get more posts when the user reaches the bottom of the page:

app/_components/Posts.tsx
"use client";
import React from "react";
import { useIntersectionObserver } from "@uidotdev/usehooks";
import { getPosts } from "../_actions/get-posts";
interface PostsProps {
initialPosts: JSX.Element[];
initialOffset: number;
}
export default function Posts(props: PostsProps) {
const [posts, setPosts] = React.useState(props.initialPosts);
const [loadMoreRef, entry] = useIntersectionObserver({
threshold: 0,
root: null,
rootMargin: "0px",
});
React.useEffect(() => {
if (entry?.isIntersecting) {
const offset = posts.length;
getPosts(offset).then(({ posts: newPosts, hasMore }) => {
if (hasMore) {
setPosts((prevPosts) => [...prevPosts, ...newPosts]);
}
});
}
}, [entry?.isIntersecting]);
17 collapsed lines
return (
<>
{posts.length > 0 ? posts : "No posts to show"}
<div
ref={loadMoreRef}
style={{
height: "20px",
display: "block",
color: "white",
fontSize: "1rem",
}}
>
Loading more...
</div>
</>
);
}

What we just did is to call the getPosts function when the user reaches the bottom of the page, and then set the new posts to the state.

Handling Pending States

Now, if we want to implement pending states, I found that we can use the useActionState hook, as it allows you to do it out of the box very easily, but this hook has one big caveat, and it is that when the action is called (the returned action that useActionState returns), it will send the current state to the server, so if we have a lot of posts, it will send all of them to the server, which is not ideal. So for this case, I wouldn’t recommend using the useActionState hook.

Fortunately we still have a way to handle the pending state, and that is by using the useTransition hook.

app/_components/Posts.tsx
9 collapsed lines
"use client";
import React from "react";
import { useIntersectionObserver } from "@uidotdev/usehooks";
import { getPosts } from "../_actions/get-posts";
interface PostsProps {
initialPosts: JSX.Element[];
initialOffset: number;
}
export default function Posts(props: PostsProps) {
const [posts, setPosts] = React.useState(props.initialPosts);
const [loadMoreRef, entry] = useIntersectionObserver({
threshold: 0,
root: null,
rootMargin: "0px",
});
const [isPending, startTransition] = React.useTransition();
React.useEffect(() => {
if (entry?.isIntersecting) {
const offset = posts.length;
getPosts(offset).then(({ posts: newPosts, hasMore }) => {
startTransition(async () => {
const { posts: newPosts, hasMore } = await getPosts(offset);
if (hasMore) {
startTransition(() => {
setPosts((prevPosts) => [...prevPosts, ...newPosts]);
});
}
});
}
}, [entry?.isIntersecting]);
return (
<>
{posts.length > 0 ? posts : "No posts to show"}
<div
ref={loadMoreRef}
style={{
height: "20px",
display: "block",
color: "white",
fontSize: "1rem",
}}
>
Loading more...
</div>
<div ref={loadMoreRef} />
{isPending ? "Loading more posts..." : ""}
</>
);
}

And there you have it! A simple implementation of infinite scrolling leveraging Server Components and Server Actions of React. You can build upon this basic framework to create more complex and robust infinite scrolling mechanisms.