How to paginate comments dynamically with JavaScript in WordPress

Contents

Introduction

This tutorial shows how to paginate WordPress comments dynamically with JavaScript so users can navigate comment pages without full page reloads. It covers two practical approaches: using the WordPress REST API (recommended when available) and using admin-ajax / a custom AJAX handler (compatible with sites that prefer server-side rendering or need custom HTML). The guide includes full code examples for PHP, JavaScript and minimal CSS, along with notes on accessibility, history management, SEO and progressive enhancement.

Why dynamic comment pagination?

  • Better user experience: faster navigation between pages, smoother interactions.
  • No full page reloads: keeps users scroll position and preserves loaded resources.
  • Progressive enhancement: works with existing server-side pagination users without JS still get normal pagination.
  • History deep linking: you can update the URL (cpage or page param) so each page is linkable and bookmarkable.

Prerequisites

  • WordPress theme that outputs standard comment markup (wp_list_comments or comments_template).
  • Familiarity with functions.php, enqueueing scripts, and basic JavaScript (fetch or XMLHttpRequest).
  • PHP access to add AJAX handlers or REST endpoints if needed.

High-level approaches

  1. REST API approach — Use the built-in WP REST API comments endpoint (/wp/v2/comments). Fetch JSON (or HTML from server) and replace the comments block. Simpler and works well for single-page-like behavior.
  2. admin-ajax / custom endpoint approach — Create a PHP AJAX handler that returns rendered HTML for comment list and pagination markup. Useful when you want server-rendered HTML (exact markup from theme) and full control of output.

Preparation in the theme

Make sure your comments markup has stable container IDs/classes that JavaScript can target. Typical structure:


ltdiv id=comments class=comments-areagt
  lth2 class=comments-titlegt...lt/h2gt

  ltol class=comment-list id=comment-listgt
    lt!-- wp_list_comments() output --gt
  lt/olgt

  ltnav id=comments-pagination class=comment-navigationgt
    lt!-- paginate_comments_links() or previous_comments_link/next_comments_link output --gt
  lt/navgt

  ltdiv id=comment-form-wrappergt
    lt!-- comment_form() output --gt
  lt/divgt
lt/divgt

The code above is the target for dynamic updates: we will replace the innerHTML of #comment-list and #comments-pagination (and optionally #comment-form-wrapper) when the user navigates pages.

Method A — REST API (recommended)

Overview

Use the WP REST API endpoint to fetch comments for a post with pagination parameters. Two ways:

  • Fetch JSON and render the comments in the client (needs client-side templating).
  • Request server-rendered HTML via a custom REST route that returns the same HTML your theme outputs (recommended if you want identical markup).

1) Enqueue script and localize settings

Add this to functions.php or a plugin to enqueue the JS and pass necessary variables (post ID, comments per page, REST root and nonce if needed).

/
  Enqueue comment pagination script and pass settings.
 /
add_action(wp_enqueue_scripts, function() {
    if ( is_singular()  comments_open() ) {
        wp_enqueue_script(
            dynamic-comment-pagination,
            get_template_directory_uri() . /js/dynamic-comment-pagination.js,
            array(),
            1.0,
            true
        )

        // Pass useful data to JS
        wp_localize_script(dynamic-comment-pagination, DCPSettings, array(
            postId => get_the_ID(),
            perPage => get_option(comments_per_page),
            restRoot => esc_url_raw( rest_url() ),
            nonce => wp_create_nonce(wp_rest) // Optional for POSTs GET not mandatory
        ))
    }
})

2) Client-side JavaScript (fetch via REST API)

Below is an example that fetches server-rendered HTML from a custom REST route well register next. It intercepts clicks on pagination links, requests page content, updates DOM, and uses history.pushState to keep navigation and the back button working.

