How to Implement Infinite Scroll in WordPress Using the REST API

Infinite scroll loads the next page of posts automatically as the user scrolls to the bottom, replacing traditional pagination. The WordPress REST API makes this straightforward: fetch the next batch of posts with JavaScript and append them to the DOM without a full page reload.

Problem: A blog or archive page uses a "Load More" button that reloads the page on every click, which interrupts reading and does not feel like modern pagination.

Solution: Fetch the next page of posts via the WordPress REST API using the browser's fetch() API, append the returned data to the existing list without a page reload, and update the page counter. Pass a nonce and the REST URL from PHP using wp_localize_script().

The HTML structure — a container and a sentinel element at the bottom:

<div id="posts-container">
    <!-- Initial posts rendered by PHP go here -->
</div>
<div id="load-more-sentinel"></div>
<p id="loading-indicator" style="display:none;">Loading…</p>

The JavaScript — using the IntersectionObserver API to detect when the sentinel is visible:

( function() {
    let page     = 1;           // We already have page 1 from PHP
    let loading  = false;
    let maxPages = parseInt( wpInfiniteScroll.totalPages, 10 );

    const container = document.getElementById( 'posts-container' );
    const sentinel  = document.getElementById( 'load-more-sentinel' );
    const indicator = document.getElementById( 'loading-indicator' );

    async function loadNextPage() {
        if ( loading || page >= maxPages ) return;
        loading = true;
        page++;
        indicator.style.display = 'block';

        const url = `${wpInfiniteScroll.restUrl}wp/v2/posts`
                  + `?per_page=10&page=${page}&_fields=id,title,link,excerpt`;

        try {
            const res   = await fetch( url );
            const posts = await res.json();

            posts.forEach( post => {
                const article = document.createElement( 'article' );
                article.innerHTML = `
                    <h2><a href="${post.link}">${post.title.rendered}</a></h2>
                    ${post.excerpt.rendered}
                `;
                container.appendChild( article );
            } );

        } catch ( e ) {
            console.error( 'Infinite scroll error:', e );
            page--;   // allow a retry
        } finally {
            loading = false;
            indicator.style.display = 'none';
        }
    }

    const observer = new IntersectionObserver( entries => {
        if ( entries[0].isIntersecting ) loadNextPage();
    }, { rootMargin: '200px' } );

    observer.observe( sentinel );
} )();

Localize the required data from PHP:

add_action( 'wp_enqueue_scripts', function() {
    if ( ! is_home() && ! is_archive() ) return;

    wp_enqueue_script( 'infinite-scroll', get_template_directory_uri() . '/js/infinite-scroll.js', [], '1.0', true );

    global $wp_query;
    wp_localize_script( 'infinite-scroll', 'wpInfiniteScroll', [
        'restUrl'    => esc_url_raw( rest_url() ),
        'totalPages' => (int) $wp_query->max_num_pages,
    ] );
} );

NOTE: IntersectionObserver is supported in all major browsers as of 2019. For the small percentage of users on older browsers, provide a fallback "Load More" button that calls loadNextPage() on click instead of relying on the observer.