WordPress provides get_adjacent_post() (and its convenience wrappers get_next_post() / get_previous_post()) to fetch the post immediately before or after the current post in chronological order. These functions power the “Previous Post” and “Next Post” navigation links on single post pages. By default, adjacency is calculated by publish date across all posts in the same taxonomy, but both functions accept arguments that restrict navigation to posts in the same category, in a specific taxonomy, or excluding specific terms. The underlying SQL query for adjacent posts can be expensive on large sites — it requires a subquery comparing dates — and is not cached by default. Understanding how to customise the query (via the get_previous_post_where and get_next_post_where filters) and how to cache the result is important for performance-critical single post templates.
Problem: A magazine site's single post template needs: (1) previous/next navigation restricted to posts in the same primary category, (2) the navigation showing the post thumbnail and excerpt alongside the title, and (3) a cached version of the adjacent post data to avoid repeated DB queries on popular posts.
Solution: Use get_adjacent_post() with $in_same_term = true and the taxonomy name. Cache the result per post ID in a transient. Build the navigation HTML with thumbnail and excerpt from the returned post object.
<?php
// ── Get adjacent post with caching ────────────────────────────────────
function get_adjacent_post_cached( bool $previous, int $post_id ): ?WP_Post {
$direction = $previous ? 'prev' : 'next';
$transient_key = "adjacent_post_{$direction}_{$post_id}";
$cached = get_transient( $transient_key );
if ( false !== $cached ) {
// Cached as 0 means "no adjacent post"
return $cached ?: null;
}
// get_adjacent_post() reads the global $post; set it up
global $post;
$original_post = $post;
$post = get_post( $post_id );
setup_postdata( $post );
$adjacent = get_adjacent_post(
true, // $in_same_term — restrict to same category
'', // $excluded_terms — comma-separated term IDs to exclude
$previous, // $previous — true=previous, false=next
'category' // $taxonomy — 'category', 'post_tag', or any custom taxonomy
);
// Restore global post
$post = $original_post;
setup_postdata( $post );
// Cache: store 0 for "no adjacent" to avoid repeated queries
set_transient( $transient_key, $adjacent ?: 0, 6 * HOUR_IN_SECONDS );
return $adjacent ?: null;
}
// ── Clear cache when post is saved/updated ────────────────────────────
add_action( 'save_post', function ( int $post_id ) {
delete_transient( "adjacent_post_prev_{$post_id}" );
delete_transient( "adjacent_post_next_{$post_id}" );
} );
// ── Render full post navigation block ────────────────────────────────
function render_post_navigation( int $post_id ): void {
$prev = get_adjacent_post_cached( true, $post_id );
$next = get_adjacent_post_cached( false, $post_id );
echo '<nav class="post-navigation" aria-label="Post navigation">';
echo '<div class="post-navigation__inner">';
if ( $prev ) {
render_nav_item( $prev, 'Previous', '←' );
}
if ( $next ) {
render_nav_item( $next, 'Next', '→' );
}
echo '</div></nav>';
}
function render_nav_item( WP_Post $post, string $label, string $arrow ): void {
$url = get_permalink( $post );
$title = get_the_title( $post );
$excerpt = get_the_excerpt( $post );
$thumb_id = get_post_thumbnail_id( $post );
$thumb_img = $thumb_id
? wp_get_attachment_image( $thumb_id, 'thumbnail', false, [ 'class' => 'nav-thumb' ] )
: '';
printf(
'<a href="%s" class="post-nav-item">%s<div class="post-nav-item__text">'
. '<span class="post-nav-item__label">%s post</span>'
. '<span class="post-nav-item__title">%s %s</span>'
. '<span class="post-nav-item__excerpt">%s</span>'
. '</div></a>',
esc_url( $url ),
$thumb_img,
esc_html( $label ),
esc_html( $arrow ),
esc_html( $title ),
esc_html( wp_trim_words( $excerpt, 12 ) )
);
}
NOTE: get_adjacent_post() relies on the global $post variable to determine the current post and its publish date — always ensure setup_postdata() has been called with the correct post before calling this function. On sites with thousands of posts and complex category hierarchies, the adjacent post SQL query can take 50–200ms due to the subquery structure. Adding an index on post_date and post_status together (a composite index) can significantly speed it up. The get_previous_post_where and get_next_post_where filters allow replacing the WHERE clause entirely — this is the correct extension point for restricting adjacency by custom meta fields (for example, navigating only between posts by the same author).