How to make WordPress custom loop with pagination

How to Make a WordPress Custom Loop with Pagination

The default WordPress pagination doesn’t work with custom loops built on WP_Query. If you build a custom post loop with WP_Query, it won’t automatically get WordPress’s usual pagination functions like previous_posts_link(), next_posts_link(), or paginate_links() — you have to wire them up yourself.

Learning to build a WordPress custom loop with pagination is genuinely useful, since you won’t always want the default query on a page. Setting up a custom query is handy any time you want to display posts on a static page, inside a shortcode, or filtered to specific parameters that the default query doesn’t support.

What is WP_Query?

WP_Query is one of the most important classes in WordPress, used constantly by developers to display custom loops of posts, pages, and custom post types. It gives you programmatic access to the content in the database without writing raw SQL — WP_Query builds and runs the underlying query for you.

Now that you have the general idea of WP_Query and its usage, let’s build a working custom loop with pagination step by step.

First, get the current page number from the main query’s paged variable:

$paged = get_query_var('paged') ? get_query_var('paged') : 1;

Now pass this variable into the arguments array for the custom query:

$args = array( 
    'post_type' => 'post', 
    'posts_per_page' => get_option( 'posts_per_page' ), // You can enter your count manually. Ex:- 10
    'paged'=> $paged,
); 
$query = new WP_Query( $args );

Note: Don’t set 'no_found_rows' => true on a query you plan to paginate — that parameter skips the database call that counts total matching rows, which is exactly what paginate_links() needs via $query->max_num_pages below. It’s a common performance optimization for queries that don’t need pagination, but it silently breaks pagination if applied here.

Now $query holds our custom results along with the pagination info WordPress calculated. Loop through it as usual:

if ($query->have_posts()) :
    // Start looping over the query results.
    while ($query->have_posts()) : $query->the_post();

        $current_id = get_the_ID();
        $featured_img_url = get_the_post_thumbnail_url(get_the_ID(), 'full'); 
        $author_id = get_the_author_meta('ID');
        $categories = get_the_category(get_the_ID());
        ?>
        <a href="<?php the_permalink(); ?>">
            <img src="<?php echo esc_url( $featured_img_url ); ?>" alt="<?php the_title_attribute(); ?>">
        </a>
        <div class="post-date"><?php echo esc_html( get_the_date( 'F j, Y' ) ); ?> </div>
        <h3 class="post-title"><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h3>
        <div class="post-author">By <a><?php the_author_meta( 'user_nicename', $author_id ); ?> </a></div>
        <div class="post-cat">
            <?php foreach ( $categories as $key => $value ) { echo esc_html( $value->category_nicename ); } ?>
        </div>
        <?php 
    endwhile;
endif;
wp_reset_postdata();

Note: The original version of this snippet used $post->ID and $post->post_author directly. That works fine when this code runs in the global scope of a template file, but breaks the moment it’s wrapped inside a function (as it is below) — more on that in the warning after the full function. Using get_the_ID() and get_the_author_meta('ID') instead avoids the problem entirely, since they read from WordPress’s internal post/author state rather than a variable that needs to be explicitly globalized.

Add pagination links right after the endwhile:

<div class="nav-links">
    <?php
    // Create custom pagination links
    $big = 999999999; // Set an arbitrarily large number
    $pagination = paginate_links(array(
        'base'      => str_replace($big, '%#%', esc_url(get_pagenum_link($big))),
        'format'    => '?paged=%#%',
        'current'   => max(1, get_query_var('paged')),
        'total'     => $query->max_num_pages,
        'type'      => 'array',
        'prev_text' => '«',
        'next_text' => '»',
        ));

        // Output the pagination links
        if ($pagination) {
            echo '<div class="pagination">';
                foreach ($pagination as $link) {
                    echo '<span class="page-link">' . wp_kses_post( $link ) . '</span>';
                }
            echo '</div>';
        }
    ?> 
</div>

Setting type to 'array' returns an array of pagination links instead of a single string, so you can loop through them and wrap each one in your own HTML markup, as shown above. The prev_text and next_text parameters customize the previous/next link labels — adjust the markup inside the foreach loop to match your theme’s styling.

Putting It All Together as a Shortcode

Now let’s wrap the full custom query and pagination into a reusable shortcode:

