WordPressサイトの記事下部に配置する「前の記事・次の記事」ナビゲーション。
この記事では、WP_Query を使って 「順序(menu_order)」または「公開日」で並び順を指定できる 「前の記事・次の記事」ナビゲーションを、カスタムテーマ向けに実装する方法を解説します。PHP・CSSのフルコード付きです。


    完成イメージ

    • 記事ページ下部に、前の記事・次の記事を PC横並び/SP縦並び で表示
    • サムネイル、タイトル、「前の記事」「次の記事」ラベルを配置
    • タイトルは指定文字数を超えたら 末尾を で省略 (CSSでも複数行クランプ)
    • 前後の記事がない場合は 「記事がありません」 をグレーで表示(リンク無効)
    • 設定変数ひとつ で並び順(順序 or 日付)を切り替え可能

    標準の get_previous_post() では不十分

    WordPress の get_previous_post() / get_next_post() は、 投稿日(post_date)を基準に前後の記事を判定 します。シンプルなブログ運用ならこれで十分ですが、実案件では次のような弱点が問題になります。

    • 既存記事を複製して新記事を作った場合、post_date が古いまま引き継がれると、公開順とは違う位置に前後リンクがつながる
    • 「順序(menu_order)」で手動並び替えしたい要件に対応できない
    • 並び順のカスタマイズ余地がほとんどない

    そこで、WP_Query で同じ投稿タイプの記事を任意の並び順で取得し、その配列の中で現在記事の前後を割り出す方式に置き換えます。


    実装コード(フルソース)

    以下を template-parts/prevnextPost.php などのファイル名で保存し、single.php から get_template_part() で呼び出します。

    <style>
    	/* prevnextPost:CSS(前の記事・次の記事)
    	────────────── ༚༶⊰⟡⊱༶༚༺*/
    	.prextPost.prextPost-container {
    		margin-top: 2.5rem;
    		width: 728px;
    		max-width: 100%;
    		height: auto;
    		margin-inline: auto;
    	}
    
    	.prextPost .prextPost-wrapper {
    		width: 100%;
    	}
    
    	.prextPost a {
    		display: inline-block;
    		width: 100%;
    		height: 100%;
    		background: var(--c-secondary-20, hsl(224, 25%, 98%));
    	}
    
    	/* hover */
    	@media (any-hover: hover) {
    		.prextPost [href]:hover {
    			background: var(--c-secondary-100, hsl(224, 25%, 93%));
    			opacity: 1;
    		}
    	}
    
    	.prextPost [class*="prextPost-slide"] {
    		position: relative;
    		display: flex;
    		align-items: center;
    		width: 100%;
    		height: auto;
    		border: 1px var(--c-base-300, hsl(224, 6%, 74%)) solid;
    	}
    
    	.prextPost .prextPost-term {
    		flex-shrink: 0;
    		width: 5.625rem;
    		height: 5.625rem;
    		margin-top: auto;
    		background: var(--c-base-40, hsl(224, 6%, 96%));
    	}
    
    	.prextPost .prextPost-term img {
    		width: 100%;
    		height: 100%;
    		object-fit: cover;
    	}
    
    	.prextPost .prextPost-data {
    		flex-grow: 1;
    		padding-inline: 1pc;
    		text-align: center;
    	}
    
    	.prextPost .prextPost-title {
    		font-size: clamp(0.8750rem, calc(0.8750rem + ((1vw - 3.6px) * 0.1563)), 0.9375rem);
    		font-weight: 500;
    		line-height: 1.5;
    		text-align: center;
    		color: var(--c-base-900, hsl(224, 6%, 13%));
    	}
    
    	/* リンク無効 */
    	.prextPost a.hasnt-post {
    		pointer-events: none;
    		cursor: none;
    	}
    
    	.prextPost .prextPost-badge {
    		padding-block: 7px;
    		font-size: 14px;
    		font-weight: 600;
    		line-height: 1;
    	}
    
    	.prextPost a.hasnt-post .prextPost-title {
    		color: var(--c-base-300, hsl(224, 6%, 74%));
    	}
    
    	/* prextPost:CSS @(SP) */
    	@media screen and (max-width: 743.9px) {
    		.prextPost .prextPost-slide-next { margin-top: 2.5rem; }
    
    		.prextPost .prextPost-badge {
    			position: absolute;
    			top: -3em;
    			left: 0px;
    			margin: auto;
    			color: var(--c-base-900, hsl(224, 6%, 13%));
    		}
    
    		.prextPost .prextPost-badge::before {
    			position: relative;
    			top: 7px;
    			display: inline-flex;
    			place-content: center;
    			place-items: center;
    			margin-left: -5px;
    			font-size: 178.3%;
    			line-height: 1;
    			font-family: 'Material Symbols Sharp';
    			font-variation-settings: 'FILL' 1, 'wght' 300;
    			color: var(--c-primary, hsl(224, 50%, 33%));
    		}
    
    		.prextPost .prextPost-slide-prev .prextPost-badge::before { content: "\e045"; }
    		.prextPost .prextPost-slide-next .prextPost-badge::before { content: "\e044"; }
    
    		.prextPost .prextPost-title {
    			display: -webkit-box;
    			-webkit-box-orient: vertical;
    			-webkit-line-clamp: 2;
    			overflow: hidden;
    			text-overflow: ellipsis;
    		}
    	}
    
    	/* @(PC)  */
    	@media print, screen and (min-width: 744px) {
    		.prextPost .prextPost-wrapper {
    			display: flex;
    			justify-content: center;
    			align-items: center;
    		}
    
    		.prextPost [class*="prextPost-slide"] { height: 7.5pc; }
    
    		.prextPost .prextPost-slide-next {
    			flex-direction: row-reverse;
    			border-left: none;
    		}
    
    		.prextPost .prextPost-badge {
    			position: absolute;
    			margin: auto;
    			width: 5.625rem;
    			text-align: center;
    			background: var(--c-primary, hsl(224, 50%, 33%));
    			color: #FFF;
    		}
    
    		.prextPost .prextPost-slide-prev .prextPost-badge { top: 0px; left: 0px; }
    		.prextPost .prextPost-slide-next .prextPost-badge { top: 0px; right: 0px; }
    
    		.prextPost .prextPost-title {
    			display: -webkit-box;
    			-webkit-box-orient: vertical;
    			-webkit-line-clamp: 3;
    			overflow: hidden;
    			text-overflow: ellipsis;
    		}
    	}
    </style>
    
    
    <!-- ✿prevnextPost.php(前の記事・次の記事) -->
    <?php
    /**
    * タイトルを指定文字数で切り詰める
    */
    function trim_post_title($title, $max_len = 40, $suffix = '…')
    {
    	if (mb_strlen($title) > $max_len) {
    		return mb_substr($title, 0, $max_len) . $suffix;
    	}
    	return $title;
    }
    
    /**
    * 現在の記事の前後の記事を、指定した並び順で取得する
    */
    function get_adjacent_posts_by_order($current_id, $orderby = 'date', $order = 'DESC')
    {
    	$post_type = get_post_type($current_id);
    
    	// pre_get_posts の影響を避けるため WP_Query を直接使う
    	$query = new WP_Query([
    		'post_type'              => $post_type,
    		'post_status'            => 'publish',
    		'posts_per_page'         => -1,
    		'orderby'                => $orderby,
    		'order'                  => $order,
    		'fields'                 => 'ids',
    		'no_found_rows'          => true,
    		'ignore_sticky_posts'    => true,
    		'update_post_meta_cache' => false,
    		'update_post_term_cache' => false,
    	]);
    	$ids = $query->posts;
    
    	$index = array_search($current_id, $ids, true);
    
    	$prev_id = ($index !== false && isset($ids[$index + 1])) ? $ids[$index + 1] : null;
    	$next_id = ($index !== false && isset($ids[$index - 1])) ? $ids[$index - 1] : null;
    
    	return [
    		'prev' => $prev_id ? get_post($prev_id) : null,
    		'next' => $next_id ? get_post($next_id) : null,
    	];
    }
    
    /**
    * ナビゲーション表示用の生データを返す(HTMLは組み立てない)
    */
    function get_post_navigation_data($post, $max_title_length = 40, $default_title = '記事がありません')
    {
    	if (!$post) {
    		return [
    			'url'       => '',
    			'title'     => $default_title,
    			'thumbnail' => '',
    			'disabled'  => true,
    		];
    	}
    
    	$raw_title = strip_tags(get_the_title($post->ID));
    
    	if (has_post_thumbnail($post->ID)) {
    		$thumbnail = get_the_post_thumbnail($post->ID, 'thumbnail');
    	} else {
    		$noimage_url = get_theme_file_uri('assets/images/default-image.png');
    		$thumbnail = '<img src="' . esc_url($noimage_url) . '" alt="NoImage" />';
    	}
    
    	return [
    		'url'       => get_permalink($post->ID),
    		'title'     => trim_post_title($raw_title, $max_title_length),
    		'thumbnail' => $thumbnail,
    		'disabled'  => false,
    	];
    }
    
    
    // ─── ■設定 ───────────── ༚༶⊰⟡⊱༶༚༺
    // タイトルの最大文字数
    $max_title_length = 40;
    
    // 前後記事の並び順
    //   'menu_order' : 「順序」カラムで並べる
    //   'date'       : 公開日で並べる
    $prevnext_orderby = 'date';
    
    // 並び方向($ids が「新→古」順になる方向を指定)
    //   menu_order で「順序1が最新」の運用なら 'ASC'
    //   date       なら 'DESC'
    $prevnext_order = 'DESC';
    // ───────────────────── ༚༶⊰⟡⊱༶༚༺──
    
    
    // 前後の記事を取得
    $adjacent = get_adjacent_posts_by_order(get_the_ID(), $prevnext_orderby, $prevnext_order);
    
    // 表示用データ
    $prev = get_post_navigation_data($adjacent['prev'], $max_title_length);
    $next = get_post_navigation_data($adjacent['next'], $max_title_length);
    ?>
    
    <?php if ($adjacent['prev'] || $adjacent['next']) : ?>
    	<nav id="prextPostClbP" class="prextPost prextPost-container">
    		<div class="prextPost-wrapper">
    
    			<!-- 前の記事 -->
    			<a <?php if ($prev['disabled']) : ?>class="hasnt-post" <?php else : ?>href="<?php echo esc_url($prev['url']); ?>"<?php endif; ?>>
    				<dl class="prextPost-slide-prev">
    					<dt class="prextPost-term"><?php echo $prev['thumbnail']; ?></dt>
    					<dd class="prextPost-data">
    						<b class="prextPost-badge">前の記事</b>
    						<em class="prextPost-title"><?php echo esc_html($prev['title']); ?></em>
    					</dd>
    				</dl>
    			</a>
    
    			<!-- 次の記事 -->
    			<a <?php if ($next['disabled']) : ?>class="hasnt-post" <?php else : ?>href="<?php echo esc_url($next['url']); ?>"<?php endif; ?>>
    				<dl class="prextPost-slide-next">
    					<dt class="prextPost-term"><?php echo $next['thumbnail']; ?></dt>
    					<dd class="prextPost-data">
    						<b class="prextPost-badge">次の記事</b>
    						<em class="prextPost-title"><?php echo esc_html($next['title']); ?></em>
    					</dd>
    				</dl>
    			</a>
    
    		</div>
    	</nav>
    <?php endif; ?>

    コード解説

    1. タイトルを文字数で切り詰める trim_post_title()

    function trim_post_title($title, $max_len = 40, $suffix = '…')
    {
    	if (mb_strlen($title) > $max_len) {
    		return mb_substr($title, 0, $max_len) . $suffix;
    	}
    	return $title;
    }

    日本語タイトルに対応するため、必ず mb_strlen() / mb_substr() を使用します。strlen() / substr() はマルチバイト文字を1バイト単位で切ってしまうため、末尾が文字化けする原因になります。文字数上限を引数で受け取れるようにしておくと、テーマ側から柔軟に指定できます。

    2. WP_Query で並び順を指定して前後の記事を取得

    $query = new WP_Query([
    	'post_type'              => $post_type,
    	'post_status'            => 'publish',
    	'posts_per_page'         => -1,
    	'orderby'                => $orderby,
    	'order'                  => $order,
    	'fields'                 => 'ids',
    	'no_found_rows'          => true,
    	'ignore_sticky_posts'    => true,
    	'update_post_meta_cache' => false,
    	'update_post_term_cache' => false,
    ]);

    ポイントは次の3つ。

    • get_posts() ではなく new WP_Query() を直接使用:テーマや関連プラグインが pre_get_posts フィルタで posts_per_page を書き換えているケースがあり、get_posts() だと影響を受けて全件取れないことがあります。WP_Query を直接呼ぶことで、こうした介入を受けにくくなります。
    • fields => 'ids'update_*_cache => false:ID配列だけを返し、メタ/タームキャッシュ生成もスキップして軽量化。
    • no_found_rows => true:ページネーション用のカウントクエリを実行せず高速化。

    取得した $ids は「新しい→古い」順に並んでいる想定で、

    • [$index + 1] = より古い記事 = 前の記事
    • [$index - 1] = より新しい記事 = 次の記事

    として抽出しています。

    なお posts_per_page => -1 は全件取得のため、投稿数が数千件を超える規模では Transients などでのキャッシュを検討してください。

    3. HTMLを組み立てず「素データ」を返す get_post_navigation_data()

    以前は $link = '<a href="...">'; のように 部分タグを文字列として返す 設計にしていましたが、テンプレート側で <a></a> がペアで見えなくなり、エディタが構文エラーを出したりフォーマッタが誤動作したりする原因になっていました。

    今回は URL・タイトル・サムネイル・無効フラグを配列で返す設計にし、テンプレートは通常のHTMLとして開始タグと終了タグを完全な形で書けるようにしています。

    4. テンプレート側の分岐

    <a <?php if ($prev['disabled']) : ?>class="hasnt-post" <?php else : ?>href="<?php echo esc_url($prev['url']); ?>"<?php endif; ?>>

    disabled フラグで 属性だけを切り替える 形にすることで、<a>...</a> のタグ構造をテンプレート上に常に完全な形で残せます。

    5. セキュリティ処理

    esc_url()esc_html()strip_tags() の3点はセットで使います。特にWordPressでは投稿タイトルに <br> などのHTMLタグが埋め込まれるケースがあり、strip_tags() を挟まないとナビゲーション表示で意図しない改行が入ります。


    カスタマイズ設定

    ファイル下部の設定ブロックで、3つのポイントを制御できます。

    // タイトルの最大文字数
    $max_title_length = 40;
    
    // 並び順の基準
    //   'menu_order' : 「順序」カラム(手動並び替え)
    //   'date'       : 公開日
    $prevnext_orderby = 'date';
    
    // 並び方向($ids が「新→古」順になる方向)
    //   menu_order で「順序1が最新」の運用なら 'ASC'
    //   date       なら 'DESC'
    $prevnext_order = 'DESC';

    「順序」で並べたい場合

    管理画面のカスタム並び替えプラグイン等で menu_order を手動設定している運用なら、次のように切り替えるだけで対応できます。

    $prevnext_orderby = 'menu_order';
    $prevnext_order   = 'ASC';  // 順序=1 が最新の場合

    公開日で並べたい場合(従来通り)

    $prevnext_orderby = 'date';
    $prevnext_order   = 'DESC';

    single.php への組み込み

    投稿ページのループ内で get_template_part() を使って呼び出します。

    <?php get_header(); ?>
    
    <main>
    	<?php if (have_posts()) : ?>
    		<?php while (have_posts()) : the_post(); ?>
    			<article>
    				<h1><?php the_title(); ?></h1>
    				<div class="post-content">
    					<?php the_content(); ?>
    				</div>
    			</article>
    
    			<!-- 前の記事・次の記事 -->
    			<?php get_template_part('template-parts/prevnextPost'); ?>
    
    		<?php endwhile; ?>
    	<?php endif; ?>
    </main>
    
    <?php get_footer(); ?>

    まとめ

    この実装のポイントを整理すると次の通りです。

    • WP_Query ベースget_previous_post() の弱点(pre_get_posts の影響を受ける/並び順が固定)を回避
    • 並び順を「順序(menu_order)」「公開日」から選択可能
    • タイトルの文字数制限 (PHPで切り詰め+CSSで -webkit-line-clamp)で長文タイトルにも対応
    • HTMLを組み立てず素データを返す設計 で、テンプレート・エディタ・フォーマッタの相性が良い
    • nav + dl の意味付け と、 CSS clamp() によるレスポンシブフォント で軽量に仕上げ

    「前の記事・次の記事」ナビゲーションを、「公開日」で並べたいという方は試してみてください。