≡ Menu

Master Server-Side Pagination with Vanilla JavaScript

When building modern web applications, handling large datasets efficiently is a must-have skill. If you try to load 1,000 items at once, your page will lag, and your users will leave. The solution is Server-Side Pagination.

In this tutorial, we’ll build a robust pagination system that fetches data from a live API, handles dynamic button generation, and includes “smart” ellipsis (...) for a professional UI.


1. The Strategy: “The Sliding Window”

Instead of showing every page button, we use a “Sliding Window” logic. This keeps the UI clean by only showing:

  1. The First and Last pages.

  2. A small Range (2 pages) around the current page.

  3. Ellipses (…) to bridge the gaps.


2. The Full Implementation

Here is the complete, single-file solution. You can save this as index.html and run it immediately.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Professional API Pagination</title>
    <style>
        :root { --primary: #007bff; --bg: #f4f7f6; --text: #333; }
        body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: var(--bg); color: var(--text); padding: 40px 20px; max-width: 700px; margin: auto; }
        
        h2 { text-align: center; color: #444; }
        
        /* List Styling */
        #list { list-style: none; padding: 0; min-height: 400px; }
        .post-item { background: #fff; border: 1px solid #ddd; margin: 10px 0; padding: 15px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); transition: transform 0.2s; }
        .post-item:hover { transform: translateY(-2px); }
        .post-item h3 { margin: 0 0 8px 0; font-size: 1.1rem; color: var(--primary); }
        
        /* Controls Styling */
        .controls { display: flex; gap: 8px; justify-content: center; align-items: center; margin-top: 30px; flex-wrap: wrap; }
        button { padding: 8px 16px; border: 1px solid var(--primary); background: #fff; color: var(--primary); cursor: pointer; border-radius: 4px; font-weight: 600; }
        button:hover:not(:disabled) { background: var(--primary); color: white; }
        button.active { background: var(--primary); color: white; }
        button:disabled { border-color: #ccc; color: #ccc; cursor: not-allowed; }
        .dots { color: #888; padding: 0 5px; font-weight: bold; }
        
        .loading { text-align: center; font-weight: bold; color: var(--primary); display: none; }
    </style>
</head>
<body>

    <h2>Published Posts</h2>
    <div id="loading" class="loading">Fetching Data...</div>
    <ul id="list"></ul>

    <div class="controls">
        <button id="prevBtn">Previous</button>
        <div id="page-numbers"></div>
        <button id="nextBtn">Next</button>
    </div>

    <script>
        // State Management
        let currentPage = 1;
        const itemsPerPage = 6;
        let totalPages = 0;

        // DOM Elements
        const listEl = document.getElementById('list');
        const pageNumbersEl = document.getElementById('page-numbers');
        const loadingEl = document.getElementById('loading');

        /**
         * Fetches data from API using page and limit parameters
         */
        async function fetchPosts(page) {
            loadingEl.style.display = 'block';
            listEl.style.opacity = '0.5';
            
            try {
                const response = await fetch(`https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=${itemsPerPage}`);
                const data = await response.json();

                // Calculate total pages from the API header
                const totalCount = response.headers.get('x-total-count');
                totalPages = Math.ceil(totalCount / itemsPerPage);

                renderPosts(data);
                renderButtons();
            } catch (error) {
                listEl.innerHTML = `<p style="color:red">Error loading data. Please try again.</p>`;
            } finally {
                loadingEl.style.display = 'none';
                listEl.style.opacity = '1';
            }
        }

        /**
         * Renders the list of posts
         */
        function renderPosts(posts) {
            listEl.innerHTML = posts.map(post => `
                <li class="post-item">
                    <h3>${post.id}. ${post.title.substring(0, 40)}...</h3>
                    <p>${post.body.substring(0, 80)}...</p>
                </li>
            `).join('');
        }

        /**
         * Renders the smart pagination buttons with ellipses
         */
        function renderButtons() {
            pageNumbersEl.innerHTML = '';
            const range = 1; // How many pages to show around the current page
            let lastRenderedPage = 0;

            for (let i = 1; i <= totalPages; i++) {
                // Logic: Always show first, last, and the range around current page
                const isFirstOrLast = i === 1 || i === totalPages;
                const isWithinRange = i >= currentPage - range && i <= currentPage + range;

                if (isFirstOrLast || isWithinRange) {
                    
                    // If there's a gap, add dots
                    if (lastRenderedPage && i - lastRenderedPage > 1) {
                        const dots = document.createElement('span');
                        dots.innerText = '...';
                        dots.className = 'dots';
                        pageNumbersEl.appendChild(dots);
                    }

                    const btn = document.createElement('button');
                    btn.innerText = i;
                    btn.className = i === currentPage ? 'active' : '';
                    btn.onclick = () => {
                        currentPage = i;
                        fetchPosts(i);
                        window.scrollTo({ top: 0, behavior: 'smooth' });
                    };

                    pageNumbersEl.appendChild(btn);
                    lastRenderedPage = i;
                }
            }

            // Update disabled state for arrows
            document.getElementById('prevBtn').disabled = currentPage === 1;
            document.getElementById('nextBtn').disabled = currentPage === totalPages;
        }

        // Arrow Button Listeners
        document.getElementById('prevBtn').onclick = () => {
            if (currentPage > 1) fetchPosts(--currentPage);
        };

        document.getElementById('nextBtn').onclick = () => {
            if (currentPage < totalPages) fetchPosts(++currentPage);
        };

        // Initial Load
        fetchPosts(currentPage);
    </script>
</body>
</html>

3. How the “Smart Dots” Logic Works

The magic happens in the renderButtons function. We track the lastRenderedPage. If the current page i we are about to draw is more than 1 step away from the lastRenderedPage, we know there is a gap.

For example, if we just drew page 1 and the next page the logic allows is page 8, the script sees that 8 - 1 > 1 and injects the ... span automatically.


4. Key Takeaways

  • Performance: We only fetch 6 items at a time, making the app incredibly light.

  • Scalability: This code works just as well for 1,000 pages as it does for 5.

  • User Experience: We included window.scrollTo so that when a user clicks a page, they are taken back to the top of the list.

Useful links below:

Let me & my team build you a money making website/blog for your business https://bit.ly/tnrwebsite_service

Get Bluehost hosting for as little as $1.99/month (save 75%)…https://bit.ly/3C1fZd2

Best email marketing automation solution on the market! http://www.aweber.com/?373860

Build high converting sales funnels with a few simple clicks of your mouse! https://bit.ly/484YV29

Join my Patreon for one-on-one coaching and help with your coding…https://www.patreon.com/c/TyronneRatcliff

Buy me a coffee ☕️https://buymeacoffee.com/tyronneratcliff

{ 0 comments… add one }

Leave a Comment