Add a custom endpoint to WordPress REST API with Pagination

Are you trying to add a custom endpoint and/or route to the WordPress REST API?

Are you then stuck in a rut if its missing pagination?

Have no fear, I got your back! (full code is the bottom before that are small explanations explaining what’s going on)

Adding a custom endpoint to WordPress REST API

We are going to start with extending WP_REST_CONTROLLER

class REFINED_POST_ENDPOINT extends WP_REST_Controller {

}
add_action( 'rest_api_init', function () {
  $controller = new REFINED_POST_ENDPOINT();
  $controller->register_routes();
} );

In REFINED_POST_ENDPOINT is where we’ll place code specifics and the add_action is what will run all the little magical things we do (you added code, and route, this will run it).

In our newly created class we will start with the following constructor:

/**
* Constructor.
 */
public function __construct() {
  $this->namespace = 'rmh/v1';
  $this->rest_base = 'posts';
  $this->post_type = 'post';
}

From top to bottom:

  • rest base is set: yourdomain.com/wp-json/rmh/v1 (or whatever your heart desires!)
  • next is the base for the endpoint itself “posts”, not to be confused with the post type.  (yourdomain.com/wp-json/rmh/v1/posts)
  • last is the post type we are getting.  In this example, we are grabbing “post”

Registering your new route!

/**
   * Register the component routes.
   */
  public function register_routes() {
    register_rest_route( $this->namespace, '/' . $this->rest_base, array(
      array(
        'methods'             => WP_REST_Server::READABLE,
        'callback'            => array( $this, 'get_items' ),
        'permission_callback' => array( $this, 'get_items_permissions_check' ),
        'args'                => $this->get_collection_params(),
      )
    ) );
  }

From top to bottom:

  • We are combing your namespace and base together
  • making the request READABLE so to be clear that means this is a get request (see here for me details)
  • we will then get then return the code placed in our get_items function
  • it then checks to make sure we have permission to do so through our get_items_permissions_check
  • and it checks what are the possible arguments that can be used with it get_collection_params()

Retrieving posts and setting Response Headers for pagination (X-WP-Total, X-WP-TotalPages)

/**
   * Retrieve posts.
   */
  public function get_items( $request ) {
    $args = array(
      'post_type'      => $this->post_type,
      'posts_per_page' => $request['per_page'],
      'paged'           => $request['page'],
      //'name' => $request['slug'],
    );
  
    // use WP_Query to get the results with pagination
    $query = new WP_Query( $args ); 

    // if no posts found return 
    if( empty($query->posts) ){
      return new WP_Error( 'no_posts', __('No post found'), array( 'status' => 404 ) );
    }

    // set max number of pages and total num of posts
    $posts = $query->posts;
    
    $max_pages = $query->max_num_pages;
    $total = $query->found_posts;

    foreach ( $posts as $post ) {
      $response = $this->prepare_item_for_response( $post, $request );
      $data[] = $this->prepare_response_for_collection( $response );  
    }

  
    // set headers and return response      
    $response = new WP_REST_Response($data, 200);

    $response->header( 'X-WP-Total', $total ); 
    $response->header( 'X-WP-TotalPages', $max_pages );

    return $response;
  }


/**
 * Check if a given request has access to post items.
 */
   public function get_items_permissions_check( $request ) {
	return true;
  }

/**
 * Get the query params for collections
 */
   public function get_collection_params() {
	return array(
		'page'     => array(
			'description'       => 'Current page of the collection.',
			'type'              => 'integer',
			'default'           => 1,
			'sanitize_callback' => 'absint',
		),
		'per_page' => array(
			'description'       => 'Maximum number of items to be returned in result set.',
			'type'              => 'integer',
			'default'           => 10,
			'sanitize_callback' => 'absint',              
		),
	);
   }

/**
 * Prepares post data for return as an object.
 */
public function prepare_item_for_response( $post, $request ) {

	$data = array(
		'name'    => $post->post_title,
		'copy' => $post->post_content,
		'featured_img' => get_the_post_thumbnail_url($post->ID, 'full'),
		'external_link' => get_field('external_post_link',$post->ID),	
		'date' => date( "F j, Y", strtotime($post->post_date))
	);

	return $data;
}