function post_grid() 
{  
    global $wp_query;
    ob_start();

    $paged = get_query_var('paged') ? get_query_var('paged') : 1;

    $args = array( 
        'post_type' => 'post', 
        'posts_per_page' => get_option( 'posts_per_page' ), // You can enter your count manually. Ex:- 10
        'paged'=> $paged,
    ); 

    $query = new WP_Query( $args );

    if ($query->have_posts()) :

        // Start looping over the query results.
        while ($query->have_posts()) : $query->the_post();

            $featured_img_url = get_the_post_thumbnail_url(get_the_ID(), 'full'); 
            $author_id = get_the_author_meta('ID');
            $categories = get_the_category(get_the_ID());
            ?>
            <a href="<?php the_permalink(); ?>">
                <img src="<?php echo esc_url( $featured_img_url ); ?>" alt="<?php the_title_attribute(); ?>">
            </a>
            <div class="post-date"><?php echo esc_html( get_the_date( 'F j, Y' ) ); ?> </div>
            <h3 class="post-title"><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h3>
            <div class="post-author">By <a><?php the_author_meta( 'user_nicename', $author_id ); ?> </a></div>
            <div class="post-cat">
                <?php foreach ( $categories as $key => $value ) { echo esc_html( $value->category_nicename ); } ?>
            </div>
            <?php 
        endwhile;
        ?>

        <div class="nav-links">
            <?php
            // Create custom pagination links
            $big = 999999999; // Set an arbitrarily large number
            $pagination = paginate_links(array(
                'base'      => str_replace($big, '%#%', esc_url(get_pagenum_link($big))),
                'format'    => '?paged=%#%',
                'current'   => max(1, get_query_var('paged')),
                'total'     => $query->max_num_pages,
                'type'      => 'array',
                'prev_text' => '«',
                'next_text' => '»',
            ));

            // Output the pagination links
            if ($pagination) {
                echo '<div class="pagination">';
                    foreach ($pagination as $link) {
                        echo '<span class="page-link">' . wp_kses_post( $link ) . '</span>';
                    }
                echo '</div>';
            }
            ?> 
        </div>
        <?php
    endif;
    wp_reset_postdata();
    return ob_get_clean(); 
}
add_shortcode('post_grid', 'post_grid');

Warning: The original version of this function referenced $post->ID and $post->post_author directly inside the function body, without ever declaring global $post;. Since post_grid() is a function, PHP gives it its own local variable scope — the global $post that $query->the_post() sets up is not automatically visible inside a function unless you either add global $post; at the top, or avoid referencing $post directly and use template functions like get_the_ID() and get_the_author_meta() instead, as the corrected version above does. Without one of those two fixes, this shortcode throws a fatal “Attempt to read property “ID” on null” error in PHP 8+.

Note: The corrected version also swaps wp_reset_query() for wp_reset_postdata(). wp_reset_query() is meant to undo changes made by query_posts() to the main query — it’s not the right cleanup function for a standalone WP_Query object like this one. wp_reset_postdata() is the function WordPress core actually documents for restoring global post data after a custom WP_Query loop, and it’s what should follow any loop built this way.

That’s it — now you can drop [post_grid] into any post, page, or widget to display a paginated WordPress custom loop.

Common Mistakes to Avoid

  • Forgetting global $post; when referencing $post directly inside a function, as covered above — or better, avoid the global entirely and use template tag functions.
  • Using wp_reset_query() instead of wp_reset_postdata() after a custom WP_Query loop.
  • Setting 'no_found_rows' => true on a query that needs pagination, which silently breaks max_num_pages.
  • Echoing raw category or user data without escaping — category names and display names can technically contain characters that need esc_html() before being echoed into HTML.

Wrapping Up

A paginated custom loop is really three pieces: reading the current page number, passing it into WP_Query‘s paged argument, and feeding $query->max_num_pages into paginate_links(). Wrapping the whole thing in a shortcode, as shown here, makes it reusable anywhere on the site — just remember the scope gotcha with $post if you adapt this pattern into your own function. If you’re querying a large custom post type and want to keep this fast at scale, our SQL query optimization guide covers indexing and caching strategies that pair well with a heavily-used custom loop like this one.

Leave a Reply

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

2 replies on “How to make WordPress custom loop with pagination”

  • […] the loop, without declaring global $post;. Just like the fatal-error scope bug covered in our custom loop with pagination guide, this works fine at the top level of a template file but throws “Attempt to read property […]

  • […] loop with proper pagination (needed the moment you have more movies than fit on one page), see our custom loop with pagination guide — the same WP_Query pattern applies directly to a custom post […]

The link has been Copied to clipboard!