Next.js App Router Pagination and Infinite Scroll: Complete Working Solution Guide

Next.js App Router Pagination and Infinite Scroll: Complete Working Solution Guide

|By Malik Saqib

Pagination and infinite scroll are fundamental patterns for displaying large datasets in web applications. But if you're using Next.js App Router, you've probably encountered frustrating issues where these features simply don't work as expected. The data doesn't load, the scroll event fires endlessly, or the UI breaks completely during server-side rendering. These aren't bugs in Next.js—they're architectural challenges that require understanding how App Router handles data fetching differently from Pages Router.

This comprehensive guide walks you through exactly what breaks, why it breaks, and most importantly, how to fix it with working code examples. Whether you're building pagination or infinite scroll, you'll find production-ready solutions that handle caching, hydration, and performance correctly.

Introduction: Why Pagination and Infinite Scroll Break in App Router

The Next.js App Router introduced a new paradigm: server components by default with the ability to opt into client components where needed. This fundamental shift breaks many traditional pagination and infinite scroll implementations because developers often copy patterns from Pages Router or client-side frameworks that don't account for server-side rendering, caching, and component boundaries.

When pagination or infinite scroll stops working in App Router, it's usually because:

  • Server component data doesn't re-fetch when you expect it to: Server components render on every request, but caching can prevent fresh data
  • Client component state becomes out of sync: Fetching data in a client component while the parent is a server component creates hydration mismatches
  • API route interactions conflict with caching: The built-in caching layers prevent fresh data from reaching your UI
  • Scroll events fire but nothing happens: The intersection observer isn't properly connected to state management

Understanding these causes is the first step to building pagination and infinite scroll that actually works.

Understanding Pagination vs Infinite Scroll: When to Use Each

Before solving technical problems, let's clarify what you're trying to build.

Pagination uses numbered pages or next/previous buttons to load data in chunks. Users control when new data loads. This approach works well when users want to jump to specific pages, return to a previous page, or have a sense of total progress through a dataset. E-commerce sites, search results, and admin dashboards typically use pagination.

Infinite scroll automatically loads more data as users scroll toward the bottom of the page. It feels seamless and modern but can be disorienting because users lose track of how much content they've seen. Social media feeds, image galleries, and discovery-focused applications typically use infinite scroll.

From a UX perspective, pagination provides better control and is easier to implement accessibly. Infinite scroll feels smoother but can hurt performance on large datasets and makes SEO harder because search engines can't easily access paginated content.

From a technical perspective, pagination is simpler to implement—you fetch a specific page number. Infinite scroll requires tracking scroll position, managing loading states, and preventing duplicate requests.

For most business applications, pagination is the recommended approach. Infinite scroll is best for content discovery applications where users browse casually rather than search intentionally.

Why Pagination and Infinite Scroll Don't Work in App Router

Let's identify the specific issues that break these patterns in Next.js App Router.

The Caching Problem

Next.js App Router caches fetch requests by default. This is great for performance but terrible for pagination because when you fetch the same endpoint multiple times with different parameters, the cache returns the same data:

// app/api/posts/route.js
export async function GET(request) {
  const { searchParams } = new URL(request.url);
  const page = searchParams.get("page") || 1;
 
  // This request gets cached!
  // Requesting page 1 then page 2 returns the same data
  const response = await fetch(`https://api.example.com/posts?page=${page}`);
  return Response.json(await response.json());
}

The cache doesn't distinguish between ?page=1 and ?page=2—they're treated as the same request. For pagination to work, you must explicitly disable caching.

Server vs Client Component Boundary Issues

Mixing server and client components incorrectly causes hydration mismatches:

// ❌ This breaks
// app/posts/page.js (Server Component)
export default async function PostsPage() {
  const posts = await fetch('https://api.example.com/posts?page=1');
 
  return <PaginationUI posts={posts} />; // PaginationUI tries to re-fetch
}
 
// components/PaginationUI.js (Client Component)
'use client';
export default function PaginationUI({ posts }) {
  // posts prop won't work as expected with client-side pagination
}

The server component fetches data once, but the client component expects to re-fetch when the user clicks "next page." This architectural mismatch breaks pagination.

Intersection Observer Without Proper State Management

Infinite scroll typically uses Intersection Observer API to detect when users scroll to the bottom:

// ❌ This causes infinite requests
"use client";
import { useEffect, useRef } from "react";
 
