Client-side pagination in vanilla JS: simple, lightweight, zero framework

The blog loads its articles from a posts.json via fetch() and renders them in vanilla JS. No framework, no build. With 7 articles it works perfectly. At 50 articles, you want pagination — for readability, not performance (the JSON stays lightweight anyway). The main constraint: no hidden elements in the DOM.

The wrong way to do it: load everything then display: none what you don't show. The right way: only render the posts for the current page. The difference seems cosmetic on a personal blog. It reflects a working habit — don't put in the DOM what you don't display.

The existing architecture

The starting setup: fetch('posts.json') loads the manifest, articles are sorted by date, then render(allPosts) writes to #posts-container via innerHTML. The filterPosts() function filters the full array by category and text search. applyFilters() is called on every interaction — click on a filter, keystroke in the search field.

The entry point for pagination is the render() function — it's the one that decides what to write to the DOM. Modifying filterPosts() isn't necessary: it keeps returning the full filtered array, without pagination. It's render() that extracts the slice from it.

The bad pattern to avoid

// ❌ Load everything, hide everything — useless and misleading
function render(posts) {
    container.innerHTML = posts.map(function(post, index) {
        return '<article style="display:' + (index < 10 ? 'block' : 'none') + '">'
            + buildCardHTML(post)
            + '</article>';
    }).join('');
}

The DOM contains 50 elements, the user sees 10. The page CSS does unnecessary work, screen readers read the hidden content, and the "lightweight loading" is an illusion. This pattern is common because it's quick to write. It's also fundamentally wrong.

The implementation

Two state variables added at module level:

var currentPage = 1;
var PAGE_SIZE = 10;

The modified render() function — it receives the full filtered array, extracts the slice for the current page, and renders only those posts:

function render(filteredPosts) {
    var container = document.getElementById('posts-container');
    var noResults = document.getElementById('no-results');
    var pagination = document.getElementById('blog-pagination');

    if (filteredPosts.length === 0) {
        container.innerHTML = '';
        noResults.style.display = '';
        pagination.innerHTML = '';
        return;
    }

    noResults.style.display = 'none';

    var totalPages = Math.ceil(filteredPosts.length / PAGE_SIZE);
    if (currentPage > totalPages) currentPage = totalPages;

    var start = (currentPage - 1) * PAGE_SIZE;
    container.innerHTML = filteredPosts
        .slice(start, start + PAGE_SIZE)
        .map(buildCardHTML)
        .join('');

    if (totalPages <= 1) { pagination.innerHTML = ''; return; }

    var prevDisabled = currentPage === 1 ? ' disabled' : '';
    var nextDisabled = currentPage === totalPages ? ' disabled' : '';
    pagination.innerHTML =
        '<button class="btn-page"' + prevDisabled + ' data-page="' + (currentPage - 1) + '">&larr; Previous</button>'
        + '<span class="page-info">Page ' + currentPage + ' of ' + totalPages + '</span>'
        + '<button class="btn-page"' + nextDisabled + ' data-page="' + (currentPage + 1) + '">Next &rarr;</button>';
}

The if (currentPage > totalPages) currentPage = totalPages in the middle of the function isn't defensive for nothing: if the user is on page 5 and does a search that only returns 3 articles (so 1 page), you need to bring currentPage back to 1 before calculating start. Otherwise slice(40, 50) on a 3-element array returns an empty array — and the container displays empty even though there are results.

Page reset on filter

When the user changes category or searches, we go back to page 1. This is the only place where currentPage is reset:

function applyFilters() {
    currentPage = 1;  // ← always page 1 on new filter
    render(filterPosts());
}

The pagination handler doesn't reset to 1 — it just updates currentPage and re-renders:

document.getElementById('blog-pagination').addEventListener('click', function (e) {
    var btn = e.target.closest('[data-page]');
    if (!btn || btn.disabled) return;
    currentPage = parseInt(btn.getAttribute('data-page'), 10);
    render(filterPosts());
    window.scrollTo({ top: 0, behavior: 'smooth' });
});

Event delegation on the #blog-pagination container — no listeners attached to individual buttons. Buttons are recreated on every render via innerHTML, so listeners attached directly would be lost on every update. Delegation avoids this cleanly: a single listener on a stable element, which survives re-renders.

What we didn't do

A few deliberate choices NOT to over-engineer:

  • No page numbering (1, 2, 3, …, N) — with 10 articles per page, "Page X of Y" is enough up to several hundred articles. Full numbering adds complexity to truncate long series (the infamous "1 2 … 5 6 7 … 12 13"). Not justified here.
  • No URL with ?page=2 — the blog is a portfolio, not a search engine. Pagination state doesn't need to be shareable or indexable by Google. Adding history.pushState() represents 3x more code for zero benefit in this context.
  • No debounce on searchposts.json is a local file already loaded in memory, filtering is synchronous and instant. Debounce is an optimization for network requests, not for Array.filter() on 50 elements.
  • No CSS transition between pages — blog content pagination is not a carousel. Direct replacement is more readable and more predictable. A fade-in animation would delay reading without providing any information.

The CSS

Consistent with the rest of the blog (Montserrat, green #3aaa64, grey borders). Disabled buttons have opacity: 0.5 and cursor: default — no JS for that, just the HTML disabled attribute which triggers CSS :disabled pseudo-classes. That's the intended platform behavior; no need to manually reimplement what the browser already does.

Conclusion

30 lines of JS. Zero dependencies. Lightweight DOM on every page. Pagination integrates cleanly into the existing flow: filterPosts() always returns the full filtered array, render() extracts the slice — the two functions remain decoupled. Adding pagination didn't require restructuring the existing code: just two state variables and a modification of render().

That's the right level of engineering for a personal blog.

Comments (0)