Aunque no soy partidario de paginar loops secundarios, salvo casos muy excepcionales y luego explicaré por qué, es una pregunta tan frecuente que le voy a dedicar un post a explicar como se hace. Vamos a ello.

Para crear un listado paginado necesitamos:

  • el parámetro post_per_page de WP_Query para establecer el número de posts que se muestran en cada página.
  • el parámetro paged para establecer el número de la página actual (o page si estamos un static front page).
  • las funciones previous_posts_link()/next_posts_link() para mostrar un enlace a la página anterior/siguiente o la función paginate_links() para mostrar un set enlaces a la primera/última página, a la siguiente/anterior y a un conjunto de páginas intermedias.

Imaginemos que tenemos este loop secundario para obtener «eventos» próximos según una fecha almacenada en un meta field:

$args = array(
  'post_type'  => 'event',
  'meta_key'   => 'event_start_date',
  'order'      => 'ASC',
  'orderby'    => 'meta_value',
  'meta_query' => array(
     array(
       'key'      => 'event_start_date',
       'value'    => date( "Y-m-d" ),
       'compare'  => '>=',
       'type'     => 'DATE'
     )
   )
 
);

$events_query = new WP_Query( $args );

if( $events_query->have_posts() ) { ?>

  <h1><?php _e( 'Upcoming events', 'textdomain' ); ?></h1>

  <?php while( $events_query->have_posts() ) {
    $events_query->the_post(); ?>

    <h2><?php the_title(); ?></h2>

  <?php }

  wp_reset_postdata();

} else {

  _e( 'No results found', 'textdomain'  );

}

Si queremos paginarlo:

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

$args = array(
  'post_type'    => 'event',
  'meta_key'     => 'event_start_date',
  'order'        => 'ASC',
  'orderby'      => 'meta_value',
  'meta_query'    => array(
     array(
       'key'      => 'event_start_date',
       'value'    => date( "Y-m-d" ),
       'compare'  => '>=',
       'type'     => 'DATE'
     )
  ),
  // Paginación
  'posts_per_page' => get_option('posts_per_page'),
  'paged'         => $current_page,
);

$events_query = new WP_Query( $args );

if( $events_query->have_posts() ) { ?>

  <h1><?php _e( 'Upcoming events', 'textdomain' ); ?></h1>

  <?php while( $events_query->have_posts() ) {
    $events_query->the_post(); ?>

    <h2><?php the_title(); ?></h2>

  <?php }

  echo paginate_links( array(
        'current' => $current_page,
        'total' => $events_query->max_num_pages
  ) );

  wp_reset_postdata();

} else {

  _e( 'No results found', 'textdomain'  );

}

¿Qué hemos hecho?

  1. Hemos obtenido la página actual con get_query_var( 'paged' ) y su valor lo hemos puesto como argumento paged en nuestro WP_Query. (Recuerda utilizar page si estás en un static front page).
  2. Hemos establecido el número de posts por página en el argumento post_per_page. Lo hemos puesto según el valor configurado para todo el sitio en Ajustes->Lectura pero puedes poner el número que quieras.
  3. Finalmente hemos utilizado la función paginate_links() y le hemos pasado los valores de la página actual y, aquí viene lo más importante, el número de páginas de nuestro query.

El punto tres es dónde más confusión suele haber. De forma predeterminada la función paginate_links() utiliza los datos del query principal pero nosotros estamos paginando un query secundario, así que tenemos que pasar los datos de este query secundario a la función:

$current_page = get_query_var( 'paged' ) ? get_query_var( 'paged' ): 1;
$args = array(
  // ....
  'posts_per_page' => get_option('posts_per_page'),
  'paged'         => $current_page
);
$mi_query = new WP_Query( $args );
// ...
echo paginate_links( array(
    'current' => $current_page,
    'total'   => $mi_query->max_num_pages
) );

Con previous_posts_link()/next_posts_link() ocurre algo similar. previous_posts_link() no necesita nada adicional pero next_posts_link() necesita conocer el número de páginas totales del query:

previous_posts_link( __( 'Previous page', 'textdomain' ) );
next_posts_link( __( 'Next page', 'textdomain' ), $mi_query->max_num_pages );

¿Cuándo paginar un loop secundario?

Prácticamente nunca. Cuándo se crean listados paginados suelen ir en el contenido principal de la página y si lo piensas un segundo te darás cuenta que «contenido principal» y «query secundario» chocan, son contradictorios.

Si te ves en la necesidad de modificar el contenido principal en cualquier parte de WordPress en la que intervengan posts de cualquier tipo, utiliza el action pre_get_posts para modificar el query principal según tus necesidades. Y como se trabaja con el query principal, las funciones de paginación no necesitan ninguna atención especial.

add_action( 'pre_get_posts', 'cyb_set_upcoming_events_query' );
function cyb_set_upcoming_events_query( $query ) {
  if ( $query->is_main_query() && ! is_admin() && alguna_otra_condicion_que_necesites() ) {

    $meta_query = array(
      array(
        'key'      => 'event_start_date',
        'value'    => date( "Y-m-d" ),
        'compare'  => '>=',
        'type'     => 'DATE'
      )
    );

    $query->set( 'post_type', 'event' );
    $query->set( 'meta_key', 'event_start_date' );
    $query->set( 'order', 'ASC' );
    $query->set( 'orderby', 'meta_value' );
    $query->set( 'meta_query', $meta_query );

  }
}

Además, al utilizar el action pre_get_posts le decimos a WordPress la solicitud que debe hacer a la base de datos desde el principio. De lo contrario, WordPress hará la solicitud que tenga que hacer (por ejemplo para obtener una página) y luego descartamos los datos de ese query para hacer nuestro query secundario y ponerlo como contenido principal. Hacemos queries extra sin necesidad.

Por estos motivos, si el query secundario es el contenido principal, modifica el query principal y olvídate del query secundario. Parece obvio, ¿verdad?