export default function InfiniteScroll() {
  const observerTarget = useRef(null);
 
  useEffect(() => {
    const observer = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting) {
        // This fires multiple times, creating race conditions
        fetch("/api/posts?page=2");
      }
    });
 
    if (observerTarget.current) {
      observer.observe(observerTarget.current);
    }
  }, []); // No dependency on loading state!
 
  return <div ref={observerTarget}>Loading...</div>;
}

Without proper state management and loading flags, the intersection observer fires multiple requests simultaneously, overwhelming your API.

App Router Architecture Explained

To fix these issues, you need to understand how App Router structures files and components:

app/
  page.js                    # Root page (server component by default)
  layout.js                  # Layout wrapper
  api/
    posts/
      route.js              # API endpoint (server-only)
  posts/
    page.js                 # Posts listing page
    loading.js              # Loading UI
    error.js                # Error UI
components/
  PaginationUI.js           # Client component for pagination controls
  PostsList.js              # Client component for displaying posts

Server Components (app/page.js) run on the server and can directly access databases and APIs. They don't have access to browser APIs like useEffect or useState. They're cached by default.

Client Components ('use client' directive) run in the browser and have access to browser APIs but can't directly access server resources. They're not cached.

API Routes (app/api/posts/route.js) are server-only endpoints that handle HTTP requests. They follow standard HTTP semantics.

The key insight: For pagination and infinite scroll to work, you typically need a client component that manages state and makes requests to an API route, while keeping server components minimal.

Build a Working Pagination Example

Let's build functioning pagination step by step.

Step 1: Create the API Endpoint

