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

PHP
<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-1 puts one post on each line on phones, and row-cols-md-2 switches to two per line from the md breakpoint (768px) up. For three per line on large screens, add row-cols-lg-3; the loop stays the same.
  • g-4 sets the gutter, the space between columns and between lines.
  • post_class( 'col' ) prints the col class together with WordPress’s usual post classes.
  • h-100 makes 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:

CSS
.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 plain 1fr keeps them equal when a post contains a long unbroken string, such as a URL. In Chrome, with plain 1fr such a string widened its column to 655px and squeezed the other to 121px; with minmax(0, 1fr) both stayed at 388px.
  • gap spaces 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 (.grid and .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

PHP
<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> with col-md-6, so two posts fill a row from the md breakpoint up. Replace the title and excerpt with your own markup.
  • After each post, $counter goes 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_count skips 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_query is 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.

PHP
<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_filters stops query filters added by plugins from changing the results. An even posts_per_page keeps the last row full.
  • $paged holds the current page number from get_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 the page query 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' ) loads template-part/blog-card.php from your theme for each post. The loop adds no column wrapper, so the column class (for example col-md-6) belongs in that file.
  • $post_count < $dcp_query->post_count does 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.
Share on X (opens in a new tab) Share on LinkedIn (opens in a new tab) Share on Facebook (opens in a new tab)

ChatGPT (opens in a new tab) Claude (opens in a new tab) Gemini (opens in a new tab) Perplexity (opens in a new tab) Grok (opens in a new tab)

More Insights

See More