Choose one source of truth
Pagination has two related jobs: show the correct slice of a list and show which page is selected. If those values are stored separately, they can drift apart. A control might say “Page 2” while the list still shows Page 1. For a list that people may want to bookmark or revisit, the URL query parameter is a useful source of truth. The list and controls can both be rendered from its page value.
This example uses plain JavaScript in a modern browser. It can be pasted into the browser console on a page where you can run scripts. It creates its own sample list, so no library or existing page markup is required. It uses the History API to change the URL without a reload and listens for the popstate event to respond to the browser’s Back and Forward buttons. On a site that already uses a router, let the router manage URL changes instead of adding a second history handler.
Read, validate, and bound the page
A URL parameter is input, not a guaranteed valid page number. It might be missing, negative, fractional, or larger than the number of available pages. The reader below accepts positive whole numbers and falls back to Page 1 for other values. It then caps the result at the last page. That gives rendering a safe page number even if the URL contains an unexpected value.
For a changing data set, calculate the page count from the current item count rather than storing it separately. Use at least one page when the list is empty; the interface can then show an empty message while disabling both movement controls. If filtering or deletion reduces the page count, read and bound the page again before rendering.
Render the list and controls together
Paste the following JavaScript into a browser console. It creates 23 sample items and displays five at a time. The buttons are recreated on each render, so their labels, disabled states, and current-page marker all follow the same page value as the visible items.
const items = Array.from({ length: 23 }, (_, i) => `Item ${i + 1}`); const pageSize = 5; const root = document.createElement('section'); const heading = document.createElement('h2'); heading.textContent = 'Sample items'; const list = document.createElement('ol'); const status = document.createElement('p'); status.setAttribute('role', 'status'); const nav = document.createElement('nav'); nav.setAttribute('aria-label', 'List pages'); root.append(heading, list, nav, status); document.body.append(root); function pageCount() { return Math.max(1, Math.ceil(items.length / pageSize)); } function readPage() { const raw = new URL(location.href).searchParams.get('page'); const number = raw && /^[1-9]\d*$/.test(raw) ? Number(raw) : 1; return Math.min(Number.isSafeInteger(number) ? number : 1, pageCount()); } function render() { const page = readPage(); const start = (page - 1) * pageSize; list.replaceChildren(); for (const item of items.slice(start, start + pageSize)) { const li = document.createElement('li'); li.textContent = item; list.append(li); } if (!items.length) { const li = document.createElement('li'); li.textContent = 'No items to show.'; list.append(li); } nav.replaceChildren(); for (const [label, target, disabled] of [ ['Previous', page - 1, page === 1 || !items.length], ...Array.from({ length: pageCount() }, (_, i) => [ `Page ${i + 1}`, i + 1, i + 1 === page || !items.length ]), ['Next', page + 1, page === pageCount() || !items.length] ]) { const button = document.createElement('button'); button.type = 'button'; button.textContent = label; button.disabled = disabled; if (label === `Page ${page}`) button.setAttribute('aria-current', 'page'); button.addEventListener('click', () => { const url = new URL(location.href); url.searchParams.set('page', String(target)); history.pushState(null, '', url); render(); }); nav.append(button, ' '); } status.textContent = items.length ? `Showing page ${page} of ${pageCount()}` : 'No items to show.'; } addEventListener('popstate', render); render();
Try clicking Next, then pressing Back. Each click adds a history entry and calls render because pushState does not itself fire popstate. Back and Forward do fire popstate, allowing the same render function to read the restored URL. Creating a URL object before setting page also preserves unrelated query parameters and the URL fragment.
Check the interaction, not just the arithmetic
Test a direct visit with ?page=3, then try ?page=999, ?page=0, and ?page=word. The displayed page should remain within the available range. This example leaves an invalid query value visible until someone clicks a control; the displayed state is valid, but the address bar has not been normalized. If your product requires a canonical URL, you can replace the current history entry with the bounded page value on initial load. Decide that policy deliberately rather than adding an extra history entry during recovery.
Native buttons provide keyboard activation and disabled behavior. The navigation landmark identifies the controls, aria-current identifies the selected page, and the status text announces changes in supporting assistive technology. On a longer list, showing a button for every page would become unwieldy; a smaller range of page buttons can use the same URL-reading and rendering pattern. If changing pages also replaces a large region elsewhere on a real page, consider where keyboard focus should go after activation so people can easily reach the new content.