// app/api/posts/route.js
export async function GET(request) {
  const { searchParams } = new URL(request.url);
  const page = parseInt(searchParams.get("page") || "1");
  const limit = parseInt(searchParams.get("limit") || "10");
 
  // CRITICAL: Disable caching for pagination
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=${limit}`,
    {
      // Don't cache this endpoint
      cache: "no-store",
      // Or use revalidation
      // next: { revalidate: 0 }
    }
  );
 
  if (!response.ok) {
    return Response.json(
      { error: "Failed to fetch posts" },
      { status: response.status }
    );
  }
 
  const posts = await response.json();
  const total = response.headers.get("x-total-count") || "100";
 
  return Response.json({
    posts,
    total: parseInt(total),
    page,
    totalPages: Math.ceil(parseInt(total) / limit),
  });
}

The critical part: cache: 'no-store' tells Next.js not to cache this endpoint, ensuring each request gets fresh data.

Step 2: Fetch Logic in a Client Component

// components/PaginationUI.js
"use client";
 
import { useState, useEffect } from "react";
 
export default function PaginationUI() {
  const [posts, setPosts] = useState([]);
  const [page, setPage] = useState(1);
  const [totalPages, setTotalPages] = useState(1);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
 
  const LIMIT = 10;
 
  // Fetch posts when page changes
  useEffect(() => {
    const fetchPosts = async () => {
      setLoading(true);
      setError(null);
 
      try {
        const response = await fetch(`/api/posts?page=${page}&limit=${LIMIT}`);
 
        if (!response.ok) {
          throw new Error("Failed to fetch posts");
        }
 
        const data = await response.json();
        setPosts(data.posts);
        setTotalPages(data.totalPages);
      } catch (err) {
        setError(err.message);
        console.error("Fetch error:", err);
      } finally {
        setLoading(false);
      }
    };
 
    fetchPosts();
  }, [page, LIMIT]);
 
  const handleNextPage = () => {
    if (page < totalPages) {
      setPage((prev) => prev + 1);
      // Scroll to top
      window.scrollTo({ top: 0, behavior: "smooth" });
    }
  };
 
  const handlePrevPage = () => {
    if (page > 1) {
      setPage((prev) => prev - 1);
      window.scrollTo({ top: 0, behavior: "smooth" });
    }
  };
 
  return (
    <div className="pagination-container">
      {error && <div className="error-message">Error: {error}</div>}
 
      {loading ? (
        <div className="loading">Loading posts...</div>
      ) : (
        <div className="posts-list">
          {posts.map((post) => (
            <article key={post.id} className="post-item">
              <h2>{post.title}</h2>
              <p>{post.body}</p>
            </article>
          ))}
        </div>
      )}
 
      <div className="pagination-controls">
        <button
          onClick={handlePrevPage}
          disabled={page === 1 || loading}
          aria-label="Previous page"
        >
          ← Previous
        </button>
 
        <span className="page-info">
          Page {page} of {totalPages}
        </span>
 
        <button
          onClick={handleNextPage}
          disabled={page >= totalPages || loading}
          aria-label="Next page"
        >
          Next →
        </button>
      </div>
    </div>
  );
}

Step 3: Use the Component in Your Page

// app/posts/page.js
import PaginationUI from "@/components/PaginationUI";
 
export const metadata = {
  title: "Posts with Pagination",
  description: "Browse posts with working pagination",
};
 
export default function PostsPage() {
  return (
    <main className="posts-page">
      <h1>Posts</h1>
      <PaginationUI />
    </main>
  );
}

This architecture works because the page is a server component that renders the client component (PaginationUI). The client component manages its own state and makes requests to the API endpoint. There's a clear boundary between server and client concerns.

Convert Pagination to Infinite Scroll

Now let's build infinite scroll using the same API endpoint but different client logic.

// components/InfiniteScroll.js
"use client";
 
import { useState, useEffect, useRef, useCallback } from "react";
 
export default function InfiniteScroll() {
  const [posts, setPosts] = useState([]);
  const [page, setPage] = useState(1);
  const [hasMore, setHasMore] = useState(true);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
 
  const observerTarget = useRef(null);
  const isLoadingRef = useRef(false);
  const LIMIT = 10;
 
  // Fetch posts
  const fetchPosts = useCallback(async (pageNum) => {
    // Prevent duplicate requests
    if (isLoadingRef.current) return;
 
    isLoadingRef.current = true;
    setLoading(true);
 
    try {
      const response = await fetch(`/api/posts?page=${pageNum}&limit=${LIMIT}`);
 
      if (!response.ok) {
        throw new Error("Failed to fetch posts");
      }
 
      const data = await response.json();
 
      // For infinite scroll, append to existing posts
      if (pageNum === 1) {
        setPosts(data.posts);
      } else {
        setPosts((prev) => [...prev, ...data.posts]);
      }
 
      setHasMore(pageNum < data.totalPages);
    } catch (err) {
      setError(err.message);
      console.error("Fetch error:", err);
    } finally {
      isLoadingRef.current = false;
      setLoading(false);
    }
  }, []);
 
  // Initial fetch
  useEffect(() => {
    fetchPosts(1);
  }, [fetchPosts]);
 
  // Intersection Observer for infinite scroll
  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        const [entry] = entries;
 
        // Load more when observer target becomes visible
        if (entry.isIntersecting && hasMore && !loading) {
          setPage((prev) => prev + 1);
        }
      },
      {
        rootMargin: "100px", // Start loading 100px before reaching bottom
      }
    );
 
    if (observerTarget.current) {
      observer.observe(observerTarget.current);
    }
 
    return () => {
      if (observerTarget.current) {
        observer.unobserve(observerTarget.current);
      }
    };
  }, [hasMore, loading]);
 
  // Fetch when page changes
  useEffect(() => {
    if (page > 1) {
      fetchPosts(page);
    }
  }, [page, fetchPosts]);
 
  return (
    <div className="infinite-scroll-container">
      {error && <div className="error-message">Error: {error}</div>}
 
      <div className="posts-grid">
        {posts.map((post) => (
          <article key={post.id} className="post-card">
            <h2>{post.title}</h2>
            <p>{post.body}</p>
          </article>
        ))}
      </div>
 
      {loading && (
        <div className="loading-indicator">Loading more posts...</div>
      )}
 
      {hasMore && !loading && (
        <div
          ref={observerTarget}
          className="observer-target"
          aria-label="Load more posts"
        />
      )}
 
      {!hasMore && <div className="end-message">No more posts to load</div>}
    </div>
  );
}

The key differences from pagination:

  1. Posts are appended instead of replaced: setPosts(prev => [...prev, ...data.posts])
  2. Intersection Observer triggers automatic loading: when users scroll near the bottom, new posts load
  3. Loading state is checked: isLoadingRef prevents duplicate concurrent requests
  4. Root margin (100px) starts loading before reaching the absolute bottom for smoother UX

Real Errors Developers Hit and How to Fix Them

Let's address specific problems you'll encounter and their solutions.

1. "List Resets After Scroll"

Problem: When users scroll back to the top and scroll down again, the list resets to page 1.

Cause: Using a key-based approach that recreates components.

Solution:

// ❌ Wrong - causes list to reset
{
  posts.map((post, index) => (
    <div key={index}>... </div> // Never use index as key!
  ));
}
 
// ✅ Correct - maintains list integrity
{
  posts.map((post) => (
    <div key={post.id}>... </div> // Use stable unique ID
  ));
}

Additionally, store the posts array in a ref to persist across re-renders:

const postsRef = useRef([]);
 
const fetchPosts = async (pageNum) => {
  const data = await fetchPostsFromAPI(pageNum);
 
  if (pageNum === 1) {
    postsRef.current = data.posts;
  } else {
    postsRef.current = [...postsRef.current, ...data.posts];
  }
 
  setPosts(postsRef.current);
};

2. "SSR Cache Returns Old Data"

Problem: Users see stale data that doesn't update.

Cause: Fetch requests are cached by default in App Router.

Solution:

// app/api/posts/route.js
 
// Option 1: Disable caching completely
export async function GET(request) {
  const response = await fetch("...", {
    cache: "no-store",
  });
  return Response.json(await response.json());
}
 
// Option 2: Revalidate after specific time
export async function GET(request) {
  const response = await fetch("...", {
    next: { revalidate: 60 }, // Revalidate after 60 seconds
  });
  return Response.json(await response.json());
}
 
// Option 3: Use ISR (Incremental Static Regeneration)
export async function GET(request) {
  const data = await fetch("...", {
    next: { revalidate: 3600 }, // Revalidate every hour
  });
  return Response.json(await data.json());
}

For pagination specifically, use cache: 'no-store' to ensure fresh data on every request.

3. "Scroll Jumps to Top"

Problem: When loading new posts, the page jumps to the top.

Cause: React re-renders and browser repositions focus.

Solution:

// components/InfiniteScroll.js
"use client";
 
import { useEffect, useRef } from "react";
 
export default function InfiniteScroll() {
  const containerRef = useRef(null);
  const scrollPositionRef = useRef(0);
 
  useEffect(() => {
    // Save scroll position before updates
    const handleScroll = () => {
      scrollPositionRef.current = window.scrollY;
    };
 
    window.addEventListener("scroll", handleScroll);
    return () => window.removeEventListener("scroll", handleScroll);
  }, []);
 
  useEffect(() => {
    // Restore scroll position after updates
    const timer = setTimeout(() => {
      if (scrollPositionRef.current > 0) {
        window.scrollTo(0, scrollPositionRef.current);
      }
    }, 0);
 
    return () => clearTimeout(timer);
  }, [posts]);
 
  return <div ref={containerRef}>...</div>;
}

Alternatively, use CSS to prevent jumps:

html {
  scroll-behavior: auto; /* Disable smooth scroll during updates */
}
 
/* Re-enable after load */
html.loaded {
  scroll-behavior: smooth;
}

4. "Hydration Mismatch"

Problem: Error message: "Hydration failed because the initial UI does not match what was rendered on the server."

Cause: Server and client render different content.

Solution:

// ❌ Wrong - server renders nothing, client renders data
export default function Posts() {
  const [posts, setPosts] = useState([]);
 
  useEffect(() => {
    fetch('/api/posts').then(r => r.json()).then(setPosts);
  }, []);
 
  // Server renders empty, client renders posts - mismatch!
  return posts.map(p => <div key={p.id}>{p.title}</div>);
}
 
// ✅ Correct - use suppressHydrationWarning for dynamic content
export default function Posts() {
  const [posts, setPosts] = useState([]);
  const [isClient, setIsClient] = useState(false);
 
  useEffect(() => {
    setIsClient(true);
    fetch('/api/posts').then(r => r.json()).then(setPosts);
  }, []);
 
  if (!isClient) {
    return <div>Loading...</div>; // Server rendering
  }
 
  return posts.map(p => <div key={p.id}>{p.title}</div>);
}

Or use a loading skeleton that matches server output:

export default function Posts() {
  const [posts, setPosts] = useState([]);
  const [hydrated, setHydrated] = useState(false);
 
  useEffect(() => {
    setHydrated(true);
    fetch("/api/posts")
      .then((r) => r.json())
      .then(setPosts);
  }, []);
 
  if (!hydrated) {
    // Render skeleton that matches server render
    return (
      <div className="posts-skeleton">
        {[...Array(5)].map((_, i) => (
          <div key={i} className="skeleton-item" />
        ))}
      </div>
    );
  }
 
  return posts.map((p) => <div key={p.id}>{p.title}</div>);
}

Best Practices for Pagination and Infinite Scroll

Caching Strategies

// app/api/posts/route.js
 
export async function GET(request) {
  const { searchParams } = new URL(request.url);
  const page = searchParams.get("page");
 
  // First page can be cached (usually doesn't change frequently)
  if (page === "1" || !page) {
    const response = await fetch("...", {
      next: { revalidate: 300 }, // Cache for 5 minutes
    });
    return Response.json(await response.json());
  }
 
  // Later pages should be fresh
  const response = await fetch("...", {
    cache: "no-store",
  });
  return Response.json(await response.json());
}

Loading States and Skeleton UI

// components/SkeletonCard.js
export function SkeletonCard() {
  return (
    <div className="skeleton-card">
      <div className="skeleton skeleton-title" />
      <div className="skeleton skeleton-text" />
      <div className="skeleton skeleton-text" />
    </div>
  );
}
 
// components/InfiniteScroll.js
{
  loading && (
    <div className="loading-section">
      {[...Array(5)].map((_, i) => (
        <SkeletonCard key={`skeleton-${i}`} />
      ))}
    </div>
  );
}

CSS for skeleton animation:

.skeleton {
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: loading 1.5s infinite;
}
 
@keyframes loading {
  0% {
    background-position: 200% 0;
  }
  100% {
    background-position: -200% 0;
  }
}

Performance Optimization Tips

1. Lazy Loading Images

// components/PostCard.js
export default function PostCard({ post }) {
  return (
    <article>
      <img src={post.image} loading="lazy" decoding="async" alt={post.title} />
      <h2>{post.title}</h2>
    </article>
  );
}

2. Request Batching

// Combine multiple requests
const fetchPostsAndComments = async (postIds) => {
  const response = await fetch("/api/posts/batch", {
    method: "POST",
    body: JSON.stringify({ ids: postIds }),
  });
  return response.json();
};
 
// app/api/posts/batch/route.js
export async function POST(request) {
  const { ids } = await request.json();
 
  // Fetch all at once instead of individual requests
  const posts = await Promise.all(
    ids.map((id) => fetch(`https://api.example.com/posts/${id}`))
  );
 
  return Response.json(posts);
}

