How to do infinite scroll with REST API and JavaScript in WordPress

Contents

Overview

Infinite scroll with the WordPress REST API and vanilla JavaScript is a modern, user-friendly way to load additional posts as the reader scrolls. This article covers everything you need: how the REST API pagination works, server-side tweaks (exposing featured images, custom endpoints, headers), the recommended client-side implementation (IntersectionObserver with a scroll fallback), accessibility and SEO considerations, performance and caching strategies, and detailed code examples you can copy and paste.

How WordPress REST API Pagination Works

The WP REST API uses query parameters for pagination:

  • page — the page number (1-based).
  • per_page — number of items per page (default often 10, maximum 100).

Responses include two useful headers for client-side logic:

  • X-WP-Total — total number of objects matching the query.
  • X-WP-TotalPages — total number of pages (ceil(total/per_page)).

Use the endpoint /wp-json/wp/v2/posts (or a custom route). Add _embed=true to include related objects like featured_media (so you can get featured image URLs without extra requests).

Server-side Preparations (WordPress)

Before wiring the client, ensure the REST responses provide what you need and that the initial page is accessible to search engines. Implementing infinite scroll as progressive enhancement is recommended: the server still serves paginated pages for crawlers and non-JS users, and JavaScript enhances UX.

1) Add featured image URL to the REST response

Many themes need the featured image URL inside the REST response. Use register_rest_field or rest_prepare_post to add a custom field.

 my_rest_get_featured_image,
            schema          => null,
        )
    )
} )

function my_rest_get_featured_image( object ) {
    feat_id = object[featured_media]
    if ( empty( feat_id ) ) {
        return null
    }
    img = wp_get_attachment_image_src( feat_id, full )
    if ( ! img ) {
        return null
    }
    return img[0]
}
?>

2) Enable _embed (recommended) and ensure headers are present

When requesting posts, add _embed=true to include featured_media under _embedded[wp:featuredmedia]. For custom endpoints, ensure you add X-WP-Total and X-WP-TotalPages headers if youre building a bespoke REST route.

3) Create a custom REST route for complex queries (optional)

If you need tailored data (post meta, complex joins), create a custom endpoint that returns the exact payload and headers.

 GET,
        callback            => myplugin_get_posts,
        permission_callback => __return_true,
        args                => array(
            page => array(
                validate_callback => is_numeric,
            ),
            per_page => array(
                validate_callback => is_numeric,
            ),
        ),
    ) )
} )

function myplugin_get_posts( request ) {
    page = max( 1, intval( request->get_param( page ) ) )
    per_page = min( 100, max(1, intval( request->get_param( per_page ) ) ) )

    query = new WP_Query( array(
        post_type => post,
        paged     => page,
        posts_per_page => per_page,
    ) )

    posts = array()
    foreach ( query->posts as post ) {
        posts[] = array(
            id    => post->ID,
            title => get_the_title( post ),
            link  => get_permalink( post ),
            date  => get_the_date( , post ),
        )
    }

    total = intval( query->found_posts )
    total_pages = intval( query->max_num_pages )

    return rest_ensure_response( posts )->header( X-WP-Total, total )->header( X-WP-TotalPages, total_pages )
}
?>

4) Keep paginated server output for SEO and crawlers

Ensure your theme outputs normal paginated links (rel=prev/next) and server-rendered content for the first page. Infinite scroll must be progressive enhancement — the server should also handle page queries like /page/2/.

Client-side: HTML skeleton required

Your theme should include three elements in the markup (this is the minimal HTML required). Insert these where you want posts to appear:

lt!-- Example minimal markup --gt
ltdiv id=posts-containergt
  lt!-- server-rendered initial posts here (page 1) --gt
lt/divgt

ltdiv id=posts-loader aria-hidden=true style=display:nonegtLoading…lt/divgt

ltdiv id=posts-sentinelgtlt/divgt

Client-side: JavaScript implementation (IntersectionObserver)

Below is a comprehensive, production-ready JavaScript example using IntersectionObserver. It includes:

  • Fetch with AbortController
  • Reads X-WP-TotalPages headers
  • _embed support to get featured images
  • Throttle/debounce and a fallback to scroll events if IntersectionObserver isnt available
  • Simple caching of pages
  • ARIA live updates for screen readers
/ Infinite scroll using WP REST API and IntersectionObserver
   Assumptions:
   - The initial page (page 1) is server-rendered inside #posts-container.
   - There is a #posts-sentinel element near the bottom where we observe intersection.
   - There is a #posts-loader element to show/hide loading state.
   - Endpoint: /wp-json/wp/v2/posts?_embed=trueper_page=10page=N
/

