WooCommerce Faceted Navigation
Overview
Faceted (layered) navigation lets shoppers narrow a catalogue by attribute — price, brand, colour, size, spec. Done well it is the single biggest discovery win on a large store; done badly it is a crawl trap, a duplicate-content generator, and an origin-load DDoS you inflicted on yourself. This skill is the HOW + crawl-control layer.
| Need |
Go to |
| WHAT to put on a category page (facet list, counts, sort, drawer) |
ecommerce-growth §Category Pages — the checklist; reference it, don't duplicate |
| Woo PHP code, security, HPOS query rules, external-search offload |
woocommerce-developer |
| Schema, site architecture (3-click), sitemaps, CWV |
seo-structure-architect |
Four independent controls — never conflate them: UX state (what the shopper sees), crawlability (can a bot fetch the URL), indexability (may it appear in search), and cacheability (can the response be served from cache). A URL can be cacheable yet non-indexable, or crawlable yet noindexed. Design each deliberately.
Facet Taxonomy & Filter UX
- Choose facets by demand, not by "every attribute we store." Expose the handful shoppers actually filter on; bury the long tail.
- Show option counts and prevent zero-result states — disable or hide options that would return nothing for the current selection (see
ecommerce-growth). A directly-requested invalid, duplicate, or zero-result filter URL should return 404, not a 200 empty page (per Google's faceted-navigation guidance) — that lets crawlers drop it.
- AJAX vs full reload:
|
AJAX filtering |
Full page reload |
| UX |
Fast, no scroll reset |
Slower, scroll resets |
| SEO |
Filtered state often invisible to crawlers unless URLs update |
Server-rendered, crawlable by default |
| Cache |
AJAX endpoint responses need their own caching strategy |
Uses normal page cache |
| Best for |
Interactive refinement on already-landed users |
States you WANT indexed as landing pages |
Pattern that gets both: AJAX-update the results and push a clean, normalized URL via the History API so the state is shareable and (if allowlisted) crawlable.
- Mobile: filters in a slide-in drawer with an "Apply" action, not inline; show the applied-count on the trigger.
- Applied-filter chips above results (each removable) so shoppers can see and undo selections; include a "clear all."
- Sort (best-selling / price / newest / rating) is a display control — keep sort in a parameter you can strip from the canonical, never a separately indexable URL.
- Pagination: prefer numbered, crawlable pages over infinite scroll for indexable listings; "load more" is fine for UX but ensure the paginated set is still reachable via
<a href> links for crawlers. Self-referencing canonicals per page (do NOT canonical page 2+ to page 1 — that hides deeper products).
URL & State Strategy
- Query parameters (
?brand=acme&color=black) are the pragmatic default in WooCommerce; pretty paths (/shop/acme/black/) look cleaner but are a double-edged sword (see caveat 5 below).
- Normalize aggressively so one logical state has exactly one URL:
- Fix parameter order (alphabetical) and value case; collapse duplicates.
- Drop empty/default params; strip tracking params from the canonical.
- Decide a single delimiter for multi-select and stick to it.
- Preserve history/back-button behaviour via
pushState/replaceState; make filter states shareable (the URL fully reconstructs the view).
Indexability Allowlist (core)
Treat facet URLs as allowlist, not blocklist: index a small, curated set of demand-backed combinations as landing pages; everything else is thin and should stay out of the index.
- Index-worthy — single facets or proven double-facet combos with real search demand and unique, useful content (e.g.
/gaming-pcs/rtx-5070/). Give them a self-referencing canonical, a tailored title/H1, and intro copy.
- Thin / infinite — 3+ stacked facets, sort orders, price sliders, near-empty combinations. Keep these out of the index.
Decision matrix:
| Combination |
Internal links |
Canonical |
Meta robots |
robots.txt |
| Curated landing facet |
Yes (crawlable <a>) |
Self |
index,follow |
Allow |
| Non-curated but low-volume |
No |
(n/a) |
noindex,follow |
Allow (so the noindex is seen) |
| Combinatorial explosion / sort / session params |
No |
— |
— |
Disallow the parameter pattern |
NC4 caveats — non-negotiable, because each is a common way teams break their own site:
- noindex requires the page to be crawlable to be seen — Googlebot only honours a
noindex if it can fetch the page and read the tag/header. A URL you both Disallow in robots.txt and mark noindex will keep the noindex unread.
- robots.txt disallow hides the noindex tag — the two directives conflict. Disallowed URLs can still be indexed (URL-only, from links) precisely because the crawler never sees the
noindex. Pick one mechanism per URL.
- canonical is only a hint —
rel=canonical is a suggestion Google may ignore; it consolidates signals, it does not guarantee the alternate is dropped, and it never stops crawling or serving of the variant.
- none of noindex/canonical/robots alone prevents origin load —
noindex and canonical still get crawled (they must be, to be read); robots.txt stops Googlebot fetching but not users, other bots, or your own AJAX calls. Controlling crawl and cost is a separate job from controlling indexing (next section).
- naive pretty-URL rewrites can make facets MORE crawlable — rewriting
?brand=x&color=y into /brand-x/color-y/ turns parameter noise into clean, linkable, crawlable paths, multiplying the crawl surface. Pretty URLs are a commitment to index; use them only for the allowlisted set, never as a blanket rewrite.
Crawl-Budget & Origin-Load Protection
Indexability keeps junk out of search; this keeps junk off your server. Co-design them — cacheability is not indexability.
- GET-parameter normalization (from URL & State): fewer distinct URLs = fewer crawl paths and higher cache hit-rate. Canonicalize order/case at the edge or in the app.
- Cache-key design: decide which parameters vary the cache. Ignore/normalize params that don't change output so
?color=black and ?color=black&utm=x hit one cached object — but only ignore params proven inert; dropping a real facet, currency, or customer-segment param serves wrong content or enables cache poisoning. Cloudflare "Cache Rules" + "Query String Sort" (normalize param order), Varnish's default hash on full req.url (normalize in VCL), and LiteSpeed's per-query-string copies ("Drop Query String" only for inert params) all need explicit configuration — the defaults over- or under-cache facets. Verify real HIT/MISS/BYPASS headers.
- AJAX response caching: filter AJAX endpoints bypass the page cache by default. Cache their JSON responses (object cache / transient / edge) keyed on the normalized facet set, or they become an uncached origin hit per interaction.
- Internal-link discipline: only allowlisted combinations get crawlable
<a href> links. Render non-indexable filters as buttons/JS controls (not anchors) so you are not advertising crawl paths you then try to suppress.
- Monitor: watch GSC Crawl Stats and server logs for crawler time spent on parameter URLs; a spike there is the early signal of a crawl trap.
Woo Query & DB Performance
High-cardinality attribute filtering is where WooCommerce falls over at the database.
meta_query is the trap. Filtering on product attributes stored as post meta forces expensive wp_postmeta self-joins that scale badly with catalogue size and attribute count.
- Use the attribute lookup table. WooCommerce maintains
wc_product_attributes_lookup — a flat, indexed table purpose-built for fast attribute filtering. (Version history, since it is widely misremembered: WC 3.6 introduced wc_product_meta_lookup; the attribute lookup table wc_product_attributes_lookup arrived experimentally in WC 5.6 and became the default in WC 6.3. It is standard in WC 9.x/10.x.) Keep it regenerated (WooCommerce → Status → Tools) after bulk imports.
- Cache expensive aggregations (facet counts, price ranges) in transients / a persistent object cache (Redis/Memcached); recompute on product save, not per request.
- When to offload: past a few thousand products, or with many high-cardinality attributes and free-text search, move filtering/search to an external index — ElasticPress (Elasticsearch/OpenSearch) or Algolia. Keep WooCommerce as the source of truth and treat the index as a rebuildable projection. The mechanics of that offload (sync, keys, security) live in
woocommerce-developer §Performance → Search Offload — do not duplicate them here.
Build vs Plugin
| Option |
When it fits |
Watch for |
| FacetWP-class plugin |
You need fast time-to-value and it exposes the controls below |
Verify it lets you control indexability + cache behaviour |
| Custom build |
You need exact crawl-control / cache-key / index semantics |
More engineering; you own the query strategy |
Evaluate any filter plugin against: (1) does it emit clean, normalizable URLs; (2) can you set canonical/noindex per facet state; (3) does it use the attribute lookup table (not raw meta_query); (4) how do its AJAX endpoints interact with your page cache and CDN; (5) does it let you withhold internal links from non-allowlisted combos.
Anti-Patterns
| Don't |
Why |
Expose every filter combination as a crawlable <a href> |
Crawl trap + thin duplicates; only allowlisted combos get links |
Disallow a URL in robots.txt AND rely on its noindex |
The crawler never sees the noindex — the page can still get indexed |
Blanket-rewrite ?params into pretty paths |
Multiplies the crawlable surface (caveat 5) |
| Canonical page 2+ back to page 1 |
Hides deeper products from indexing |
Trust canonical/noindex to cut server load |
They must be crawled to work; crawl/cost control is separate |
Filter high-cardinality attributes via raw meta_query |
wp_postmeta self-joins melt at scale — use wc_product_attributes_lookup |
| Leave filter AJAX endpoints uncached |
One uncached origin hit per interaction |
| Return a 200 empty page for an invalid/zero-result combo |
Return 404 so crawlers drop it (Google faceted-nav guidance) |
| Duplicate the search-offload recipe here |
It lives in woocommerce-developer — reference it |
1---2name: woocommerce-faceted-navigation3description: Use when building, reviewing, or fixing faceted/layered navigation and product filters on a WooCommerce store — filter UX (AJAX vs reload, mobile filter drawer, applied-filter chips, sort logic), facet indexability strategy (index-worthy facet landing pages vs thin parameter combinations, canonical/noindex/robots decision matrix), crawl-budget and origin-load protection (parameter normalization, cache-key design, allowlisted exposure), and Woo query/DB performance for high-cardinality attributes (attribute lookup tables, external index offload boundary). Trigger on - product filters, layered nav, faceted navigation, filter SEO, crawl trap, filter URLs indexed, AJAX filters slow. Category-page content checklists live in ecommerce-growth; generic schema and site architecture live in seo-structure-architect; Woo code security patterns live in woocommerce-developer.4---56# WooCommerce Faceted Navigation78## Overview910Faceted (layered) navigation lets shoppers narrow a catalogue by attribute — price, brand, colour, size, spec. Done well it is the single biggest discovery win on a large store; done badly it is a crawl trap, a duplicate-content generator, and an origin-load DDoS you inflicted on yourself. This skill is the **HOW + crawl-control** layer.1112| Need | Go to |13|------|-------|14| WHAT to put on a category page (facet list, counts, sort, drawer) | `ecommerce-growth` §Category Pages — the checklist; reference it, don't duplicate |15| Woo PHP code, security, HPOS query rules, external-search offload | `woocommerce-developer` |16| Schema, site architecture (3-click), sitemaps, CWV | `seo-structure-architect` |1718**Four independent controls — never conflate them:** UX state (what the shopper sees), **crawlability** (can a bot fetch the URL), **indexability** (may it appear in search), and **cacheability** (can the response be served from cache). A URL can be cacheable yet non-indexable, or crawlable yet noindexed. Design each deliberately.1920## Facet Taxonomy & Filter UX2122- **Choose facets by demand,** not by "every attribute we store." Expose the handful shoppers actually filter on; bury the long tail.23- **Show option counts** and prevent zero-result states — disable or hide options that would return nothing for the current selection (see `ecommerce-growth`). A directly-requested **invalid, duplicate, or zero-result** filter URL should return **`404`**, not a `200` empty page (per Google's faceted-navigation guidance) — that lets crawlers drop it.24- **AJAX vs full reload:**2526| | AJAX filtering | Full page reload |27|--|---------------|------------------|28| UX | Fast, no scroll reset | Slower, scroll resets |29| SEO | Filtered state often invisible to crawlers unless URLs update | Server-rendered, crawlable by default |30| Cache | AJAX endpoint responses need their own caching strategy | Uses normal page cache |31| Best for | Interactive refinement on already-landed users | States you WANT indexed as landing pages |3233 Pattern that gets both: AJAX-update the results **and** push a clean, normalized URL via the History API so the state is shareable and (if allowlisted) crawlable.34- **Mobile:** filters in a slide-in drawer with an "Apply" action, not inline; show the applied-count on the trigger.35- **Applied-filter chips** above results (each removable) so shoppers can see and undo selections; include a "clear all."36- **Sort** (best-selling / price / newest / rating) is a *display* control — keep sort in a parameter you can strip from the canonical, never a separately indexable URL.37- **Pagination:** prefer numbered, crawlable pages over infinite scroll for indexable listings; "load more" is fine for UX but ensure the paginated set is still reachable via `<a href>` links for crawlers. Self-referencing canonicals per page (do NOT canonical page 2+ to page 1 — that hides deeper products).3839## URL & State Strategy4041- **Query parameters** (`?brand=acme&color=black`) are the pragmatic default in WooCommerce; **pretty paths** (`/shop/acme/black/`) look cleaner but are a double-edged sword (see caveat 5 below).42- **Normalize aggressively** so one logical state has exactly one URL:43 - Fix parameter **order** (alphabetical) and value case; collapse duplicates.44 - Drop empty/default params; strip tracking params from the canonical.45 - Decide a single delimiter for multi-select and stick to it.46- Preserve **history/back-button** behaviour via `pushState`/`replaceState`; make filter states **shareable** (the URL fully reconstructs the view).4748## Indexability Allowlist (core)4950Treat facet URLs as **allowlist, not blocklist**: index a small, curated set of demand-backed combinations as **landing pages**; everything else is thin and should stay out of the index.5152- **Index-worthy** — single facets or proven double-facet combos with real search demand and unique, useful content (e.g. `/gaming-pcs/rtx-5070/`). Give them a self-referencing canonical, a tailored title/H1, and intro copy.53- **Thin / infinite** — 3+ stacked facets, sort orders, price sliders, near-empty combinations. Keep these out of the index.5455**Decision matrix:**5657| Combination | Internal links | Canonical | Meta robots | robots.txt |58|-------------|---------------|-----------|-------------|-----------|59| Curated landing facet | Yes (crawlable `<a>`) | Self | index,follow | Allow |60| Non-curated but low-volume | No | (n/a) | noindex,follow | Allow (so the noindex is seen) |61| Combinatorial explosion / sort / session params | No | — | — | Disallow the parameter pattern |6263**NC4 caveats — non-negotiable, because each is a common way teams break their own site:**64651. **noindex requires the page to be crawlable to be seen** — Googlebot only honours a `noindex` if it can fetch the page and read the tag/header. A URL you both `Disallow` in robots.txt and mark `noindex` will keep the `noindex` **unread**.662. **robots.txt disallow hides the noindex tag** — the two directives conflict. Disallowed URLs can still be indexed (URL-only, from links) precisely because the crawler never sees the `noindex`. Pick one mechanism per URL.673. **canonical is only a hint** — `rel=canonical` is a suggestion Google may ignore; it consolidates signals, it does **not** guarantee the alternate is dropped, and it never stops crawling or serving of the variant.684. **none of noindex/canonical/robots alone prevents origin load** — `noindex` and `canonical` still get crawled (they must be, to be read); `robots.txt` stops *Googlebot* fetching but not users, other bots, or your own AJAX calls. Controlling **crawl and cost** is a separate job from controlling **indexing** (next section).695. **naive pretty-URL rewrites can make facets MORE crawlable** — rewriting `?brand=x&color=y` into `/brand-x/color-y/` turns parameter noise into clean, linkable, crawlable paths, multiplying the crawl surface. Pretty URLs are a *commitment to index*; use them only for the allowlisted set, never as a blanket rewrite.7071## Crawl-Budget & Origin-Load Protection7273Indexability keeps junk out of search; this keeps junk off your **server**. Co-design them — cacheability is not indexability.7475- **GET-parameter normalization** (from URL & State): fewer distinct URLs = fewer crawl paths and higher cache hit-rate. Canonicalize order/case at the edge or in the app.76- **Cache-key design:** decide which parameters vary the cache. Ignore/normalize params that don't change output so `?color=black` and `?color=black&utm=x` hit one cached object — but only ignore params **proven** inert; dropping a real facet, currency, or customer-segment param serves wrong content or enables **cache poisoning**. Cloudflare "Cache Rules" + "Query String Sort" (normalize param order), Varnish's default hash on full `req.url` (normalize in VCL), and LiteSpeed's per-query-string copies ("Drop Query String" only for inert params) all need explicit configuration — the defaults over- or under-cache facets. Verify real `HIT/MISS/BYPASS` headers.77- **AJAX response caching:** filter AJAX endpoints bypass the page cache by default. Cache their JSON responses (object cache / transient / edge) keyed on the *normalized* facet set, or they become an uncached origin hit per interaction.78- **Internal-link discipline:** only allowlisted combinations get crawlable `<a href>` links. Render non-indexable filters as buttons/JS controls (not anchors) so you are not *advertising* crawl paths you then try to suppress.79- **Monitor:** watch GSC Crawl Stats and server logs for crawler time spent on parameter URLs; a spike there is the early signal of a crawl trap.8081## Woo Query & DB Performance8283High-cardinality attribute filtering is where WooCommerce falls over at the database.8485- **`meta_query` is the trap.** Filtering on product attributes stored as post meta forces expensive `wp_postmeta` self-joins that scale badly with catalogue size and attribute count.86- **Use the attribute lookup table.** WooCommerce maintains **`wc_product_attributes_lookup`** — a flat, indexed table purpose-built for fast attribute filtering. (Version history, since it is widely misremembered: WC **3.6** introduced `wc_product_meta_lookup`; the **attribute** lookup table `wc_product_attributes_lookup` arrived experimentally in **WC 5.6** and became the default in **WC 6.3**. It is standard in WC 9.x/10.x.) Keep it regenerated (WooCommerce → Status → Tools) after bulk imports.87- **Cache expensive aggregations** (facet counts, price ranges) in transients / a persistent object cache (Redis/Memcached); recompute on product save, not per request.88- **When to offload:** past a few thousand products, or with many high-cardinality attributes and free-text search, move filtering/search to an external index — **ElasticPress** (Elasticsearch/OpenSearch) or **Algolia**. Keep **WooCommerce as the source of truth** and treat the index as a rebuildable projection. The mechanics of that offload (sync, keys, security) live in `woocommerce-developer` §Performance → Search Offload — do not duplicate them here.8990## Build vs Plugin9192| Option | When it fits | Watch for |93|--------|-------------|-----------|94| **FacetWP-class plugin** | You need fast time-to-value and it exposes the controls below | Verify it lets you control indexability + cache behaviour |95| **Custom build** | You need exact crawl-control / cache-key / index semantics | More engineering; you own the query strategy |9697Evaluate any filter plugin against: (1) does it emit clean, normalizable URLs; (2) can you set canonical/noindex per facet state; (3) does it use the attribute lookup table (not raw `meta_query`); (4) how do its AJAX endpoints interact with your page cache and CDN; (5) does it let you withhold internal links from non-allowlisted combos.9899## Anti-Patterns100101| Don't | Why |102|-------|-----|103| Expose every filter combination as a crawlable `<a href>` | Crawl trap + thin duplicates; only allowlisted combos get links |104| `Disallow` a URL in robots.txt AND rely on its `noindex` | The crawler never sees the `noindex` — the page can still get indexed |105| Blanket-rewrite `?params` into pretty paths | Multiplies the crawlable surface (caveat 5) |106| Canonical page 2+ back to page 1 | Hides deeper products from indexing |107| Trust `canonical`/`noindex` to cut server load | They must be crawled to work; crawl/cost control is separate |108| Filter high-cardinality attributes via raw `meta_query` | `wp_postmeta` self-joins melt at scale — use `wc_product_attributes_lookup` |109| Leave filter AJAX endpoints uncached | One uncached origin hit per interaction |110| Return a 200 empty page for an invalid/zero-result combo | Return `404` so crawlers drop it (Google faceted-nav guidance) |111| Duplicate the search-offload recipe here | It lives in `woocommerce-developer` — reference it |