Server Components in Next.js fundamentally change how you approach infinite scroll, moving the heavy lifting from client-side JavaScript to the server, which can dramatically improve initial load times and user experience.

Let’s watch this unfold. Imagine a simple blog feed where each post is a card.

// app/page.js
import PostCard from './PostCard';
import LoadMoreButton from './LoadMoreButton';
import { fetchPosts } from './actions'; // Server Action

async function HomePage() {
  const initialPosts = await fetchPosts({ page: 1 });

  return (
    <main>
      <h1>My Awesome Blog</h1>
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        {initialPosts.map((post) => (
          <PostCard key={post.id} post={post} />
        ))}
      </div>
      <LoadMoreButton initialPage={1} />
    </main>
  );
}

export default HomePage;

Here, fetchPosts is a Server Action. It looks like a regular JavaScript function, but it runs on the server.

// app/actions.js
'use server';

import { prisma } from './db'; // Assume Prisma client for data fetching

export async function fetchPosts({ page, limit = 10 }) {
  const offset = (page - 1) * limit;
  const posts = await prisma.post.findMany({
    skip: offset,
    take: limit,
    orderBy: { createdAt: 'desc' },
  });
  return posts;
}

The LoadMoreButton component is where the client-side interaction happens. It uses a useOptimistic hook and a Server Action to update the UI without a full page reload.

// app/LoadMoreButton.js
'use client';

import { useState, useOptimistic, experimental_use as use } from 'react';
import { fetchPosts } from './actions';
import PostCard from './PostCard';
import { Button } from '@/components/ui/button'; // Example UI component

export default function LoadMoreButton({ initialPage }) {
  const [page, setPage] = useState(initialPage);
  const [posts, setPosts] = useState([]); // State for newly loaded posts
  const [isLoading, setIsLoading] = useState(false);

  // Use optimistic updates to show UI feedback immediately
  const [optimisticPosts, addOptimisticPost] = useOptimistic(
    posts,
    (currentPosts, newPost) => [...currentPosts, newPost] // Simple append
  );

  const loadMore = async () => {
    setIsLoading(true);
    const nextPage = page + 1;
    const newPosts = await fetchPosts({ page: nextPage });

    // Add newly fetched posts optimistically
    newPosts.forEach(post => addOptimisticPost(post));

    setPosts(prevPosts => [...prevPosts, ...newPosts]); // Update actual state
    setPage(nextPage);
    setIsLoading(false);
  };

  return (
    <div className="text-center my-8">
      <Button onClick={loadMore} disabled={isLoading}>
        {isLoading ? 'Loading More...' : 'Load More Posts'}
      </Button>
      {/* Display optimistic posts as they are added */}
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mt-6">
        {optimisticPosts.map((post) => (
          <PostCard key={post.id} post={post} />
        ))}
      </div>
    </div>
  );
}

The PostCard is a simple presentational component.

// app/PostCard.js
export default function PostCard({ post }) {
  return (
    <div className="border p-4 rounded-lg shadow-sm">
      <h3 className="text-xl font-semibold mb-2">{post.title}</h3>
      <p className="text-gray-600">{post.excerpt}</p>
      <p className="text-sm text-gray-400 mt-2">Published: {new Date(post.createdAt).toLocaleDateString()}</p>
    </div>
  );
}

The magic here is that fetchPosts runs on the server. When loadMore is called, the client sends a request to the server action. The server fetches the next batch of data and returns it. The client-side useOptimistic hook then updates the UI with the new posts, providing a smooth, app-like experience without the entire page re-rendering. The initial render is purely Server Components, meaning no JavaScript is needed to see the first set of posts.

The core idea is to decouple the data fetching from the UI rendering as much as possible. Server Components handle the data fetching and initial render, and then Client Components (like LoadMoreButton) manage the subsequent interactions and data updates, leveraging Server Actions to communicate back to the server. This hybrid approach allows you to benefit from the performance of Server Components while maintaining the interactivity of Client Components.

The counterintuitive part is how useOptimistic works with Server Actions. You’re not just displaying a loading state; you’re pre-rendering the new content before the server has even confirmed the operation, based on the assumption that the operation will succeed. This makes the UI feel incredibly responsive. The optimisticPosts array is updated instantly with the newPost data, and only after fetchPosts returns do you update the actual posts state with the confirmed data, ensuring consistency.

The next hurdle is managing the scroll position after loading more content, ensuring the user doesn’t jump back to the top of the page.

Want structured learning?

Take the full Nextjs course →