Add AJAX: Load More, Sorting, Filtering & Live Search to WordPress

Posted on Nov 28, 2024

by Jimmy

Last updated on March 13, 2025


An extensive snippet that allows for various useful functionalities.

This is building on my basic AJAX integration post. It is an extensive snippet, but will include a bunch of features I find myself using over and over again. Those features include:

  • Sorting
  • Live search results (posts will filter as the user types)
  • Pagination
  • Category filtering
  • A clear button

Please do remove whatever you do not need.

Step 1: Create the Dropdown Filters

filter.php

PHP
        
            <div class="search-bar">
    <div class="search-bar__categories">
        <button class="cat-tag cat-tag--active" data-category="">All</button>
        <?php $categories = get_categories();
        foreach( $categories as $category ) : ?>
            <button class="cat-tag" data-category="<?= esc_attr($category->slug); ?>"><?= esc_html($category->cat_name); ?></button>
        <?php endforeach; ?>
    </div>

    <div class="seach-bar__search">
        <input class="seach-bar__input" type="text" name="s" id="search-input" value="<?php the_search_query(); ?>" placeholder="Search for a snippet" required autocomplete="off">
        <button class="search-bar__clear" title="Clear search input" id="clear">
            <svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" version="1.1" viewBox="0 0 22.8 22.8">
                <defs>
                    <style>
                    .icon-close{fill:none;stroke:#fff;stroke-linecap:round;stroke-width:2px}
                    </style>
                </defs>
                <g id="icon-close">
                    <path id="Line_322" d="m1.4 1.4 20 20" class="icon-close" data-name="Line 322"/>
                    <path id="Line_323" d="m1.4 21.4 20-20" class="icon-close" data-name="Line 323"/>
                </g>
            </svg>
        </button>
    </div>
</div>

<div class="filter-bar">
    <div class="filter__sort">
        <select id="filter-sort" class="filter__dropdown">
            <option value="newest">Sort by newest</option>
            <option value="recent">Sort by recently updated</option>
            <option value="oldest">Sort by oldest</option>
            <option value="popular">Sort by most popular</option>
        </select>
    </div>
</div>        
    

Step 2: Add JavaScript for all the AJAX things

ajaxFilterPosts.js

JavaScript
        
            export default () => {
  const searchInput = jQuery('#search-input');
  const container = jQuery('#ajax-post-results');
  const top = jQuery('#top');
  const pagination = jQuery('.pagination');
  const prevPageButton = jQuery('#prev-page');
  const nextPageButton = jQuery('#next-page');
  const currentPageSpan = jQuery('.pagination__current-page');
  const totalPagesSpan = jQuery('.pagination__total-pages');
  const sortSelect = jQuery('#filter-sort');
  const categoryButtons = jQuery('.cat-tag');
  const clear = jQuery('#clear');

  let selectedCategory = '';
  let currentPage = 1;
  let totalPages = 1;

  // Helper function to get a URL parameter
  const getURLParam = (param, defaultValue = '') => {
    const urlParams = new URLSearchParams(window.location.search);
    return urlParams.get(param) || defaultValue;
  };

  // Helper function to update the URL without reloading
  const updateURL = (param, value) => {
    const url = new URL(window.location);
    url.searchParams.set(param, value);
    window.history.pushState({}, '', url);
  };

  // Function to update pagination UI
  const updatePaginationUI = () => {
    if( totalPages > 1 ) {
      pagination.css('display', 'flex');
    } else {
      pagination.css('display', 'none');
    }

    currentPageSpan.text(currentPage);
    totalPagesSpan.text(totalPages);
    prevPageButton.prop('disabled', currentPage === 1);
    nextPageButton.prop('disabled', currentPage >= totalPages);
  };

  // Function to scroll the page to the top on pagination change
  const scrollToTop = () => {
    jQuery('html, body').animate({
      scrollTop: top.offset().top - 50,
    }, 500);
  };

  // Function to filter posts via AJAX
  const filterPosts = () => {
    jQuery.ajax({
      url: ajaxurl.url,
      type: 'POST',
      data: {
        action: 'filter_posts',
        search: searchInput.val(),
        page: currentPage,
        category: selectedCategory,
        sort: sortSelect.val(),
      },
      beforeSend: () => {
        container.html('<div class="spinner"><div></div><div></div><div></div><div></div></div>');
      },
      success: (response) => {
        container.html(response.html);
        totalPages = response.max_pages;
        updatePaginationUI();
      },
    });
  };

  // Function to initialize values from the URL
  const initializeFiltersFromURL = () => {
    selectedCategory = getURLParam('category');
    searchInput.val(getURLParam('search'));
    sortSelect.val(getURLParam('sort', 'newest'));

    // Highlight the correct category button
    categoryButtons.removeClass('cat-tag--active');
    jQuery(`.cat-tag[data-category="${selectedCategory}"]`).addClass('cat-tag--active');
  };

  // Event: Category Click
  categoryButtons.on('click', function (e) {
    e.preventDefault();
    selectedCategory = jQuery(this).data('category') || '';
    
    categoryButtons.removeClass('cat-tag--active');
    jQuery(this).addClass('cat-tag--active');

    currentPage = 1;
    updateURL('category', selectedCategory);
    filterPosts();
  });

  // Event: Pagination Buttons
  prevPageButton.on('click', () => {
    if (currentPage > 1) {
      currentPage--;
      filterPosts();
      scrollToTop();
    }
  });

  nextPageButton.on('click', () => {
    if (currentPage < totalPages) {
      currentPage++;
      filterPosts();
      scrollToTop();
    }
  });

  // Event: Search Input with Debounce
  searchInput.on('keyup', () => {
    clearTimeout(window.searchTimeout);
    window.searchTimeout = setTimeout(() => {
      currentPage = 1;
      updateURL('search', searchInput.val());
      filterPosts();
    }, 300);

    // Show clear button if input has value
    if( searchInput.val() ) {
      clear.css('display', 'block');
    } else {
      clear.css('display', 'none');
    }
  });

  // Event: Clear search input
  clear.on('click', () => {
    searchInput.val('');
    clear.css('display', 'none');
    currentPage = 1;
    updateURL('search', searchInput.val());
    filterPosts();
  });

  // Event: Sort Select Change
  sortSelect.on('change', () => {
    updateURL('sort', sortSelect.val());
    filterPosts();
  });

  // Initialize filters and trigger initial post load
  initializeFiltersFromURL();
  filterPosts();
};        
    

And make sure to import this code in your main.js file.

Step 3: Handle the AJAX Request in WordPress

ajax-filter-posts.php

PHP
        
            <?php
// Handle filtering for projects
add_action('wp_ajax_filter_posts', 'filter_posts_function');
add_action('wp_ajax_nopriv_filter_posts', 'filter_posts_function');

function filter_posts_function() {
    $search_query = isset($_POST['search']) ? sanitize_text_field($_POST['search']) : '';
    $category = isset($_POST['category']) ? sanitize_text_field($_POST['category']) : '';
    $sort_option = isset($_POST['sort']) ? sanitize_text_field($_POST['sort']) : 'newest';
    $page = isset($_POST['page']) ? intval($_POST['page']) : 1;

    $args = [
        'post_type' => 'post',
        'post_status' => 'publish',
        'posts_per_page' => get_option('posts_per_page'),
        'paged' => $page,
        's' => $search_query,
    ];

    // Add category filter only if a specific category is selected
    if (!empty($category)) {
        $args['category_name'] = $category;
    }

    // Add sorting options
    if ($sort_option === 'newest') {
        $args['orderby'] = [
            'date' => 'DESC'
        ];
    } elseif ($sort_option === 'recent') {
        $args['orderby'] = [
            'modified' => 'DESC'
        ];
    } elseif ($sort_option === 'oldest') {
        $args['orderby'] = [
            'date' => 'ASC'
        ];
    } elseif ($sort_option === 'popular') {
        $args['orderby'] = [
            'post_views' => 'ASC'
        ];
    }

    $query = new WP_Query($args);
    $total_posts_query = new WP_Query(array_merge($args, ['posts_per_page' => -1]));
    $total_posts = $total_posts_query->found_posts;

    ob_start();
    if ($query->have_posts()) {
        while ($query->have_posts()) {
            $query->the_post();
            echo '<article class="card">';
                get_template_part('inc/card-content');
            echo '</article>';
        }
    } else {
        if ($page === 1) {
            echo '<p class="loop-message">Sorry, no results were found.</p>';
        }
    }
    $html = ob_get_clean();

    wp_reset_postdata();

    // Output JSON response
    wp_send_json([
        'html' => $html,
        'max_pages' => $query->max_num_pages,
        'total_posts' => $total_posts,
    ]);

    wp_die(); // Required to terminate immediately and return a proper response
}        
    

And import this code in your functions.php file: get_template_part('functions/ajax-filters-posts.php');

Step 4: Enqueue and Localize

functions.php

PHP
        
            function scripts() {

    // AJAX URL
    wp_localize_script('js', 'ajaxurl', array(
        'url' => admin_url('admin-ajax.php')
    ));

}        
    

Step 5: Include filter.php

Include your filter.php file wherever you need it and create the container div where your posts will be injected. For me, it was in my projects.php template.

projects.php

PHP
        
            <?php get_template_part('inc/filter'); ?>
<div class="articles__loop" id="ajax-post-results" data-max-pages="<?= $wp_query->max_num_pages; ?>">
    <?php /** Filtered posts will be injected here */ ?>
</div><!-- END articles__loop -->

<button id="load-more-btn" class="load-more-button">Load more projects</button>        
    

Back to Snippets