From top to bottom get_items:

  • We are setting are variables for WP_Query
    • why that instead of get_posts? Because we want to return the total number of pages and posts with it as well
  • We then set the variables
    • If nothing then let us know
  • Set the variables for what we will loop through, total number pages and total number of posts found
  • Loop through the posts that will check the data we set and return it in the response
  • Finish get_items up by returning the response and setting the response headers for pagination
  • We then set the permission check
  • For get_collection_params we are setting the page and per_page details and defaults
  • For prepare_item_for_response we are setting the data we literally returning per post and is called above in get_items
    • You, of course, would set this based on your needs.
    • In this example, we are getting the title/content/featured image/an ACF Field/date

If we put it all together it looks like this:

class REFINED_POST_ENDPOINT extends WP_REST_Controller {
    /**
   * Constructor.
   */
  public function __construct() {
    $this->namespace = 'rmh/v1';
    $this->rest_base = 'posts';
        $this->post_type = 'post';
  }

  /**
   * Register the component routes.
   */
  public function register_routes() {
    register_rest_route( $this->namespace, '/' . $this->rest_base, array(
      array(
        'methods'             => WP_REST_Server::READABLE,
        'callback'            => array( $this, 'get_items' ),
        'permission_callback' => array( $this, 'get_items_permissions_check' ),
        'args'                => $this->get_collection_params(),
      )
    ) );
  }
  
  /**
   * Retrieve posts.
   */
  public function get_items( $request ) {
    $args = array(
      'post_type'      => $this->post_type,
      'posts_per_page' => $request['per_page'],
      'paged'           => $request['page'],
      //'name' => $request['slug'],
    );
  
    // use WP_Query to get the results with pagination
    $query = new WP_Query( $args ); 

    // if no posts found return 
    if( empty($query->posts) ){
      return new WP_Error( 'no_posts', __('No post found'), array( 'status' => 404 ) );
    }


    // set max number of pages and total num of posts
    $posts = $query->posts;
    
    $max_pages = $query->max_num_pages;
    $total = $query->found_posts;

    foreach ( $posts as $post ) {
      $response = $this->prepare_item_for_response( $post, $request );
      $data[] = $this->prepare_response_for_collection( $response );  
    }

  
    // set headers and return response      
    $response = new WP_REST_Response($data, 200);

    $response->header( 'X-WP-Total', $total ); 
    $response->header( 'X-WP-TotalPages', $max_pages );

    return $response;
  }
  
  /**
   * Check if a given request has access to post items.
   */
  public function get_items_permissions_check( $request ) {
    return true;
  }
  
  /**
   * Get the query params for collections
   */
  public function get_collection_params() {
    return array(
      'page'     => array(
        'description'       => 'Current page of the collection.',
        'type'              => 'integer',
        'default'           => 1,
        'sanitize_callback' => 'absint',
      ),
      'per_page' => array(
        'description'       => 'Maximum number of items to be returned in result set.',
        'type'              => 'integer',
        'default'           => 10,
        'sanitize_callback' => 'absint',              
      ),
    );
  }
  
  /**
   * Prepares post data for return as an object.
   */
  public function prepare_item_for_response( $post, $request ) {

    $data = array(
      'name'    => $post->post_title,
      'copy' => $post->post_content,
      'featured_img' => get_the_post_thumbnail_url($post->ID, 'full'),
      'external_link' => get_field('external_post_link',$post->ID),	
      'date' => date( "F j, Y", strtotime($post->post_date))
    );
  
    return $data;
  }
}





add_action( 'rest_api_init', function () {
  $controller = new REFINED_POST_ENDPOINT();
  $controller->register_routes();
} );

Now you’ll have the following:

  • yourdomain.com/wp-json/rmh/v1/posts
  • yourdomain.com/wp-json/rmh/v1/posts?page=2 etc
  • yourdomain.com/wp-json/rmh/v1/posts?per_page=3 etc
  • yourdomain.com/wp-json/rmh/v1/posts?per_page=5&page=3 and so on!