(function() {
  const postId = DCPSettings.postId
  const perPage = DCPSettings.perPage  10
  const restRoot = DCPSettings.restRoot.replace(///, )
  const commentsContainer = document.getElementById(comment-list)
  const paginationContainer = document.getElementById(comments-pagination)
  const commentFormWrapper = document.getElementById(comment-form-wrapper)

  if (!commentsContainer  !paginationContainer) return

  function getPageFromLink(href) {
    try {
      const url = new URL(href, location.href)
      return url.searchParams.get(cpage)  url.searchParams.get(page)  1
    } catch (e) {
      return 1
    }
  }

  async function fetchCommentsPage(page) {
    const endpoint = {restRoot}/dcp/v1/comments?post={postId}per_page={perPage}page={page}
    const res = await fetch(endpoint, {
      method: GET,
      credentials: same-origin,
      headers: {
        Accept: application/json
      }
    })
    if (!res.ok) throw new Error(Network response was not ok:    res.status)
    return await res.json()
  }

  function updateDOM(payload, page) {
    // payload.html contains the comment list markup (ol/comment list)
    if (payload.comment_list_html) {
      commentsContainer.innerHTML = payload.comment_list_html
    }
    if (payload.pagination_html) {
      paginationContainer.innerHTML = payload.pagination_html
    }
    if (payload.comment_form_html  commentFormWrapper) {
      commentFormWrapper.innerHTML = payload.comment_form_html
    }

    // Update URL: set cpage or page param
    const url = new URL(location.href)
    url.searchParams.set(cpage, page)
    history.pushState({dcp_page: page}, , url.toString())
    // Optionally, scroll to comments area
    commentsContainer.scrollIntoView({behavior: smooth, block: start})
  }

  async function handleClick(e) {
    const anchor = e.target.closest  e.target.closest(a)
    if (!anchor) return
    if (!paginationContainer.contains(anchor)) return
    e.preventDefault()
    const page = getPageFromLink(anchor.href)
    try {
      // Show loading state if you want
      const payload = await fetchCommentsPage(page)
      updateDOM(payload, page)
    } catch (err) {
      console.error(Failed to load comments page:, err)
      // Fallback to real link navigation
      location.href = anchor.href
    }
  }

  // Handle browser navigation (back/forward)
  window.addEventListener(popstate, function(ev) {
    const state = ev.state
    const page = (state  state.dcp_page) ? state.dcp_page : (new URL(location.href)).searchParams.get(cpage)  1
    fetchCommentsPage(page)
      .then(payload => updateDOM(payload, page))
      .catch(() => {})
  })

  // Intercept pagination clicks
  paginationContainer.addEventListener(click, handleClick, false)
})()

3) REST endpoint that returns HTML

Register a simple custom REST route that returns JSON with fields for the comment list HTML and pagination HTML. The endpoint will use themes comment template functions so the markup matches your theme.

/
  REST endpoint: GET /wp-json/dcp/v1/comments?post=IDper_page=10page=2
 /
add_action(rest_api_init, function() {
    register_rest_route(dcp/v1, /comments, array(
        methods  => GET,
        callback => dcp_rest_get_comments_page,
        args     => array(
            post => array(validate_callback => is_numeric),
            per_page => array(validate_callback => is_numeric),
            page => array(validate_callback => is_numeric),
        ),
    ))
})

