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 =} } // Return minimal article markup return (
(imgHTML ? ) } // 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() } })() })()imgHTML: )title
excerpt
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
- 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.
- X-WP-TotalPages header missing — if youre using a custom endpoint, ensure you add headers with rest_ensure_response(...)->header(...).
- CORS issues — if calling the API from a different origin, enable CORS or use same-origin requests.
- Featured image not available — either include _embed=true or add a custom field for the featured image URL via register_rest_field.
- Slow responses — implement server caching (object cache, transient cache) for heavy queries.
- 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 🙂 |