3. Client-Side Caching

// components/InfiniteScroll.js
const cacheRef = useRef(new Map());
 
const fetchPosts = useCallback(async (pageNum) => {
  const cacheKey = `page-${pageNum}`;
 
  // Check cache first
  if (cacheRef.current.has(cacheKey)) {
    const cachedData = cacheRef.current.get(cacheKey);
    setPosts(cachedData.posts);
    return;
  }
 
  const response = await fetch(`/api/posts?page=${pageNum}`);
  const data = await response.json();
 
  // Store in cache
  cacheRef.current.set(cacheKey, data);
  setPosts(data.posts);
}, []);

For more advanced optimization techniques and modern development approaches, check out our guide on AI in web development to discover tools that can help automate performance testing and optimization.

Related Resources and Further Learning

As you implement pagination and infinite scroll, you might encounter related challenges. Our comprehensive guides cover:

These resources dive deeper into the architectural patterns that make pagination and infinite scroll maintainable long-term.

Conclusion: Building Pagination and Infinite Scroll That Actually Works

The key to fixing pagination and infinite scroll in Next.js App Router is understanding the architectural differences from Pages Router and client-side frameworks. Server components, caching, and component boundaries create challenges, but once you understand them, the solutions are straightforward.

Here's your implementation checklist:

  1. For Pagination:

    • Disable caching on your API endpoint (cache: 'no-store')
    • Use a client component to manage page state
    • Fetch fresh data whenever the page number changes
    • Scroll to top when moving between pages
  2. For Infinite Scroll:

    • Use Intersection Observer to detect when users reach the bottom
    • Append new posts instead of replacing existing ones
    • Use isLoadingRef to prevent duplicate requests
    • Add rootMargin to start loading before reaching the absolute bottom
  3. For Both:

    • Always use stable IDs as React keys
    • Handle hydration mismatches with client-side checks
    • Implement proper loading states and skeleton UI
    • Add error handling and user feedback

The patterns shown in this guide handle the most common issues developers encounter. Apply them to your specific use case, and you'll have pagination and infinite scroll that work reliably across server and client boundaries.

Start with the pagination example, get it working completely, then convert it to infinite scroll. This approach helps you understand each pattern independently before combining them. Test thoroughly on real devices, monitor your API usage, and adjust caching strategies based on your specific performance requirements.

Your users will appreciate the smooth, performant data loading experience you've built.

Author

Malik Saqib

I craft short, practical AI & web dev articles. Follow me on LinkedIn.