Como hacer infinite scroll con REST API y JavaScript en WordPress

Contents

Introducción

Este tutorial muestra paso a paso cómo implementar infinite scroll en un tema de WordPress usando la REST API y JavaScript moderno. Verás alternativas, código listo para pegar en functions.php, el script cliente que consume la API, estilos del loader, buenas prácticas de accesibilidad, SEO y manejo de errores. El enfoque usa la API REST nativa (wp/v2/posts) y IntersectionObserver para un scroll eficiente y sin bloqueos.

Concepto y cuándo usar infinite scroll

  • Qué es: Cargar más entradas automáticamente conforme el usuario se acerca al final de la lista.
  • Cuándo usar: Listados largos (blog, portfolio, tienda), cuando la intención es exploratoria. Evita en contenidos que necesiten referencias claras de paginación (SEO muy sensible, o navegaciones profundas donde los usuarios marcan páginas).
  • Consideraciones: Mantener accesibilidad, proporcionar fallback (paginación clásica), evitar carga infinita sin fin y manejar correctamente el historial (pushState si es necesario).

Enfoques posibles

  1. Usar el endpoint nativo /wp/v2/posts y leer los headers para saber el número total de páginas.
  2. Crear un endpoint REST personalizado si necesitas estructura de respuesta distinta (campos ACF, metadatos, o filtros complejos).
  3. Hacer paginación clásica como fallback para usuarios sin JavaScript o para bots.

Preparación en WordPress (functions.php)

Lo mínimo que necesitas en el servidor es encolar el script y pasar datos útiles (URL de la REST API, nonce si vas a usar endpoints que requieren permisos, posts_por_page inicial, etc.). Si usas el endpoint nativo no es obligatorio crear un endpoint nuevo.

Ejemplo: encolar script y pasar datos

// functions.php
function mytheme_enqueue_infinite_scroll() {
    wp_enqueue_script(
        mytheme-infinite-scroll,
        get_template_directory_uri() . /js/infinite-scroll.js,
        array(),
        1.0,
        true
    )

    // Datos que usará el script: REST URL, posts por página inicial y nonce para acciones protegidas
    wp_localize_script(
        mytheme-infinite-scroll,
        MyThemeInfiniteScroll,
        array(
            rest_url => esc_url_raw( rest_url( wp/v2/posts ) ),
            posts_per_page => intval( get_option( posts_per_page ) ?: 10 ),
            nonce => wp_create_nonce( wp_rest )
        )
    )
}
add_action( wp_enqueue_scripts, mytheme_enqueue_infinite_scroll )

Nota sobre endpoints personalizados

Si necesitas exponer campos adicionales (ACF, campos privados o transformar la respuesta), registra una ruta REST personalizada. En el ejemplo siguiente devolvemos posts con _embed para facilitar acceso a la imagen destacada y leemos el total de páginas.

// functions.php (opcional: endpoint personalizado)
add_action( rest_api_init, function() {
    register_rest_route( mytheme/v1, /posts, array(
        methods  => GET,
        callback => mytheme_rest_get_posts,
        args     => array(
            page => array(
                required => false,
                default  => 1,
                sanitize_callback => absint,
            ),
            per_page => array(
                required => false,
                default  => 10,
                sanitize_callback => absint,
            ),
        ),
    ) )
} )

function mytheme_rest_get_posts( request ) {
    page = request->get_param( page )
    per_page = request->get_param( per_page )

    query = new WP_Query( array(
        post_type      => post,
        post_status    => publish,
        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 ),
            excerpt => wp_trim_words( post->post_content, 30 ),
            // Añade campos extra si lo necesitas
        )
    }

    // Añadimos header con número total de páginas
    response = new WP_REST_Response( posts )
    response->header( X-Total-Pages, (int) query->max_num_pages )
    return response
}

Estructura HTML mínima (plantilla de tema)

En tu plantilla (index.php, archive.php o un template part) añade un contenedor para las entradas y un sentinel que servirá para IntersectionObserver. A continuación un ejemplo de la estructura que se recomienda insertar en el template:

