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) + '">← Previous</button>'
+ '<span class="page-info">Page ' + currentPage + ' of ' + totalPages + '</span>'
+ '<button class="btn-page"' + nextDisabled + ' data-page="' + (currentPage + 1) + '">Next →</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. Addinghistory.pushState()represents 3x more code for zero benefit in this context. -
No debounce on search —
posts.jsonis a local file already loaded in memory, filtering is synchronous and instant. Debounce is an optimization for network requests, not forArray.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.