function dcp_rest_get_comments_page( request ) {
    post_id = (int) request->get_param(post)
    per_page = (int) request->get_param(per_page) ?: get_option(comments_per_page)
    page = (int) request->get_param(page) ?: 1

    if ( ! post_id ) {
        return new WP_Error(no_post, Post ID is required, array(status => 400))
    }

    // Setup global-like environment for comment rendering
    comments = get_comments(array(
        post_id => post_id,
        status  => approve,
        number  => per_page,
        offset  => (page - 1)  per_page,
        orderby => comment_date_gmt,
        order   => ASC,
    ))

    // Render comment list HTML using a callback capture output
    ob_start()
    wp_list_comments(array(
        style      => ol,
        short_ping => true,
        avatar_size=> 48
    ), comments)
    comment_list_html = ob_get_clean()

    // Render pagination HTML using paginate_links and total pages calc
    total_comments = get_comments(array(post_id => post_id, count => true, status => approve))
    total_pages = (int) ceil( total_comments / per_page )

    if (total_pages <= 1) {
        pagination_html = 
    } else {
        // Build pagination links
        base_url = add_query_arg(cpage, %#%, get_permalink(post_id))
        pagination_html = paginate_links(array(
            base      => base_url,
            format    => ,
            current   => page,
            total     => total_pages,
            type      => list,
            prev_text => laquo Previous,
            next_text => Next raquo,
        ))
    }

    // Optionally, return comment form HTML too (render comment_form with args)
    ob_start()
    comment_form(array(), post_id)
    comment_form_html = ob_get_clean()

    return rest_ensure_response(array(
        comment_list_html => comment_list_html,
        pagination_html   => pagination_html,
        comment_form_html => comment_form_html,
        page              => page,
        total_pages       => total_pages,
    ))
}

Note: The endpoint uses wp_list_comments() and comment_form() to reuse theme rendering. If your themes comment callbacks rely on global state (like post), consider setting up global post = get_post(post_id) setup_postdata(post) before rendering and wp_reset_postdata() after.

Pros cons of REST approach

  • Pros: Simple, standard API, works well with modern themes, easy caching and decoupling.
  • Cons: If you prefer exact server-side rendered HTML from complex theme callbacks you may need to carefully simulate global state.

Method B — admin-ajax (server-rendered HTML)

Overview

Use admin-ajax.php to register an action that returns the rendered comment list HTML. This is useful when you want to avoid the REST API or prefer admin-ajaxs traditional flow. The JS will request admin-ajax.php?action=load_commentsamppost_id=…amppage=… and the PHP will echo JSON or HTML.

1) PHP AJAX handler

/
  AJAX handler for logged in and non-logged-in users.
 /
add_action(wp_ajax_nopriv_load_comments, dcp_ajax_load_comments)
add_action(wp_ajax_load_comments, dcp_ajax_load_comments)

function dcp_ajax_load_comments() {
    post_id = isset(_GET[post_id]) ? (int) _GET[post_id] : 0
    per_page = isset(_GET[per_page]) ? (int) _GET[per_page] : get_option(comments_per_page)
    page = isset(_GET[page]) ? (int) _GET[page] : 1

    if (!post_id) {
        wp_send_json_error(Missing post_id, 400)
    }

    offset = (page - 1)  per_page
    comments = get_comments(array(
        post_id => post_id,
        status  => approve,
        number  => per_page,
        offset  => offset,
        orderby => comment_date_gmt,
        order   => ASC,
    ))

    ob_start()
    wp_list_comments(array(
        style      => ol,
        short_ping => true,
        avatar_size=> 48
    ), comments)
    comment_list_html = ob_get_clean()

    total_comments = get_comments(array(post_id => post_id, count => true, status => approve))
    total_pages = (int) ceil(total_comments / per_page)

    base_url = add_query_arg(cpage, %#%, get_permalink(post_id))
    pagination_html = total_pages > 1 ? paginate_links(array(
        base      => base_url,
        format    => ,
        current   => page,
        total     => total_pages,
        type      => list,
    )) : 

    // Return JSON
    wp_send_json_success(array(
        comment_list_html => comment_list_html,
        pagination_html   => pagination_html,
        page              => page,
        total_pages       => total_pages,
    ))
}

2) Client JavaScript

This script intercepts pagination link clicks, sends an AJAX request to admin-ajax.php and updates the DOM. It also degrades gracefully to normal navigation if the request fails.

