EmpireUI
Get Pro
← Blog8 min read#pagination#react#tailwind

Pagination in React: Controlled, Infinite Scroll, URL-Based

Three real pagination patterns for React — controlled state, infinite scroll, and URL-based — with code, tradeoffs, and when to pick each one.

Developer writing React pagination code on a monitor display

Why Pagination Still Matters in 2026

Every app hits the wall eventually. You've got 50,000 product rows, a blog with 400 posts, or an admin table that makes browsers cry. Pagination is how you don't ship that to your users in one giant blob.

Honestly, the reason most pagination implementations feel janky isn't the concept — it's that developers pick the wrong pattern for the job. Infinite scroll on an e-commerce product grid. Numbered pages on a live activity feed. These mismatches kill UX fast.

There are three patterns worth knowing well: controlled pagination (local state, numbered pages), infinite scroll (intersection observer), and URL-based pagination (query params, shareable state). Each one has a different job. The trick is knowing which one to reach for before you write a single line.

Controlled Pagination: Local State, Numbered Pages

This is your bread-and-butter approach. You keep currentPage in useState, slice your data client-side or pass page offsets to your API, and render a row of numbered buttons. Simple. It works for most admin dashboards and data tables where you don't need the URL to reflect the current page.

The whole thing is around 40 lines of JSX if you keep it clean. Here's a minimal working version:

import { useState } from 'react';

const PAGE_SIZE = 10;

export function ControlledPagination({ items }) {
  const [page, setPage] = useState(1);
  const totalPages = Math.ceil(items.length / PAGE_SIZE);
  const visible = items.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);

  return (
    <div className="flex flex-col gap-4">
      <ul className="divide-y divide-zinc-800">
        {visible.map((item) => (
          <li key={item.id} className="py-3 text-sm text-zinc-200">{item.label}</li>
        ))}
      </ul>
      <div className="flex items-center gap-2">
        <button
          onClick={() => setPage((p) => Math.max(1, p - 1))}
          disabled={page === 1}
          className="px-3 py-1.5 rounded-md bg-zinc-800 text-zinc-300 disabled:opacity-40 text-sm"
        >
          Prev
        </button>
        {Array.from({ length: totalPages }, (_, i) => (
          <button
            key={i + 1}
            onClick={() => setPage(i + 1)}
            className={`px-3 py-1.5 rounded-md text-sm ${
              page === i + 1
                ? 'bg-indigo-600 text-white'
                : 'bg-zinc-800 text-zinc-300'
            }`}
          >
            {i + 1}
          </button>
        ))}
        <button
          onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
          disabled={page === totalPages}
          className="px-3 py-1.5 rounded-md bg-zinc-800 text-zinc-300 disabled:opacity-40 text-sm"
        >
          Next
        </button>
      </div>
    </div>
  );
}

Worth noting: if you're rendering more than 7-8 page buttons, add an ellipsis — displaying 40 buttons in a row at 32px each is a layout disaster. Truncate to [1, ..., currentPage-1, currentPage, currentPage+1, ..., last].

One more thing — this pattern loses its state on navigation. User goes to page 5, clicks a row, hits back: they're back on page 1. If that matters to your users, keep reading.

URL-Based Pagination: Shareable, Back-Button Friendly

URL-based pagination syncs your current page into the query string — ?page=3 — so users can bookmark it, share it, and navigate back without losing their place. It's the right call for any public-facing list: blog archives, search results, product listings.

In React Router v6 or Next.js App Router, reading and writing query params is straightforward. With Next.js, you'd use useSearchParams and useRouter:

'use client';
import { useRouter, useSearchParams } from 'next/navigation';

export function UrlPagination({ totalPages }) {
  const router = useRouter();
  const params = useSearchParams();
  const page = Number(params.get('page') ?? '1');

  const goTo = (p) => {
    const next = new URLSearchParams(params.toString());
    next.set('page', String(p));
    router.push(`?${next.toString()}`);
  };

  return (
    <div className="flex gap-2">
      <button
        onClick={() => goTo(Math.max(1, page - 1))}
        disabled={page === 1}
        className="px-4 py-2 rounded-lg bg-zinc-900 text-zinc-300 disabled:opacity-40"
      >
        &larr; Prev
      </button>
      <span className="px-4 py-2 text-sm text-zinc-400">Page {page} of {totalPages}</span>
      <button
        onClick={() => goTo(Math.min(totalPages, page + 1))}
        disabled={page === totalPages}
        className="px-4 py-2 rounded-lg bg-zinc-900 text-zinc-300 disabled:opacity-40"
      >
        Next &rarr;
      </button>
    </div>
  );
}

In practice, this pattern pairs beautifully with server components. You pass page from searchParams directly to your data-fetching function, no client state needed. The URL is the state.

That said, always copy existing params before setting page. If your URL already has ?q=shoes&sort=asc and you do router.push('?page=2'), you just nuked the user's filters. Build a new URLSearchParams from the existing params first — the snippet above handles this correctly.

Infinite Scroll: IntersectionObserver, No Scroll Events

Infinite scroll is the right call for feeds, activity streams, and content-heavy lists where numbered pages would feel weird. It's the wrong call for e-commerce — users can't go back to where they were, which tanks conversion.

The implementation you want uses IntersectionObserver, not a scroll event listener. Scroll listeners fire constantly and tank performance. An observer fires once when a sentinel element enters the viewport. Here's the pattern:

