How to Wrap Every 2 WordPress Posts in a Bootstrap Row

Quick Summary
- In Bootstrap 4 and 5, put all posts in one .row with row-cols-1 row-cols-md-2; the flexbox row wraps them two per line without a counter.
- Without Bootstrap, a CSS grid with grid-template-columns: repeat(2, minmax(0, 1fr)) gives the same two-column layout.
- Use the $counter % 2 method only when each pair needs its own wrapper or on Bootstrap 3, and compare it with the post count so no empty row is left at the end.
In Bootstrap 4 and 5 you usually don’t need a new row after every second post. A .row is a flexbox container that wraps its columns onto the next line, so you can put all the posts in one row with row-cols-1 row-cols-md-2 and they line up two by two. Without Bootstrap, a CSS grid with two columns does the same. Close and reopen the row with a counter only when each pair needs its own wrapper element, or when you are still on Bootstrap 3’s float-based grid.
The code goes in a theme template file, such as index.php, archive.php, home.php or a page template.
Two posts per row in Bootstrap 5
<div id="main" class="container">
<?php if ( have_posts() ) : ?>
<div class="row row-cols-1 row-cols-md-2 g-4">
<?php while ( have_posts() ) : the_post(); ?>
<article id="post-<?php the_ID(); ?>" <?php post_class( 'col' ); ?>>
<div class="card h-100">
<div class="card-body">
<h2 class="h5"><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h2>
<?php the_excerpt(); ?>
</div>
</div>
</article>
<?php endwhile; ?>
</div>
<?php the_posts_pagination(); ?>
<?php endif; ?>
</div>row-cols-1puts one post on each line on phones, androw-cols-md-2switches to two per line from themdbreakpoint (768px) up. For three per line on large screens, addrow-cols-lg-3; the loop stays the same.g-4sets the gutter, the space between columns and between lines.post_class( 'col' )prints thecolclass together with WordPress’s usual post classes.h-100makes each card fill its column. Columns on the same line of a flex row share one height, so the two cards in a pair match.the_posts_pagination()prints the page links for the main loop.
Tested in headless Chrome with Bootstrap 5.3.8 and five real posts of different lengths, this printed one row and the posts lined up in pairs (1-2, 3-4, then 5 alone on the left), with the two cards in each pair at the same height. In a window narrower than 768px, they stacked in one column. Putting col-md-6 on each post inside one plain .row gave the same pairs.
Without Bootstrap: CSS grid
If the theme doesn’t load Bootstrap, CSS grid gives the same layout. Use the loop above with <div class="post-grid"> in place of the row, and post_class() without the col class. Then add this to the theme’s stylesheet:
.post-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1.5rem;
}
@media (min-width: 768px) {
.post-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}repeat(2, minmax(0, 1fr))makes two equal columns.minmax(0, 1fr)instead of plain1frkeeps them equal when a post contains a long unbroken string, such as a URL. In Chrome, with plain1frsuch a string widened its column to 655px and squeezed the other to 121px; withminmax(0, 1fr)both stayed at 388px.gapspaces the posts in both directions, and the posts on the same line of the grid share one height.- Bootstrap 5.3 has CSS grid classes of its own (
.gridand.g-col-6), but they are opt-in: they aren’t in the default compiled CSS, and you enable them in Bootstrap’s Sass with$enable-cssgrid. - In a block theme you don’t need template code for this at all: the Query Loop block’s Post Template has a Grid view with a column count.
When each pair needs its own wrapper
Bootstrap 3’s grid used floats instead of flexbox, and that is where this counter method came from. With every post in one Bootstrap 3 row, a tall first post catches the third one: in the same headless Chrome test with Bootstrap 3.4.1, the third post floated into the right-hand column under the second (lines 1-2, 3, 4-5). Closing the row after every second post fixed it.
You still need a separate wrapper per pair when the pair itself is a design element, for example a block with its own background or border, or a slide that holds two posts. This counter method was built for the DCP Site project.
Basic version with the main loop
<div id="main" class="container" role="main">
<div class="row">
<?php $counter = 0; ?>
<?php if (have_posts()) : while (have_posts()) : the_post(); ?>
<article class="col-md-6" id="post-<?php the_ID(); ?>" role="article">
<h2><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h2>
<?php the_excerpt(); ?>
</article>
<?php $counter++;
if ($counter % 2 == 0 && $counter < $wp_query->post_count) {
echo '</div><div class="row">';
}
endwhile; endif; ?>
</div>
</div>- Each post is an
<article>withcol-md-6, so two posts fill a row from themdbreakpoint up. Replace the title and excerpt with your own markup. - After each post,
$countergoes up by one. When$counter % 2 == 0, the code prints</div><div class="row">, which closes the current row and opens a new one. $counter = 0;above the loop starts the count at zero. Without it, PHP would warn about an undefined variable on the first post.$counter < $wp_query->post_countskips the close-and-open after the last post. Without it, an even number of posts leaves an empty row at the end, which shows once the wrapper has its own padding or border.$wp_queryis available in theme templates because WordPress loads them with the main query in scope. In the Chrome test with six posts, the check brought the row count from four (the last one empty) to three.
Custom WP_Query version
This version runs its own query and loads each post from a template part, which suits a blog page template.
<div class="row">
<?php
$paged = (get_query_var('paged')) ? get_query_var('paged') : 1;
$args = [
'post_type' => 'post',
'post_status' => 'publish',
'orderby' => 'date',
'order' => 'DESC',
'ignore_sticky_posts' => 1,
'suppress_filters' => true,
'posts_per_page' => 6,
'paged' => $paged,
];
$dcp_query = new WP_Query($args);
?>
<?php if ($dcp_query->have_posts()): ?>
<?php
$post_count = 0;
while ($dcp_query->have_posts()):
$dcp_query->the_post(); ?>
<?php get_template_part('template-part/blog-card'); ?>
<?php
$post_count++;
if ($post_count % 2 == 0 && $post_count < $dcp_query->post_count) {
echo '</div><div class="row">';
}
?>
<?php
endwhile;
wp_reset_postdata();
?>
<?php else: ?>
<h3>No Post Found</h3>
<?php endif; ?>
</div>- The query gets 6 published posts per page, newest first, and ignores sticky posts.
suppress_filtersstops query filters added by plugins from changing the results. An evenposts_per_pagekeeps the last row full. $pagedholds the current page number fromget_query_var( 'paged' ), or 1 on the first page, so page 2 shows the next 6 posts. If the template is used as a static front page, WordPress stores the page number in thepagequery var instead. To print the page links under the grid, see adding numbered pagination to a custom WP_Query.get_template_part( 'template-part/blog-card' )loadstemplate-part/blog-card.phpfrom your theme for each post. The loop adds no column wrapper, so the column class (for examplecol-md-6) belongs in that file.$post_count < $dcp_query->post_countdoes the same job as in the basic version: no empty row after the last pair.wp_reset_postdata()restores the global post data after the custom loop.
Related articles
- page links for the main blog loop: pagination for the basic version above
- organizing a theme into template parts: where a file like blog-card.php fits in a larger theme