(function() {
  const postId = DCPSettings.postId
  const perPage = DCPSettings.perPage  10
  const ajaxUrl = DCPSettings.ajaxUrl  (window.ajaxurl  /wp-admin/admin-ajax.php)
  const commentsContainer = document.getElementById(comment-list)
  const paginationContainer = document.getElementById(comments-pagination)

  if (!commentsContainer  !paginationContainer) return

  function getPageFromLink(href) {
    try {
      const url = new URL(href, location.href)
      return url.searchParams.get(cpage)  url.searchParams.get(page)  1
    } catch (e) {
      return 1
    }
  }

  function fetchComments(page) {
    const params = new URLSearchParams()
    params.append(action, load_comments)
    params.append(post_id, postId)
    params.append(per_page, perPage)
    params.append(page, page)

    return fetch(ajaxUrl   ?   params.toString(), {
      credentials: same-origin,
      method: GET,
      headers: {Accept: application/json}
    })
    .then(res => {
      if (!res.ok) throw new Error(Network error)
      return res.json()
    })
    .then(json => {
      if (!json.success) throw new Error(Server error)
      return json.data
    })
  }

  async function handleClick(e) {
    const anchor = e.target.closest  e.target.closest(a)
    if (!anchor) return
    if (!paginationContainer.contains(anchor)) return
    e.preventDefault()
    const page = getPageFromLink(anchor.href)
    try {
      const payload = await fetchComments(page)
      if (payload.comment_list_html) {
        commentsContainer.innerHTML = payload.comment_list_html
      }
      if (payload.pagination_html) {
        paginationContainer.innerHTML = payload.pagination_html
      }
      const url = new URL(location.href)
      url.searchParams.set(cpage, page)
      history.pushState({dcp_page: page}, , url.toString())
      commentsContainer.scrollIntoView({behavior: smooth, block: start})
    } catch (err) {
      console.error(err)
      location.href = anchor.href
    }
  }

  window.addEventListener(popstate, function(ev) {
    const state = ev.state
    const page = (state  state.dcp_page) ? state.dcp_page : (new URL(location.href)).searchParams.get(cpage)  1
    fetchComments(page)
      .then(payload => {
        if (payload.comment_list_html) commentsContainer.innerHTML = payload.comment_list_html
        if (payload.pagination_html) paginationContainer.innerHTML = payload.pagination_html
      })
      .catch(() => {})
  })

  paginationContainer.addEventListener(click, handleClick, false)
})()

Accessibility and semantics

  • Keep the semantic elements: ol.comment-list or ul.comment-list and the nav.comment-navigation for pagination.
  • Ensure the pagination links have descriptive text for screen readers (prev/next and page numbers).
  • Announce dynamic updates for screen readers using ARIA live regions if needed. For example, a visually-hidden element with aria-live=polite that you update with Showing comments page X of Y.

ARIA live example (rendered in PHP)

ltdiv id=comments-live-region class=screen-reader-text aria-live=polite aria-atomic=truegtShowing comments page 1lt/divgt

Update it from JS after replacing content: document.getElementById(comments-live-region).textContent = Showing comments page page of totalPages

Progressive enhancement and graceful fallback

  • Do not remove server-side pagination links. JavaScript intercepts them but if JS is disabled links still navigate normally.
  • Ensure the server-side rendering and the dynamic rendering produce identical markup to avoid flicker and layout shifts.
  • Always implement error handling: if AJAX fails, fall back to standard navigation (location.href = link.href).

Optimizations and caching

  • Cache comment fragments if your hosting stack supports fragment caching. This reduces DB queries for each AJAX call.
  • Use transient caching in PHP for comment HTML per page for a short TTL when traffic is high.
  • Set appropriate response headers (Cache-Control) on REST routes if content is cache-safe for anonymous users.
  • Limit comments returned per request to your themes comments_per_page to reduce payload size.

