header blog

“Load More Related Products” Ajax Button @ Single Product Page

The WooCommerce Single Product Page typically displays a fixed number of related products (4 by default). You can change that number with a filter, but there’s no built-in way to replace the static grid with a “Load More” button that pulls in additional related products via Ajax, without reloading the page.

Here’s a working version: a button appears under the related products block, and clicking it loads 3 more at a time. The button hides itself automatically once there are no more related products left to show.

The same Ajax pattern (a button that fetches and appends more content without a page reload) works for other use cases too, like “load more blog posts,” once you understand how the request and response flow between PHP and JavaScript.

function fixelar_related_products_load_more( $html ) {

   // ONLY TARGET THE RELATED PRODUCTS LOOP
   if ( wc_get_loop_prop( 'name' ) === 'related' ) {

      // SHOW BUTTON
      $html .= '<a class="button loadmore">Load More Related Products</a>';

      // TRIGGER 'ajax_loadmore' ACTION ON CLICK
      // AND APPEND MORE RELATED PRODUCTS, OR HIDE THE BUTTON IF NONE ARE LEFT
      wc_enqueue_js( "
         var page = 2;
         var ajaxurl = '" . admin_url( 'admin-ajax.php' ) . "';
         $('body').on('click', '.loadmore', function(evt) {
            var \$button = $(this);
            var data = {
               'action': 'ajax_loadmore',
               'page': page,
               'product_id': " . get_queried_object_id() . ",
            };
            $.post(ajaxurl, data, function(response) {
               if (response.html) {
                  $('.related .products').append(response.html);
                  page++;
               }
               if (!response.has_more) {
                  \$button.hide();
               }
            });
         });
      " );
   }
   return $html;
}
add_filter( 'woocommerce_product_loop_end', 'fixelar_related_products_load_more' );


function fixelar_related_products_load_more_event() {

   $paged      = isset( $_POST['page'] ) ? absint( $_POST['page'] ) : 2;
   $product_id = isset( $_POST['product_id'] ) ? absint( $_POST['product_id'] ) : 0;
   $product    = wc_get_product( $product_id );

   if ( ! $product ) {
      wp_send_json( array( 'html' => '', 'has_more' => false ) );
   }

   $per_page = 3;

   // GET THE FULL RELATED PRODUCTS LIST ONCE, THEN SLICE THE PAGE WE NEED
   $all_related_ids = wc_get_related_products( $product->get_id(), -1 );
   $offset          = $per_page * ( $paged - 1 );
   $page_ids        = array_slice( $all_related_ids, $offset, $per_page );

   if ( empty( $page_ids ) ) {
      wp_send_json( array( 'html' => '', 'has_more' => false ) );
   }

   $related_products = array_filter( array_map( 'wc_get_product', $page_ids ), 'wc_products_array_filter_visible' );

   ob_start();
   foreach ( $related_products as $related_product ) {
      $post_object = get_post( $related_product->get_id() );
      setup_postdata( $GLOBALS['post'] =& $post_object );
      wc_get_template_part( 'content', 'product' );
   }
   wp_reset_postdata();
   $html = ob_get_clean();

   $has_more = count( $all_related_ids ) > ( $offset + $per_page );

   wp_send_json( array( 'html' => $html, 'has_more' => $has_more ) );
}
add_action( 'wp_ajax_nopriv_ajax_loadmore', 'fixelar_related_products_load_more_event' );
add_action( 'wp_ajax_ajax_loadmore', 'fixelar_related_products_load_more_event' );

The key fix compared to a naive version of this snippet: the PHP side calculates has_more by comparing the full related products count against how many have been shown so far, and sends that back to JavaScript as JSON instead of raw HTML. That’s what lets the button hide itself at exactly the right moment, instead of guessing based on an empty response.

Adjust $per_page = 3; to load a different number of products per click.