import { useRef, useEffect, useState } from 'react';

export function InfiniteList({ fetchPage }) {
  const [items, setItems] = useState([]);
  const [page, setPage] = useState(1);
  const [hasMore, setHasMore] = useState(true);
  const sentinelRef = useRef(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      async ([entry]) => {
        if (entry.isIntersecting && hasMore) {
          const newItems = await fetchPage(page);
          if (newItems.length === 0) { setHasMore(false); return; }
          setItems((prev) => [...prev, ...newItems]);
          setPage((p) => p + 1);
        }
      },
      { rootMargin: '200px' } // load 200px before the user hits the bottom
    );
    if (sentinelRef.current) observer.observe(sentinelRef.current);
    return () => observer.disconnect();
  }, [page, hasMore, fetchPage]);

  return (
    <div>
      <ul className="divide-y divide-zinc-800">
        {items.map((item) => (
          <li key={item.id} className="py-3 text-zinc-200">{item.label}</li>
        ))}
      </ul>
      <div ref={sentinelRef} className="h-4" />
      {!hasMore && <p className="text-center text-zinc-500 py-4">You've reached the end.</p>}
    </div>
  );
}

Quick aside: the rootMargin: '200px' kicks the fetch off 200 pixels before the sentinel hits the fold. This hides loading latency almost entirely on a fast connection. Tune it down if your content is heavy.

If you want something pre-built and polished, browse the components at Empire UI — the pagination and infinite scroll components are styled and ready to drop in without starting from scratch.

Styling Pagination with Tailwind: The Details That Matter

The visual difference between a pagination component that feels professional and one that feels thrown together is usually 3 things: active state contrast, disabled state opacity, and focus rings.

Active page buttons need a clear visual distinction from inactive ones — bg-indigo-600 on the active page vs bg-zinc-800 on inactive works well on dark UIs. Go at least 40% contrast difference. Disabled states (disabled:opacity-40) telegraph to users that the action is blocked without hiding the button.

Focus rings matter for keyboard navigation. Add focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-zinc-950 to every pagination button. It's invisible to mouse users but saves keyboard users from guessing where they are.

Look, most pagination components in the wild skip the gap between the button border and the page background's ring offset — you get a muddy halo. Getting ring-offset-color to match your actual background color fixes this instantly. If your page is bg-zinc-950, set ring-offset-zinc-950. One line. Big difference.

Choosing the Right Pattern: A Decision Framework

So how do you actually pick? Ask three questions: Does the user need to share or bookmark this page? Does navigation history matter (back button)? Is the total count meaningful to the user?

If any answer is yes — go URL-based. If you're building a feed or stream where position is meaningless — infinite scroll. For everything else, controlled local state is fine and you'll ship faster.

One more thing — you can mix patterns. A search results page might use URL-based pagination for numbered pages but switch to infinite scroll on mobile. Read the viewport width from a hook, render conditionally. It's more work but some product teams genuinely want this.

If you're already building with a design system, check if your component library handles this for you. Empire UI ships pagination components that support all three approaches with Tailwind-based theming. Saves you from reinventing the disabled-state logic for the fourth time this year.

Accessibility and SEO Considerations

URL-based pagination wins on SEO by default — crawlers follow ?page=2 links and index the content at each URL. For infinite scroll, you'd need to implement a static paginated fallback or use JavaScript rendering. Most teams don't bother and their content past page 1 just doesn't get indexed.

For accessibility, your pagination nav should be wrapped in a <nav aria-label="Pagination"> element. Each page button needs an aria-label like aria-label="Go to page 3" — numeric buttons alone don't give screen readers enough context. The active page should have aria-current="page".

Worth noting: when you programmatically change pages, focus management matters. If you're replacing a list of items, move focus to the top of the new list or to a status region (aria-live="polite") that announces the page change. Users on keyboard or screen readers otherwise have no idea the content changed.

For more on building accessible, well-styled React components, the glassmorphism components page shows how Empire UI handles focus, contrast, and motion preferences in a real design-system context. Same principles apply to pagination.

FAQ

Should I use URL-based pagination or local state for a Next.js app?

URL-based pagination is almost always the better call in Next.js — it works with server components, it's bookmarkable, and the back button works correctly. Use local state only for internal tools where shareability doesn't matter.

How do I prevent infinite scroll from triggering multiple simultaneous fetches?

Add a loading ref or flag that you check before firing the fetch, and disconnect the observer while a fetch is in-flight. Re-observe the sentinel after the fetch resolves. Without this guard, fast scrolls can stack multiple identical requests.

What's the best page size for a pagination component?

10-25 items per page covers most use cases. Below 10 feels fragmented; above 25 and you're often back to the same performance problem you were trying to avoid. Match it to how dense your row UI is.

Does infinite scroll hurt SEO?

Yes, if you don't add a fallback. Googlebot doesn't scroll, so content loaded dynamically below the fold often won't get indexed. Either render a paginated static fallback or use URL-based pagination for content you actually want crawled.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Timeline Component in React: Vertical, Horizontal, AlternatingPricing Table React Component: 3-Tier, Annual Toggle, HighlightWhat Is Glassmorphism? A Free React + Tailwind Guide10 Tailwind Component Patterns Every Developer Should Know