WooCommerce won't filter by two attributes at once. A client needed 40 category pages that do exactly that.

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 store models size as two product attributes: pa_width and pa_height. Their menu links to filtered listings like: /product-category/frames/?filte
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
Enter fullscreen mode Exit fullscreen mode
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;
} );
Enter fullscreen mode Exit fullscreen mode
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.



