A redesign of the blog list and post detail pages for Rev6, a fitness and wellness platform running WordPress + Divi on Cloudways. This is less a "look at the pretty result" piece and more an honest account of the architecture decisions and the bugs that taught me something — including the one that cost a chunk of a Saturday and had nothing to do with code at all.
The starting point
Rev6's blog needed a full visual and structural overhaul: a proper editorial list page with a category sidebar, sorting, and a card grid, plus a clean reading-focused detail page with an author bar, author bio, and related posts. The site is built on a Divi child theme, so the obvious first question was whether to lean on Divi's built-in Blog module.
I didn't. The Blog module is fine for generic listings, but it falls down on the things this project actually needed: URL-based category routing, custom card markup, and membership-aware post handling. Fighting a page builder to produce non-default markup is a losing game — you end up with a tangle of overrides that breaks on the next Divi update. So the list and detail pages are driven by a small system of custom shortcodes living in the child theme's inc/rev6-blog-functions.php, styled by a single assets/rev6-blog.css, and assembled inside Divi's Theme Builder.
The shortcode system
The whole front end is composed from a handful of focused shortcodes:
-
[rev6_blog_sidebar]— category navigation, search, branding -
[rev6_sort_dropdown]— sort control -
[rev6_post_grid]— the card grid -
[rev6_author_bar]— byline on the detail page -
[rev6_author_bio]— author block at the foot of a post -
[rev6_related_posts]— "Keep reading" section
Keeping each piece as its own shortcode means the Theme Builder template is just a thin assembly layer — a Code module with the shortcodes in the order they should render — and all the logic stays in versioned PHP rather than locked inside the page builder's database.
One template for every category
The neat trick on the list side is that a single Theme Builder template serves both the main blog index and every category archive. The post grid reads the query context and filters itself:
$current_id = is_category() ? (int) get_queried_object_id() : null;
// ...
if (is_category()) {
$args['cat'] = (int) get_queried_object_id();
}
Assign the template to Pages → Blog and Categories → All Category Pages, and the same markup produces the right results everywhere. On the index you get all posts; on /category/longevity/ the grid scopes itself to Longevity. No per-category templates, no duplication.
The editorial layout — and a 27,000-pixel bug
The list page uses a named-area CSS grid: a sticky sidebar spanning the full height, with the sort control, card grid, and pagination stacked in the main column.
.rev6-blog-layout {
display: grid;
grid-template-columns: var(--rev6-sidebar-w) 1fr;
grid-template-rows: auto 1fr auto;
grid-template-areas:
"sidebar sort"
"sidebar grid"
"sidebar pagination";
column-gap: var(--rev6-gutter);
row-gap: 28px;
}
This is the v2.2 layout. v2.1 had a genuinely fun bug. To make the sidebar span the full content height, I'd reached for grid-row: 1 / span 999 — the lazy "just span everything" move. The problem: span 999 doesn't clamp to the number of real rows. It generates ~996 phantom implicit rows below the content, and every one of them inherited the 28px row-gap. The result was roughly 27,888px of dead whitespace hanging below the pagination. The fix was to stop being lazy and define an explicit three-row grid with named areas, letting the sidebar occupy a named region that spans the rows that actually exist. Lesson: span <big number> is a code smell in grid layouts; name your areas.
The detail page
The post detail template is four stacked pieces: the post title, an author bar, the post content (carrying a rev6-single-content class for typography), the author bio, and related posts.
Typography is the usual editorial setup — Roboto Serif headings in a deep blue (#1d5280), a navy body color (#232a43), generous line-height, a constrained measure for readability. The author system is custom: rather than rely on WordPress's native bio field, the theme registers a "Rev6 Author Profile" section on the user edit screen with dedicated Role and bio fields, and the author photo comes from Simple Local Avatars.
The related-posts block is small but worth showing, because it became the centerpiece of a debugging session that had nothing to do with the code:
function rev6_related_posts($atts) {
$atts = shortcode_atts(['count' => 3], $atts);
if (!is_single()) return '';
$cat = rev6_primary_category();
if (!$cat) return '';
$q = new WP_Query([
'post_type' => 'post',
'posts_per_page' => (int) $atts['count'],
'post_status' => 'publish',
'cat' => (int) $cat->term_id,
'post__not_in' => [get_the_ID()],
'ignore_sticky_posts' => 1,
'orderby' => 'date',
'order' => 'DESC',
]);
if (!$q->have_posts()) return '';
// ...render cards...
}
It pulls up to three posts from the current post's primary category, excluding the current one. Primary category resolution is defensive — it prefers Yoast's designated primary term but falls back gracefully if that term no longer exists:
function rev6_primary_category($post_id = null) {
$post_id = $post_id ?: get_the_ID();
if (function_exists('yoast_get_primary_term_id')) {
$term_id = yoast_get_primary_term_id('category', $post_id);
if ($term_id) {
$term = get_term($term_id, 'category');
if (!is_wp_error($term) && $term) return $term;
}
}
$cats = get_the_category($post_id);
return !empty($cats) ? $cats[0] : null;
}
The "bug" that wasn't
On the live site, the related-posts section refused to render on the flagship post. The code looked correct. I'd already confirmed the categories existed. So where was the bug?
There wasn't one. The category existed but was empty — the only post assigned to "Longevity" was the very post being viewed. post__not_in excluded it, the query returned zero results, and the function did exactly what it should: returned an empty string. The moment I assigned a second post to the category, "Keep reading" appeared instantly.
This is a recurring trap with content-driven features: the function is correct, the data is incomplete, and the symptom looks like a code failure. Before debugging a query, check whether it has anything to query.
The cutover: code and layout move, content doesn't
The single most expensive lesson came during the staging-to-live migration, and it's the one I'd most want a past version of myself to internalize.
I'd pushed the theme files to live and imported the Divi Theme Builder templates from staging. Everything should have matched. It didn't — the detail page body sat in the wrong place, related posts were missing, the sidebar looked thin. My instinct was to keep editing CSS on live to force a match. That instinct was wrong, and it cost several round-trips.
The actual issue: imports move code and layout. They do not move content.
- Theme files (
.css,.php) carry presentation and logic. - A Divi Theme Builder export carries template structure — sections, modules, CSS classes.
- Neither one carries posts, categories, or which post belongs to which category. Those are database rows.
So a body-width difference between staging and live wasn't a CSS problem at all — the width was set on a Divi module's Design tab, which lives in the database, not the stylesheet. The empty related-posts blocks weren't a code problem — the category-to-post assignments had never been recreated on live. I'd copied the stylesheet byte-for-byte and the two environments still diverged, which is the clearest possible signal that the difference lives somewhere a stylesheet can't reach.
Once you internalize the split — files and templates are the shell; the database is the contents — the whole class of "I imported it but it's different" problems becomes diagnosable in seconds instead of debugged in CSS for an hour.
A corollary, learned the hard way and stated here as a warning: never resolve a staging/live difference with a blanket database push to production. A full DB sync overwrites live's real data — orders, registrations, everything users created since the snapshot. Use targeted file copies and, where data genuinely must move, scoped table-level operations. The convenience of a one-click "push everything" is not worth what it can erase.
Caching will lie to you
One more environmental gremlin worth naming: aggressive CSS optimization. Cloudways' Breeze with "Used CSS" (RUCSS) strips selectors it doesn't detect as used when it generates its optimized stylesheet. If a template wasn't live when the cache was built, perfectly correct rules — list-style: none on pagination, container widths, an icon font — get pruned, and you get partial styling: some rules apply, others vanish. Partial application is the tell. A fully missing stylesheet looks different from a half-applied one.
The fix isn't to touch the CSS; it's to purge and regenerate the Used CSS after any template change, and to exclude the relevant selectors from optimization if they keep getting stripped. When debugging CSS on an optimized WordPress site, clearing cache is step zero, not step ten.
Takeaways
- Don't fight the page builder. If you need non-default markup or custom query logic, own it in versioned PHP and let the builder assemble.
- Name your grid areas.
span 999will find a way to embarrass you. - A correct function over empty data looks identical to a broken function. Check the data first.
- Files and templates are the shell; the database is the contents. Most "I migrated it but it's different" bugs live in the gap between those two.
- Never blanket-push a database to production.
- On an optimized WordPress stack, purge the cache before you believe anything you see.
The redesign shipped: editorial list page with context-aware category routing, a clean reading-focused detail page, a working author system, and related posts that populate themselves from category data. The code was the easy part. The environment was the teacher.
Top comments (0)