Handling comment counts, anchors and focus

  • After loading a page, you may want to focus the comments container for keyboard users: commentsContainer.focus() but ensure it is focusable (tabindex=-1).
  • Update displayed comment count if your theme shows it in the title, by including that markup in the REST/AJAX response or by recalculating in JS.
  • If links to individual comments include anchors (e.g., #comment-123) and the user clicks them, allow the browser to navigate to that anchor or fetch the page that contains that comment and then scroll it into view.

Styling: minimal CSS for loading state and pagination

/ Minimal styles to show a loading indicator and ensure focus visibility /
#comment-list.loading { opacity: 0.6 pointer-events: none }
#comments-pagination .current { font-weight: bold }
#comments-pagination .page-numbers { display: inline-block margin: 0 3px }
.screen-reader-text { position: absolute !important left: -9999px !important top: auto !important width: 1px !important height: 1px !important overflow: hidden }

Troubleshooting common issues

  1. Pagination links not intercepted: Make sure JS is enqueued and runs after DOM load and that selectors match your markup (IDs/classes).
  2. REST endpoint returns different markup: Ensure wp_list_comments() and comment_form() are called in the same context as your theme. You may need to set the global post and call setup_postdata().
  3. Permalinks cause weird links: Use get_permalink(post_id) to build pagination base in server code and parse page from URL in JS robustly.
  4. Back button doesnt restore state: Make sure you pushState with page value, and handle popstate to fetch and restore content.
  5. Authenticated features require nonce: For operations that require auth (POST comment), include valid nonces via wp_create_nonce and use them in requests.

Security considerations

  • Sanitize input parameters in PHP (cast to (int), validate numeric, verify post exists).
  • For POST endpoints, check nonces and current_user_can if needed.
  • Escape output where appropriate if you alter the comment content wp_list_comments already handles comment escaping.

Compatibility notes

  • Works with most themes that use wp_list_comments and paginate_links. If your theme uses a custom comment walker, test the endpoint rendering carefully.
  • Works with large comment counts — ensure per_page is set sensibly and offset is calculated properly.
  • If you use custom comment fields or plugins that alter form markup, include the form rendering in the response to preserve behavior.

Summary checklist before deploying

  • Wrap comment list and pagination in consistent container IDs (#comment-list and #comments-pagination).
  • Enqueue scripts properly and pass postId, perPage, and ajax/rest URLs.
  • Implement server-side endpoint (REST or admin-ajax) that returns comment list HTML and pagination HTML.
  • Handle history.pushState and popstate to support back/forward and deep links.
  • Ensure accessibility by maintaining semantic HTML and adding ARIA live updates if desired.
  • Test with JS disabled to confirm graceful fallback.

Useful references

Complete example files

Below are the central examples summarized. Adapt paths and integration per your theme.

functions.php (enqueue REST route)

// Enqueue script and localize
add_action(wp_enqueue_scripts, function() {
    if ( is_singular()  comments_open() ) {
        wp_enqueue_script(
            dynamic-comment-pagination,
            get_template_directory_uri() . /js/dynamic-comment-pagination.js,
            array(),
            1.0,
            true
        )
        wp_localize_script(dynamic-comment-pagination, DCPSettings, array(
            postId  => get_the_ID(),
            perPage => get_option(comments_per_page),
            restRoot=> esc_url_raw( rest_url() ),
            ajaxUrl => admin_url(admin-ajax.php),
            nonce   => wp_create_nonce(wp_rest),
        ))
    }
})

// REST route (see earlier REST code block for dcp_rest_get_comments_page)
add_action(rest_api_init, function() {
    register_rest_route(dcp/v1, /comments, array(...))
})

js/dynamic-comment-pagination.js

// Paste the REST or admin-ajax JavaScript example shown earlier here.
// Ensure the script uses DCPSettings. values provided by wp_localize_script.

Final notes

This approach gives a fast, smooth comment navigation experience while maintaining server-rendered fallback for non-JS users. Choose REST for a modern API-driven approach, or admin-ajax for maximum compatibility with server-side rendering. Test thoroughly on mobile and desktop, and ensure comment posting and moderation flows remain intact.



Acepto donaciones de BAT's mediante el navegador Brave 🙂



Leave a Reply

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