This week's job: an ecommerce store selling made-to-size products wanted 40 SEO landing pages - one per popular size. Think "12 x 24 Widget Frames" as a page that ranks, instead of a filter URL that never will.
The catch is small but real, and if you've tried this you've hit it: WooCommerce's product listing tools take one attribute filter, not two.
The problem, concretely
The store models size as two product attributes: pa_width and pa_height. Their menu links to filtered listings like:
/product-category/frames/?filter-width=341&filter-height=321
Two problems with parameter URLs like that:
- They don't rank. Google mostly treats them as duplicates of the base category. Forty of these links means forty pages of crawl noise and zero landing pages.
-
You can't replace them with the stock shortcode.
[products attribute="width" terms="341"]works - but there's no second attribute slot. Width AND height together isn't a thing.
The site had ~350 pages, Elementor everywhere, and a live store I wasn't allowed to break (their words: "please keep in mind that this is a live site"). So: no new plugins, no theme surgery, nothing clever. Small, boring, reversible.
The fix: 30 lines and one filter hook
WooCommerce's [products] shortcode builds a WP_Query and - helpfully - passes the query args through a filter before running it. So you wrap it: accept two term IDs, inject both as a tax_query, render, clean up.
// [size_products width="341" height="321"]
add_shortcode( 'size_products', function( $atts ) {
$atts = shortcode_atts( array(
'width' => '',
'height' => '',
'columns' => '4',
'limit' => '-1',
), $atts, 'size_products' );
if ( ! $atts['width'] || ! $atts['height'] || ! class_exists( 'WC_Shortcode_Products' ) ) {
return '';
}
$filter = function( $query_args ) use ( $atts ) {
$query_args['tax_query'][] = array(
'taxonomy' => 'pa_width',
'field' => 'term_id',
'terms' => array( (int) $atts['width'] ),
);
$query_args['tax_query'][] = array(
'taxonomy' => 'pa_height',
'field' => 'term_id',
'terms' => array( (int) $atts['height'] ),
);
return $query_args;
};
add_filter( 'woocommerce_shortcode_products_query', $filter, 20 );
$shortcode = new WC_Shortcode_Products( array(
'columns' => $atts['columns'],
'limit' => $atts['limit'],
'orderby' => 'menu_order title',
'order' => 'ASC',
), 'products' );
$html = $shortcode->get_content();
remove_filter( 'woocommerce_shortcode_products_query', $filter, 20 );
return $html;
} );
Notes that matter:
-
It renders your theme's normal product cards. Because it goes through
WC_Shortcode_Products, the output is the standard WooCommerce loop - same markup your theme already styles. The pages look native because they are. - Caching is free. The shortcode caches per-args, and since width/height are part of the args, every size page gets its own cache entry. No stampede.
-
Add and remove the filter around a single render. Leave it attached and you'll quietly contaminate every other
[products]call on the page. - Drop it in via your snippets plugin or child theme. On a live site I prefer a snippets plugin: one toggle to revert.
The part that actually took thinking: don't recategorize 350 products
The obvious alternative was "just create a size category and assign products to it." That means editing every product, duplicating information the attributes already hold, and a second source of truth that drifts. The attribute-based query means product data stays exactly as the store manages it today - new products with the right width/height attributes appear on the right size page automatically.
One prep step made the rollout mechanical: before building anything, I extracted the full attribute term map from the site's own menu markup (15 widths x 15 heights, each label already paired with its term ID in the filter URLs). Forty pages then reduced to a lookup table: page title, slug, two term IDs, done.
The SEO wrapper around it
Each size page is a normal page (cloned from the client's existing landing-page template, so design review took minutes, not days):
- H1 and title tag with the size phrase people actually search
- a unique intro paragraph and meta description per size
- clean slug like
/12-x-24-widget-frames/ - the shortcode with that size's two term IDs
The old parameter URLs keep working - nothing redirects, nothing breaks. The menu just points at real pages now.
One honest footnote
The grid shows exactly what's in the catalog - which is how we found that one size was missing a variant everyone assumed existed (its mirrored orientation existed; the portrait one didn't). A faithful query is also a free data audit. Report those findings to the store owner instead of papering over them; it's the most trust you can buy with one message.
I measure before I touch anything - it's how I work through Upwork and why my write-ups have numbers in them. Previous entries in this series: cutting a WooCommerce checkout by 60% by removing an optimization plugin and how to tell if your WordPress host is the real bottleneck.
Top comments (4)
I appreciate how you tackled the limitation of WooCommerce's product listing tools only allowing one attribute filter, by creating a custom shortcode that injects a
tax_querywith both width and height attributes. The fact that it renders the standard WooCommerce loop and caches per-args is a big plus, ensuring a seamless user experience and efficient performance. I'm also impressed by your consideration of the potential data management issues with creating a size category and assigning products to it, instead opting for an attribute-based query that keeps product data consistent. Did you consider using a similar approach for other attribute combinations, or was this solution tailored specifically for the size attributes in this project?Good question. The technique itself isn't width/height-specific -
woocommerce_shortcode_products_querywill happily accept as manytax_queryentries as you push onto the array. I hardcoded two named params (width/height) here because that's exactly what this client's catalog needed, not because the filter is capped at two.If a future project needed a third axis (say width x height x material), the change is small: swap the two fixed atts for a loop over an array of attribute => value pairs, and push one tax_query clause per pair instead of writing them out by hand. Same filter hook, same cleanup pattern, just a more generic attribute list instead of two named ones. I kept it narrow this time on purpose - two hardcoded params reads clearer in a snippets plugin than a generic version nobody but this store needs yet.
Thanks for the detailed explanation! I think keeping the implementation intentionally narrow was a great engineering decision. It’s easy to over-generalize solutions too early, but a focused approach that matches the current business requirement usually results in cleaner, safer, and easier-to-maintain code.
I especially like the idea of extending the same
tax_querypattern into a dynamic attribute map when future requirements grow. That keeps the core architecture unchanged while allowing additional dimensions like material, color, or other product attributes without creating duplicate categories or additional data management overhead.The point about preserving a single source of truth is also important for ecommerce systems. Using existing product attributes instead of manually maintaining category combinations reduces long-term maintenance and prevents catalog inconsistencies.
I’d love to connect and exchange ideas around ecommerce architecture, scalable web development, and AI-powered product solutions. My team and I are always interested in collaborating with developers who enjoy solving practical engineering challenges.
Looking forward to sharing ideas and exploring possible collaborations!
Please check my profile and portfolio.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.