Infinite scroll replaces pagination with automatic post loading triggered by the user scrolling near the bottom of the page — the implementation combines an IntersectionObserver that watches a sentinel element at the end of the post list with a fetch() call to the WordPress REST API /wp-json/wp/v2/posts endpoint that returns the next page of posts as JSON. IntersectionObserver is preferred over a scroll event listener because it fires asynchronously off the main thread, requires no debouncing, and consumes no CPU when the sentinel is not near the viewport. The REST API endpoint accepts ?per_page=10&page=2&_fields=id,title,excerpt,link,featured_media&_embed=wp:featuredmedia — the _fields parameter limits the response to only the properties needed for rendering, reducing payload size by 70–80% compared to the full post object, and _embed=wp:featuredmedia includes the featured image URL in a single request without a second fetch. The total number of pages is read from the X-WP-TotalPages response header on the first request — when the current page equals this value, the observer is disconnected to stop requesting non-existent pages. Loading state is communicated to screen readers via an aria-live="polite" region that announces “Loading more posts” and “No more posts”, satisfying WCAG 2.1 SC 4.1.3. A loading spinner is shown by toggling a CSS class on the sentinel element and hidden after the posts are appended to the DOM. Error handling catches both network errors and non-200 HTTP responses — a failed fetch disconnects the observer and shows a “Could not load more posts — refresh to try again” message. Post cards are built with document.createElement() and innerHTML is avoided — text content is set with textContent and links with setAttribute('href', ...) after validating the URL to prevent XSS from a compromised REST response. The debounce and throttle post explains why IntersectionObserver is superior to a throttled scroll handler for this use case.
Problem: A WordPress blog with a traditional paginated post list loses visitors at the pagination links — users do not click “Next Page” but will continue reading if new posts load automatically as they scroll.
Solution: Replace the pagination with an IntersectionObserver on a sentinel element that fetches the next page from the WordPress REST API, appends rendered post cards to the list, reads total pages from the X-WP-TotalPages header, and announces loading state to screen readers via aria-live.
/* infinite-scroll.js — enqueue via wp_enqueue_script() */
(function () {
'use strict';
const list = document.querySelector('.post-list'); // posts container
const sentinel = document.querySelector('.post-list__sentinel'); // empty div at the bottom
const liveMsg = document.querySelector('.post-list__live'); // aria-live region
if (!list || !sentinel) return;
let page = 1;
let totalPages = Infinity;
let loading = false;
const API_BASE = window.wpInfiniteScroll?.restUrl || '/wp-json/wp/v2/posts';
const PER_PAGE = 10;
async function loadMore() {
if (loading || page > totalPages) return;
loading = true;
sentinel.classList.add('is-loading');
if (liveMsg) liveMsg.textContent = 'Loading more posts…';
try {
const url = `${API_BASE}?per_page=${PER_PAGE}&page=${page + 1}`
+ `&_fields=id,title,excerpt,link,_links,_embedded`
+ `&_embed=wp:featuredmedia`;
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
// Read total pages from the first successful response
if (totalPages === Infinity) {
totalPages = parseInt(res.headers.get('X-WP-TotalPages') || '1', 10);
}
const posts = await res.json();
page++;
posts.forEach(post => list.insertBefore(createCard(post), sentinel));
if (page >= totalPages) {
observer.disconnect();
sentinel.classList.remove('is-loading');
sentinel.classList.add('is-end');
if (liveMsg) liveMsg.textContent = 'All posts loaded.';
} else {
if (liveMsg) liveMsg.textContent = '';
}
} catch (err) {
console.error('Infinite scroll error:', err);
observer.disconnect();
sentinel.textContent = 'Could not load more posts — refresh to try again.';
if (liveMsg) liveMsg.textContent = 'Failed to load posts.';
} finally {
loading = false;
sentinel.classList.remove('is-loading');
}
}
function createCard(post) {
const li = document.createElement('li');
li.className = 'post-card';
const a = document.createElement('a');
// Validate URL to prevent open-redirect XSS from a tampered API response
try {
const parsed = new URL(post.link);
if (parsed.origin !== window.location.origin) throw new Error();
a.setAttribute('href', parsed.href);
} catch {
a.setAttribute('href', '#');
}
a.textContent = post.title?.rendered || 'Untitled';
const p = document.createElement('p');
// excerpt.rendered contains HTML — use innerHTML but strip to text for the card
const tmp = document.createElement('div');
tmp.innerHTML = post.excerpt?.rendered || '';
p.textContent = tmp.textContent.trim();
li.append(a, p);
return li;
}
const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) loadMore();
}, { rootMargin: '200px' }); // start loading 200px before the sentinel is visible
observer.observe(sentinel);
}());
NOTE: Pass the REST API base URL from PHP to JavaScript using wp_localize_script() or wp_add_inline_script() rather than hardcoding /wp-json/wp/v2/posts — the REST API URL changes when WordPress is installed in a subdirectory or when a custom REST prefix is configured with add_filter('rest_url_prefix', ...). Use rest_url('wp/v2/posts') in PHP and pass it as window.wpInfiniteScroll.restUrl.