ltmain id=main-contentgt
  ltsection id=posts-containergt
    lt!-- Aquí van los artículos renderizados por PHP inicialmente --gt
    lt?php if ( have_posts() ) : while ( have_posts() ) : the_post() ?gt
      ltarticle class=post-itemgt
        lth2gtlta href=lt?php the_permalink() ?gtgtlt?php the_title() ?gtlt/agtlt/h2gt
        ltdiv class=excerptgtlt?php the_excerpt() ?gtlt/divgt
      lt/articlegt
    lt?php endwhile endif ?gt
  lt/sectiongt

  ltdiv id=infinite-scroll-sentinel aria-hidden=truegtlt/divgt

  ltdiv id=infinite-scroll-loader aria-live=polite style=display:nonegtCargando…lt/divgt
lt/maingt

JavaScript: Lógica del infinite scroll (cliente)

Usaremos fetch para consumir la API REST y IntersectionObserver para detectar cuando cargar la siguiente página. Leemos el header X-WP-TotalPages (en el endpoint nativo se llama así: X-WP-TotalPages) para saber cuándo detenernos.

// js/infinite-scroll.js
(function () {
  if ( ! ( IntersectionObserver in window ) ) {
    // Fallback: no IntersectionObserver (p. ej. browsers antiguos) – podrías cargar botón Cargar más
    return
  }

  const apiURL = window.MyThemeInfiniteScroll  window.MyThemeInfiniteScroll.rest_url  /wp-json/wp/v2/posts
  const perPage = window.MyThemeInfiniteScroll  window.MyThemeInfiniteScroll.posts_per_page ? parseInt(window.MyThemeInfiniteScroll.posts_per_page, 10) : 10
  let currentPage = 1 // Ya renderizaste la página 1 en PHP si no, ajusta.
  let totalPages = null
  let loading = false

  const container = document.getElementById(posts-container)
  const sentinel = document.getElementById(infinite-scroll-sentinel)
  const loader = document.getElementById(infinite-scroll-loader)

  function showLoader() {
    if (loader) loader.style.display = block
  }
  function hideLoader() {
    if (loader) loader.style.display = none
  }

  async function fetchPosts(page) {
    loading = true
    showLoader()

    const url = new URL(apiURL, window.location.origin)
    url.searchParams.set(page, page)
    url.searchParams.set(per_page, perPage)
    url.searchParams.set(_embed, true) // para obtener imágenes destacadas embebidas

    try {
      const response = await fetch(url.toString(), {
        headers: {
          Accept: application/json,
          X-WP-Nonce: window.MyThemeInfiniteScroll  window.MyThemeInfiniteScroll.nonce ? window.MyThemeInfiniteScroll.nonce : 
        }
      })

      if (!response.ok) {
        // Si la respuesta es 400 o 404 al pedir página más allá del límite, lo interpretamos como final
        if (response.status === 400  response.status === 404) {
          totalPages = currentPage // fuerza detener
          observer.disconnect()
          return []
        }
        throw new Error(HTTP error    response.status)
      }

      // Leer número total de páginas desde headers (endpoint nativo usa X-WP-TotalPages)
      const headerTotalPages = response.headers.get(X-WP-TotalPages)  response.headers.get(X-Total-Pages)
      if (headerTotalPages) {
        totalPages = parseInt(headerTotalPages, 10)
      }

      const data = await response.json()
      return data
    } catch (err) {
      console.error(Error fetching posts:, err)
      return []
    } finally {
      loading = false
      hideLoader()
    }
  }

  function renderPostItem(post) {
    // Si usas _embed, la imagen destacada puede estar en post._embedded[wp:featuredmedia][0].source_url
    const article = document.createElement(article)
    article.className = post-item
    article.innerHTML = 
      

{post.title.rendered post.title}

{post.excerpt post.excerpt.rendered ? post.excerpt.rendered : (post.excerpt )}
return article } async function loadNextPage() { if (loading) return if (totalPages currentPage >= totalPages) { observer.disconnect() return } currentPage const posts = await fetchPosts(currentPage) if (!posts posts.length === 0) { // No hay más posts: desconecta el observer if (observer) observer.disconnect() return } posts.forEach(post => { const el = renderPostItem(post) container.appendChild(el) }) // Opcional: actualizar el historial (pushState) para reflejar la página visible // const url = new URL(window.location) // url.searchParams.set(page, currentPage) // history.replaceState({}, , url) } const observer = new IntersectionObserver(entries => { entries.forEach(entry => { if (entry.isIntersecting !loading) { loadNextPage() } }) }, { root: null, rootMargin: 200px, threshold: 0 }) // Empezamos observando el sentinel observer.observe(sentinel) })()

Estilos CSS para loader y posts (opcional)

/ styles.css /
#posts-container { display: grid grid-template-columns: 1fr gap: 1.25rem }
.post-item { padding: 1rem border: 1px solid #e6e6e6 border-radius: 4px background: #fff }
#infinite-scroll-loader { text-align: center padding: 1rem color: #444 }

Manejo de casos especiales y buenas prácticas

  • Fallback para SEO y usuarios sin JS: Renderiza la primera página con PHP y deja la paginación clásica accesible (enlaces rel=next/prev). Los bots y rastreadores seguirán los enlaces.
  • Evitar loops infinitos: Usa el header de total de páginas o un contador y desconecta el observer cuando no haya más contenido.
  • Throttle y prevención de múltiples llamadas: Usa una variable loading y evita disparos concurrentes. IntersectionObserver con rootMargin reduce llamadas innecesarias.
  • Accesibilidad: Indica al usuario con aria-live cuando se cargan nuevos elementos, y ofrece un botón Cargar más visible si el usuario lo prefiere.
  • Rendimiento: Carga fragmentos más pequeños en móviles si es necesario y habilita caching en el servidor/CDN.
  • Imagen destacada: Solicita _embed para evitar peticiones adicionales, pero recuerda que el payload puede crecer.
  • Historial y URLs: Si quieres que cada scroll sea navegable, actualiza el historial con history.pushState/replaceState y gestiona el estado para restaurar la posición al volver atrás.

Checklist para implementación

  1. Encolar y localizar el script con rest_url, posts_per_page y nonce según corresponda.
  2. Renderizar inicialmente la página 1 con PHP para soporte SEO y sin-JS.
  3. Agregar sentinel y loader en la plantilla.
  4. Usar IntersectionObserver y fetch con manejo de headers para leer X-WP-TotalPages.
  5. Desconectar observer al alcanzar la última página y mostrar mensaje final si procede.
  6. Probar en navegadores móviles y desktop, y revisar rendimiento y accesibilidad.

Problemas frecuentes y soluciones

  • No carga más páginas: Verifica que el header X-WP-TotalPages esté presente (en WP REST API nativa sí viene). Si usas un endpoint personalizado, asegúrate de enviar un header con el total o devolverlo en la respuesta.
  • Peticiones 400 o 404 al pedir una página alta: Detecta esos códigos y desconecta el observer como fin de contenido.
  • Duplicados o saltos de posts: Asegúrate de usar el mismo criterio de orden y filtros en la consulta PHP inicial y en las consultas posteriores vía REST (misma taxonomy, mismo order, mismo posts_per_page).
  • Imágenes no aparecen: Si necesitas imagen destacada en la respuesta, añade _embed=true o personaliza la respuesta del endpoint para incluir el URL de la imagen.

Ejemplo completo resumido

Pasos rápidos:

  1. Añadir el código de encolar en functions.php (ver ejemplo arriba).
  2. Insertar en la plantilla el contenedor, sentinel y loader (ver HTML ejemplo arriba).
  3. Pegar el script JS en js/infinite-scroll.js y ajustar la ruta si usaste endpoint personalizado.
  4. Añadir los estilos básicos y probar en varios viewports.

Consideraciones finales

Infinite scroll mejora la exploración en muchos contextos, pero requiere cuidado con SEO, usabilidad y rendimiento. Implementar la solución con la REST API nativa de WordPress y IntersectionObserver proporciona una base sólida y eficiente. Inspecciona en herramientas de desarrollo las cabeceras de la API (X-WP-TotalPages y X-WP-Total) y ajusta la lógica del cliente según tu configuración (custom post types, taxonomías, campos personalizados).

Recursos útiles



Acepto donaciones de BAT's mediante el navegador Brave 🙂



Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *