惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

博客园 - Franky
Apple Machine Learning Research
Apple Machine Learning Research
月光博客
月光博客
Vercel News
Vercel News
Recent Announcements
Recent Announcements
B
Blog RSS Feed
Y
Y Combinator Blog
M
MIT News - Artificial intelligence
MongoDB | Blog
MongoDB | Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
雷峰网
雷峰网
D
Docker
Jina AI
Jina AI
IT之家
IT之家
人人都是产品经理
人人都是产品经理
L
LangChain Blog
G
Google Developers Blog
Google DeepMind News
Google DeepMind News
MyScale Blog
MyScale Blog
博客园 - 叶小钗
The GitHub Blog
The GitHub Blog
The Cloudflare Blog
A
About on SuperTechFans
Hugging Face - Blog
Hugging Face - Blog

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
Client-side pagination in vanilla JS: simple, lightweight...
Odilon HUGON · 2026-05-07 · via DEV Community

Odilon HUGONNOT

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('');
}

Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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>';
}

Enter fullscreen mode Exit fullscreen mode

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());
}

Enter fullscreen mode Exit fullscreen mode

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' });
});

Enter fullscreen mode Exit fullscreen mode

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.