(function () {
  const container = document.getElementById(posts-container)
  const sentinel = document.getElementById(posts-sentinel)
  const loader = document.getElementById(posts-loader)

  if (!container  !sentinel) {
    console.warn(Infinite scroll: missing required elements.)
    return
  }

  // Config
  const apiBase = /wp-json/wp/v2/posts
  const perPage = 6 // tune this based on content size
  let currentPage = 1 // initial page already server-rendered
  let totalPages = Infinity
  let loading = false
  const pageCache = new Map() // simple cache: page -> HTML string
  let abortController = null

  // Accessibility: a live region for announcing load status (create if not present)
  function ensureLiveRegion() {
    let live = document.getElementById(infinite-live-region)
    if (!live) {
      live = document.createElement(div)
      live.id = infinite-live-region
      live.setAttribute(aria-live, polite)
      live.setAttribute(aria-atomic, true)
      live.style.position = absolute
      live.style.width = 1px
      live.style.height = 1px
      live.style.margin = -1px
      live.style.padding = 0
      live.style.overflow = hidden
      live.style.clip = rect(0 0 0 0)
      document.body.appendChild(live)
    }
    return live
  }

  const liveRegion = ensureLiveRegion()

  function announce(message) {
    if (liveRegion) liveRegion.textContent = message
  }

  // Utility: render a single post object to HTML (string)
  function renderPostHTML(post) {
    // Post fields can be title.rendered, excerpt.rendered, link, date, _embedded
    const title = post.title  post.title.rendered ? post.title.rendered : 
    const excerpt = post.excerpt  post.excerpt.rendered ? post.excerpt.rendered : 
    const link = post.link  #
    let imgHTML = 
    // Try featured image from _embedded
    if (post._embedded  post._embedded[wp:featuredmedia]  post._embedded[wp:featuredmedia][0]) {
      const media = post._embedded[wp:featuredmedia][0]
      const src = (media.media_details  media.media_details.sizes  (media.media_details.sizes.medium  media.media_details.sizes.full))
                  ? ((media.media_details.sizes.medium  media.media_details.sizes.medium.source_url)  media.media_details.sizes.full.source_url)
                  : (media.source_url  )
      if (src) {
        imgHTML = (media.alt_text
      }
    }
    // Return minimal article markup
    return (
      
(imgHTML ?
imgHTML
: )

title

excerpt
) } // Fetch a page from WP REST API async function fetchPage(page) { if (pageCache.has(page)) { return pageCache.get(page) } if (loading) { return null } loading = true loader (loader.style.display = ) announce(Loading more posts) // Abort previous fetch if any if (abortController) { try { abortController.abort() } catch (e) {} } abortController = new AbortController() const signal = abortController.signal const url = apiBase ?_embed=trueper_page= perPage page= page try { const resp = await fetch(url, { signal: signal, credentials: same-origin }) if (!resp.ok) { if (resp.status === 400) { // page out of range -> no more posts totalPages = currentPage announce(No more posts) return null } throw new Error(Network response was not ok: resp.status) } const totalPagesHeader = resp.headers.get(X-WP-TotalPages) if (totalPagesHeader) { totalPages = parseInt(totalPagesHeader, 10) } const json = await resp.json() if (!Array.isArray(json) json.length === 0) { totalPages = page - 1 announce(No more posts) return null } // Build HTML let html = for (const post of json) { html = renderPostHTML(post) } pageCache.set(page, html) return html } catch (err) { if (err.name === AbortError) { // silently ignore aborted fetch return null } console.error(Infinite scroll fetch error:, err) announce(Error loading posts) throw err } finally { loading = false loader (loader.style.display = none) abortController = null } } // Append page content to container function appendPage(html) { if (!html) return container.insertAdjacentHTML(beforeend, html) } // Observer callback async function onIntersect(entries, observer) { for (const entry of entries) { if (entry.isIntersecting) { if (currentPage >= totalPages) { observer.disconnect() announce(All posts loaded) return } const nextPage = currentPage 1 try { const html = await fetchPage(nextPage) if (html) { appendPage(html) currentPage = nextPage // Optionally update URL for history try { const newUrl = new URL(window.location.href) newUrl.searchParams.set(page, String(currentPage)) window.history.replaceState(null, , newUrl) } catch (e) {} } else { // no html returned (no more pages) observer.disconnect() } } catch (err) { // keep observer active so user can attempt to scroll again console.error(err) } } } } // Fallback scroll handler for browsers without IntersectionObserver function setupScrollFallback() { let ticking = false function onScroll() { if (ticking) return ticking = true requestAnimationFrame(async function () { const rect = sentinel.getBoundingClientRect() if (rect.top <= window.innerHeight 200) { // 200px threshold // simulate intersection if (currentPage < totalPages !loading) { const nextPage = currentPage 1 try { const html = await fetchPage(nextPage) if (html) { appendPage(html) currentPage = nextPage } } catch (err) { console.error(err) } } } ticking = false }) } window.addEventListener(scroll, onScroll, { passive: true }) // initial check in case sentinel is already in view onScroll() } // Start (function init() { // Try to read totalPages header for initial page via a small HEAD/ajax if desired. // Not necessary if totalPages is set by fetch routine when first triggered. if (IntersectionObserver in window) { const observer = new IntersectionObserver(onIntersect, { root: null, rootMargin: 500px, // start loading before sentinel enters viewport threshold: 0.01 }) observer.observe(sentinel) } else { setupScrollFallback() } })() })()

Fallback: Load More Button

For devices or scenarios that require explicit user action, provide a Load more button as an alternative. This is also beneficial for accessibility and gives users more control.

// Minimal pattern for a Load More button
const loadMoreBtn = document.getElementById(load-more-btn)
loadMoreBtn.addEventListener(click, async function () {
  const nextPage = currentPage   1
  const html = await fetchPage(nextPage)
  if (html) {
    appendPage(html)
    currentPage = nextPage
  } else {
    loadMoreBtn.disabled = true
    loadMoreBtn.textContent = No more posts
  }
})

SEO Crawlers

Infinite scroll by itself can be problematic for search engines. Best practices:

  • Progressive enhancement: serve paginated HTML pages (page 1, page 2, …) from the server. JavaScript enhances UX but is not required for bots to index content.
  • Rel links: keep rel=prev/rel=next links in your page head or pagination markup so crawlers understand the sequence.
  • History: consider updating the URL (history.pushState/replaceState) when loading new pages so specific points can be shared/bookmarked. Keep this optional.
  • Server-side rendering: for best indexing, render important content server-side as paginated pages.

Accessibility

  • Provide an ARIA live region to announce loading state.
  • Offer a keyboard-accessible Load more button alternative.
  • Ensure focus is not lost when content loads avoid unexpected jumps.
  • Images should have meaningful alt attributes use media alt_text from WordPress when possible.

Performance Considerations

  • Keep per_page moderate (4–12) depending on post size and images.
  • Use image sizes appropriate for thumbnails (use WP image sizes like medium instead of full).
  • Lazy-load images (loading=lazy).
  • Cache pages client-side to avoid re-fetching when users scroll back and forth.
  • Debounce or throttle scroll events if you use a scroll fallback.
  • Use server-side caching (WP object cache, page cache) to ensure REST responses are fast.

Troubleshooting — Common Errors and Fixes

  1. 400 Bad Request when requesting page > max — this happens when you request a page beyond X-WP-TotalPages. Treat this as no more posts and stop requesting further pages.
  2. X-WP-TotalPages header missing — if youre using a custom endpoint, ensure you add headers with rest_ensure_response(...)->header(...).
  3. CORS issues — if calling the API from a different origin, enable CORS or use same-origin requests.
  4. Featured image not available — either include _embed=true or add a custom field for the featured image URL via register_rest_field.
  5. Slow responses — implement server caching (object cache, transient cache) for heavy queries.
  6. REST API disabled — some security plugins disable the REST API. Ensure its available for your route or whitelist the needed endpoints.

Advanced Tips

  • Prefetch next page: once current fetch completes, optionally prefetch the following page (but be mindful of bandwidth).
  • Smart per_page: dynamically adjust per_page based on viewport size (more items for larger screens).
  • Partial updates: on very long lists consider replacing the top-most posts with summarized placeholders to limit DOM size.
  • Analytics: fire analytics events when users reach certain pages or load more content.
  • Auth-protected content: for private content, ensure the REST API call sends credentials and that you handle authentication tokens securely.

Styling the Loader (example CSS)

/ Basic loader styles /
#posts-loader {
  text-align: center
  padding: 12px
  font-size: 14px
  color: #444
}
/ Example post styles (adjust to your theme) /
.infinite-post {
  border-bottom: 1px solid #eaeaea
  padding: 18px 0
}
.post-thumbnail img {
  max-width: 100%
  height: auto
  display: block
}
.post-title {
  margin: .5rem 0
  font-size: 1.125rem
}

Minimal Example: Putting It All Together

1) Server: ensure initial page is server-rendered and the REST API endpoint is accessible (/_json/wp/v2/posts?_embed=true). 2) Theme markup should include the container, loader and sentinel. 3) Add the JavaScript above to your theme (enqueue it via wp_enqueue_script or include inline with proper defer). 4) Optionally add the PHP helpers shown earlier to include featured image URL fields.

Security Notes

  • Never expose sensitive data via REST responses.
  • Validate any input parameters server-side (page, per_page, taxonomy filters).
  • If allowing query parameters that accept complex values, sanitize and cast them.

Useful References

Final implementation checklist

  • Server outputs paginated HTML for SEO (progressive enhancement).
  • REST API returns X-WP-Total and X-WP-TotalPages headers.
  • Featured image URLs available via _embed or a registered field.
  • Client JS uses IntersectionObserver with a scroll fallback.
  • Accessible loading announcements and a Load more button alternative.
  • Per-page tuned for performance lazy-loading images.
  • Error handling and caching for robust UX.


Acepto donaciones de BAT's mediante el navegador Brave 🙂



Leave a Reply

Your email address will not be published. Required fields are marked *