40

I have a series of posts that are ordered by a meta_key value. They could also be arranged by menu order, if necessary.

The next/prev post links (generated by next_post_link, previous_post_link, or posts_nav_link all navigate by chronology. While I understand this default behaviour, I don't understand how to change it. I found that it maps through to adjacent_post_link in link-template.php, but then it starts to seem fairly hard-coded. Is it recommended to re-write this from scratch to replace it, or is there a better solution.

2

13 Answers 13

36

Understanding the internals

The "sort" order of adjacent (next/prev) posts is not really a sort "order". It's a separate query on each request/page, but it sorts the query by the post_date - or the post parent if you have a hierarchical post as currently displayed object.

When you take a look at the internals of next_post_link(), then you see that it's basically an API wrapper for adjacent_post_link(). The later function calls get_adjacent_post() internally with the $previous argument/flag set to bool(true|false) to grab the next or previous post link.

What to filter?

After digging deeper into it, you'll see that get_adjacent_post() Source link has some nice filters for its output (a.k.a. query result): (Filter Name/Arguments)

  • "get_{$adjacent}_post_join"

    $join
    // Only if `$in_same_cat`
    // or: ! empty( $excluded_categories` 
    // and then: 
    // " INNER JOIN $wpdb->term_relationships AS tr 
    //     ON p.ID = tr.object_id 
    // INNER JOIN $wpdb->term_taxonomy tt 
    //     ON tr.term_taxonomy_id = tt.term_taxonomy_id"; 
    // and if $in_same_cat then it APPENDS: 
    // " AND tt.taxonomy = 'category' 
    // AND tt.term_id IN (" . implode(',', $cat_array) . ")";
    $in_same_cat
    $excluded_categories
    
  • "get_{$adjacent}_post_where"

    $wpdb->prepare(
          // $op = $previous ? '<' : '>'; | $current_post_date
           "WHERE p.post_date $op %s "
          // $post->post_type
          ."AND p.post_type = %s "
          // $posts_in_ex_cats_sql = " AND tt.taxonomy = 'category' 
          // AND tt.term_id NOT IN (" . implode($excluded_categories, ',') . ')'; 
          // OR empty string if $in_same_cat || ! empty( $excluded_categories
          ."AND p.post_status = 'publish' $posts_in_ex_cats_sql "
        ",
        $current_post_date,
        $post->post_type
    )
    $in_same_cat
    $excluded_categories
    
  • "get_{$adjacent}_post_sort"

    "ORDER BY p.post_date $order LIMIT 1"`
    

So you can do alot with it. That starts with filtering the WHERE clause, as well as the JOINed table and the ORDER BY statement.

The result gets cached in memory for the current request, so it doesn't add additional queries if you call that function multiple times on a single page.

Automatic query building

As @StephenHarris pointed out in the comments, there's a core function that might come in handy when building the SQL Query: get_meta_sql() - Examples in Codex. Basically this function is just used to build the meta SQL statement that gets used in WP_Query, but you can use it in this case (or others) as well. The argument that you throw into it is an array, the exact same that would add to a WP_Query.

$meta_sql = get_meta_sql(
    $meta_query,
    'post',
    $wpdb->posts,
    'ID'
);

The return value is an array:

$sql => (array) 'join' => array(),
        (array) 'where' => array()

So you can use $sql['join'] and $sql['where'] in your callback.

Dependencies to keep in mind

In your case the easiest thing would be to intercept it in a small (mu)plugin or in your themes functions.php file and alter it depending on the $adjacent = $previous ? 'previous' : 'next'; variable and the $order = $previous ? 'DESC' : 'ASC'; variable:

The actual filter names

So the filter names are:

  • get_previous_post_join, get_next_post_join
  • get_previous_post_where, get_next_post_where
  • get_previous_post_sort, get_next_post_sort

Wrapped up as a plugin

...and the filter callback would be (for example) something like the following:

<?php
/** Plugin Name: (#73190) Alter adjacent post link sort order */
function wpse73190_adjacent_post_sort( $orderby )
{
    return "ORDER BY p.menu_order DESC LIMIT 1";
}
add_filter( 'get_previous_post_sort', 'wpse73190_adjacent_post_sort' );
add_filter( 'get_next_post_sort', 'wpse73190_adjacent_post_sort' );
7
  • 2
    +1. Just for information, (@magnakai) if doing something like this for meta queries, check out get_meta_sql() Commented Nov 19, 2012 at 18:30
  • +1 to you @StephenHarris ! Haven't seen this one before. Short question: As I read from the source that you have to pass a fully qualified query object, how would you do this with the above mentioned filters? As far as I can see there're only query strings passed, as the query is executed after the filters.
    – kaiser
    Commented Nov 19, 2012 at 18:58
  • 2
    nope, $meta_query is just the array you would pass to WP_Query for the meta_query, argument: In this example: $meta_sql = get_meta_sql( $meta_query, 'post', $wpdb->posts, 'ID'); - this generates the JOIN and WHERE part of the query that would need to be added. Commented Nov 19, 2012 at 19:41
  • @StephenHarris Perfect moment to edit one (my) answer.
    – kaiser
    Commented Nov 20, 2012 at 0:28
  • @StephenHarris, I'm having trouble applying the output of get_meta_sql() - can you help join up the dots? Commented Nov 20, 2012 at 11:27
27

Kaiser's answer is awesome and thorough, however just changing the ORDER BY clause isn't enough unless your menu_order matches your chronological order.

I can't take credit for this, but I found the following code in this gist:

<?php
/**
 * Customize Adjacent Post Link Order
 */
function wpse73190_gist_adjacent_post_where($sql) {
  if ( !is_main_query() || !is_singular() )
    return $sql;

  $the_post = get_post( get_the_ID() );
  $patterns = array();
  $patterns[] = '/post_date/';
  $patterns[] = '/\'[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\'/';
  $replacements = array();
  $replacements[] = 'menu_order';
  $replacements[] = $the_post->menu_order;
  return preg_replace( $patterns, $replacements, $sql );
}
add_filter( 'get_next_post_where', 'wpse73190_gist_adjacent_post_where' );
add_filter( 'get_previous_post_where', 'wpse73190_gist_adjacent_post_where' );

function wpse73190_gist_adjacent_post_sort($sql) {
  if ( !is_main_query() || !is_singular() )
    return $sql;

  $pattern = '/post_date/';
  $replacement = 'menu_order';
  return preg_replace( $pattern, $replacement, $sql );
}
add_filter( 'get_next_post_sort', 'wpse73190_gist_adjacent_post_sort' );
add_filter( 'get_previous_post_sort', 'wpse73190_gist_adjacent_post_sort' );

I've modified the function names for WP.SE.

If you only change the ORDER BY clause, the query still looks for posts greater than or less than the current post date. If your posts aren't in chronological order, you won't get the right post.

This changes the where clause to look for posts where the menu_order is greater than or less than the current post's menu_order, in addition to modifying the orderby clause.

The orderby clause also shouldn't be hardcoded to use DESC as it will need to switch based on whether you are getting the next or previous post link.

2
  • 3
    One note: The WHERE clause looks for 'YYYY-mm-dd HH:mm:ss'. If that isn't met, it won't work. As the value isn't set by the DB, but by the Application, you will have to check for that format first when building the Regular expression.
    – kaiser
    Commented Apr 6, 2014 at 12:11
  • If you're looking to order by post_title you can replace all instances of menu_order in the code above and it should work fine. Watch out for the second item in the $replacements array though - I had to wrap it in single quotes to work i.e. $replacements[] = '\'' . $the_post->post_title . '\''; Commented Jul 1, 2021 at 19:26
9

Tried to hook in without success. Might be just a problem of my configuration, but for those who can't make the hook work, here is the simplest solution:

<?php
    $all_posts = new WP_Query(array(
        'orderby' => 'menu_order',
        'order' => 'ASC',
        'posts_per_page' => -1
    ));

    foreach($all_posts->posts as $key => $value) {
        if($value->ID == $post->ID){
            $nextID = $all_posts->posts[$key + 1]->ID;
            $prevID = $all_posts->posts[$key - 1]->ID;
            break;
        }
    }
?>
<?php if($prevID): ?>
    <span class="prev">
        <a href="<?= get_the_permalink($prevID) ?>" rel="prev"><?= get_the_title($prevID) ?></a>
    </span>
<?php endif; ?>
<?php if($nextID): ?>
    <span class="next">
        <a href="<?= get_the_permalink($nextID) ?>" rel="next"><?= get_the_title($nextID) ?></a>
    </span>
<?php endif; ?>
5
  • after a few hours of trying to get get_previous_post_where, get_previous_post_join and get_previous_post_sort to play nice with custom post types and complex ordering that includes meta keys, I gave up and used this. Thanks! Commented Jul 12, 2018 at 22:52
  • 1
    Same here, not only did I want to order by Menu Order, but also look for posts with a specific meta_key and meta_value, therefore this was the best method. The only change I made was to wrap it into a function.
    – MrCarrot
    Commented Feb 10, 2019 at 8:35
  • 1
    this code is susceptible to out of range errors
    – eballeste
    Commented Apr 2, 2020 at 1:35
  • @eballeste, if you're referring to getting the first post when on the last and last when on the first see my answer below
    – Eli Jayson
    Commented Apr 3, 2020 at 19:10
  • yup, saw it after and upvoted, thanks
    – eballeste
    Commented Apr 8, 2020 at 23:38
4
function wpse73190_gist_adjacent_post_sort( $sql ) {
    $pattern = '/post_date/';
    $replacement = 'menu_order';

    return preg_replace( $pattern, $replacement, $sql );
}

add_filter( 'get_next_post_sort', 'wpse73190_gist_adjacent_post_sort' );
add_filter( 'get_previous_post_sort', 'wpse73190_gist_adjacent_post_sort' );
4

FWIW here’s how you can order by menu_order for a specific custom post type:

/**
 * Customize adjacent post order
 */
add_filter('get_next_post_sort', function($order) {
    if (is_singular('my_custom_post_type')) {
        return 'ORDER BY p.menu_order ASC LIMIT 1';
    }

    return $order;
}, 10);

add_filter('get_previous_post_sort', function($order) {
    if (is_singular('my_custom_post_type')) {
        return 'ORDER BY p.menu_order DESC LIMIT 1';
    }

    return $order;
}, 10);

add_filter('get_next_post_where', function() {
    if (is_singular('my_custom_post_type')) {
        global $post, $wpdb;
        return $wpdb->prepare("WHERE p.menu_order > %s AND p.post_type = %s AND p.post_status = 'publish'", $post->menu_order, $post->post_type);
    }
}, 10);

add_filter('get_previous_post_where', function() {
    if (is_singular('my_custom_post_type')) {
        global $post, $wpdb;
        return $wpdb->prepare("WHERE p.menu_order < %s AND p.post_type = %s AND p.post_status = 'publish'", $post->menu_order, $post->post_type);
    }
}, 10);

Hope this helps someone else!

3

Based on @Szabolcs Páll's answer I've created this utility class with helper methods to be able to get posts of type by menu order and get the next and previous post by menu order as well. I've additionally added conditions to check if the current post is the first or last post to get the last or first post respectively.

For example:

// $currentPost is first by menu order
getPreviousPostByMenuOrder($postType, $$currentPost->ID)
// returns => last post by menu order

// $currentPost is last by menu order
getPreviousPostByMenuOrder($postType, $$currentPost->ID)
// returns => first post by menu order

The full class:

class PostMenuOrderUtils {

    public static function getPostsByMenuOrder($postType){
        $args =[
            'post_type' => $postType,
            'orderby' => 'menu_order',
            'order' => 'ASC',
            'posts_per_page' => -1
        ];

        $posts = get_posts($args);

        return $posts;
    }

    public static function getNextPostByMenuOrder($postType, $postID){
        $posts = self::getPostsByMenuOrder($postType);

        $nextPost = null;

        foreach($posts as $key => $value) {
            if($value->ID == $postID){
                $nextPost = $posts[$key] !== end($posts) ? $posts[$key + 1] : $posts[0];

                break;
            }
        }

        return $nextPost;
    }

    public static function getPreviousPostByMenuOrder($postType, $postID){
        $posts = self::getPostsByMenuOrder($postType);


        $prevPost = null;

        foreach($posts as $key => $value) {
            if($value->ID == $postID){
                $prevPost = $key !== 0 ? $posts[$key - 1] : end($posts);
                break;
            }
        }

        return $prevPost;
    }

}
2

Based on @Szabolcs Páll's answer and bbloomer's post on adding next/prev buttons in WooCommerce Single Product Page, I created this code.

It sorts all products by meta key and adding prev/next buttons above + below the product.

(The meta key can be an ACF field too!)

/**
 * @snippet       Add next/prev buttons sorted by meta key or ACF field @ WooCommerce Single Product Page
 * @testedwith    WooCommerce 4.8.0
 * @source        Elron : https://wordpress.stackexchange.com/a/365334/98773
 * @thanks        bbloomer : https://businessbloomer.com/?p=20567
 * @thanks        Szabolcs Páll : https://wordpress.stackexchange.com/a/284045/98773
 */

add_action('woocommerce_before_single_product', 'elron_prev_next_product');

// and if you also want them at the bottom...
add_action('woocommerce_after_single_product', 'elron_prev_next_product');

function elron_prev_next_product()
{
   global $post;

   echo '<div class="prev-next-buttons">';

   $all_posts = new WP_Query(
      array(
         'post_type' => 'product',
         'meta_key' => 'the_meta_key_or_acf_field', // <-- CHANGE THIS
         'orderby' => 'meta_value',
         'order' => 'DESC',
         'posts_per_page' => -1
      )
   );

   foreach ($all_posts->posts as $key => $value) {
      if ($value->ID == $post->ID) {
         $nextID = $all_posts->posts[$key + 1]->ID;
         $prevID = $all_posts->posts[$key - 1]->ID;
         break;
      }
   }

   if ($prevID) : ?>
      <a href="<?= get_the_permalink($prevID) ?>" rel="prev" class="prev" title="<?= get_the_title($prevID) ?>"><?= esc_attr__('Previous product') ?></a>
   <?php endif; ?>
   <?php if ($nextID) : ?>
      <a href="<?= get_the_permalink($nextID) ?>" rel="next" class="next" title="<?= get_the_title($nextID) ?>"><?= esc_attr__('Next product') ?></a>
   <?php endif; ?>
<?php

   echo '</div>';
}

If you want the extra scss file I used: _prev-next-buttons.scss

.prev-next-buttons {
    background: $lightpurple;
    padding: 2em;
    text-align: center;

    a {
        opacity: 0.7;
        border-radius: 0.5em;
        border: $white 1px solid;
        color: $white;
        display: inline-block;
        padding: 0.5em 0.8em;
        text-decoration: none;
        margin: 0 0.1em;
        &:hover, &:focus {
            opacity: 1;
        }
    }

    .prev {
        &:before {
            content: " 🠔 ";
        }
    }
    .next {
        &:after {
            content: " 🠖 ";
        }
    }
}

.rtl {
    .prev-next-buttons {
        .prev {
            &:before {
                content: " 🠖 ";
            }
        }
        .next {
            &:after {
                content: " 🠔 ";
            }
        }
    }
}
1
  • Thanks for the valuable info. I have used it with my custom post type which has also custom date field. It worked like charm on Wordpress 5.9.3. I can also confirm that it worked with the custom post types and fields which were created using 3rd party plugins like ACF. Commented May 10, 2022 at 11:44
1

None of the answers listed here or on the internet in general that I could find as of this writing seemed to offer a reasonably simple/elegant solution for presenting the Next/Prev Post links ordered by meta key. This works well for me and is easy to adapt. Enjoy!

add_filter( 'get_previous_post_where', function( $where ) {
    return get_adjacent_post_where( $where, false) ;
});
add_filter( 'get_next_post_where', function ( $where ) {
    return get_adjacent_post_where( $where, true );
}); 

function get_adjacent_post_where( $where, $is_next ) {  

    global $post;

    /* Enter your post type -> */
    $post_type = "_my_post_type_";

    if ($post_type == $post->post_type){

        global $wpdb;

        $show_private = current_user_can( 'read_private_pages', $post->ID );
        /* Enter your custom field meta key name -> */
        $meta_key = '_my_meta_key_name_'; 
        $meta_value = get_post_meta($post->ID,$meta_key,true);
        $operand = $is_next?">":"<";
        $direction = $is_next?"ASC":"DESC";

        $sub_query = "(SELECT m.post_id FROM `" . $wpdb->postmeta . "` AS m JOIN `" . $wpdb->posts . "` as p1 ON m.post_id = p1.ID "
        . "WHERE m.meta_key = '$meta_key' AND m.meta_value $operand '$meta_value' "
        . "AND (p1.post_status = 'publish'" . ($show_private?" OR p1.post_status = 'private') ":") ")
        . "ORDER BY m.meta_value $direction LIMIT 1)";
         
        /* The nested subquery works around current mysql/mariadb limitations */
        $where = "WHERE p.post_type = '$post_type'  AND p.ID IN (SELECT * FROM $sub_query as sq)";  
    }

    return $where;

}
2
  • You have some references to prefixed tables in your code, eg kc_wppostmeta and kc_wpposts. Best practice is to use the global $wpdb object to specify these tables, eg $wpdb->posts instead of kc_wpposts. I also think it's worth noting that this assumes you're getting publicly or privately published content in your sub-query. There's an entire section of conditions that handle this in the core code: github.com/WordPress/wordpress-develop/blob/… Commented Feb 2 at 20:17
  • Thanks @MikeNGarrett. Modifications made. Please let me know if I've missed the mark.
    – Collie
    Commented Feb 5 at 0:55
0

I find this small plugin really handy: http://wordpress.org/plugins/wp-query-powered-adjacent-post-link/

WP_Query Powered Adjacent Post Link is a plugin for developers. It adds the function wpqpapl(); to WordPress which can return information on the previous and next post to the current. It accepts arguments for use in the WP_Query class.

0

This worked for me:

add_filter( 'get_previous_post_where', 'so16495117_mod_adjacent_bis' );
add_filter( 'get_next_post_where', 'so16495117_mod_adjacent_bis' );
function so16495117_mod_adjacent_bis( $where ) {
    global $wpdb;
    return $where . " AND p.ID NOT IN ( SELECT post_id FROM $wpdb->postmeta WHERE ($wpdb->postmeta.post_id = p.ID ) AND $wpdb->postmeta.meta_key = 'archive' AND $wpdb->postmeta.meta_value = 1 )";
}

Taken from: https://stackoverflow.com/questions/16495117/how-to-skip-certain-links-on-adjacent-posts-in-wordpress

0

I had issues with this too. I magically got it to work like this:

And i didn't have to write a word of code myself :)

0

I edited Szabolcs Páll's code above to order by a custom meta_key and within a specific category but also to try to add conditionals in for the first and last posts.

On first and last post it was not showing the correct next/prev link with the original code, only showing a link for the current post id I was on.

This worked for me below but not sure if there are any potential issues with it. (I'm not the most advanced coder)

<?php
$all_posts = new WP_Query(array(
    'taxonomy' => 'category',
    'category_name' => 'projects',
    'meta_key' => 'grid_number_projects',
    'orderby' => 'meta_value',
    'order' => 'ASC',
    'posts_per_page' => -1
));
foreach($all_posts->posts as $key => $value) {
    if($value->ID == $post->ID){
        $nextID = isset($all_posts->posts[$key + 1]) ? $all_posts->posts[$key + 1]->ID : null;
        $prevID = isset($all_posts->posts[$key - 1]) ? $all_posts->posts[$key - 1]->ID : null;
        break;
    }
}

?>

<div class="project-nav-prev">
    <?php if($prevID): ?>
        <a href="<?= get_the_permalink($prevID) ?>" rel="prev"><span class="arrow">←</span> PREV PROJECT </br><?= get_the_title($prevID) ?></a>
    <?php endif; ?>
</div>
<div class="project-nav-next">
    <?php if($nextID): ?>
        <a href="<?= get_the_permalink($nextID) ?>" rel="next">NEXT PROJECT <span class="arrow">→</span> </br><?= get_the_title($nextID) ?></a>
    <?php endif; ?>
</div>
-2

I have found a much easier way to achieve a meta-key based post navigation, without the need to modify functions.php.

My example: You have a products.php and you want to switch between products. The previous product is the next cheaper one, the next product the next more expensive one.

Here comes my solution for single.php:

<div class="post_navigation">

<?php

// Prepare loop
$args = (
'post_type' => 'products',
'post_status' => 'publish',
'meta_key' => 'price',
'orderby' => 'meta_value_num',
'order' => 'ASC',
'posts_per_page' => -1
);
query_posts($args);

// Initialize array in which the IDs of ALL products posts will be stored
$posts = array();

// ... and now let's start the loop
while ( have_posts() ) : the_post();
$posts[] += $post->ID;
endwhile;

// Reset Query
wp_reset_query();

// Identify the position of the current product within the $posts-array 
$current = array_search(get_the_ID(), $posts);

// Identify ID of previous product
$prevID = $posts[$current-1];

// Identify ID of next product
$nextID = $posts[$current+1];

// Link "previous product"
if (!empty($prevID)) { ?>
<a href="/?p=<?php echo $prevID; ?>">previous product</a>
<?php }
// Link "next product"
if (!empty($nextID)) { ?>
<a href="/?p=<?php echo $nextID; ?>">next product</a>

<?php } ?>
4
  • -10 for this answer. How can this be a better solution if you are using query_posts when the codex states it should not be used. Commented May 21, 2014 at 8:31
  • but it works. so the alternative is WP_Query or what? Commented May 21, 2014 at 9:22
  • Yes, WP_Query should be used as in previous answers. Commented May 21, 2014 at 9:27
  • 1
    @KentMiller, there's an informative diagram on the codex page, and you may also find this question to be handy. It's well worth familiarising yourself with these conventions. Commented May 21, 2014 at 10:56

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.