Faceted Navigation & URL Parameters
A catalogue with eight filters and six values each can generate more URLs than there are products in the store, and a decoupled front end will happily render every one of them into a crawlable page. This section turns that combinatorial surface into a deliberate, countable set: which combinations earn indexable URLs, what happens to the rest, and how to stop the remainder from being discovered at all.
Prerequisites
Before changing facet behaviour, confirm you have:
- A facet inventory — every filter, its value count, and whether it is single- or multi-select. Without this the problem has no size.
- Search demand data per facet value, from Search Console query data or keyword research, since indexability decisions must follow demand rather than intuition.
- Crawl data for the current parameter space, ideally from verified crawler logs — see parsing CDN logs for Googlebot crawl waste.
- Control over listing markup, so filter controls can be rendered as buttons rather than anchors where required.
- A working canonical layer. Facet control is mostly canonical work at scale — see canonical URL enforcement.
The Combinatorial Surface
The core problem is multiplication. Each facet you allow to combine freely multiplies the reachable URL count, and because crawl budget is finite, every additional order of magnitude pushes real product pages further down the queue.
The numbers matter less than the shape: indexability has to be an allowlist, because any denylist approach is trying to enumerate a set that grows faster than you can maintain it.
Step-by-Step Implementation Workflow
Step 1 — Measure the surface you actually have
Start from the route data, not from a guess. Enumerate the facets and compute the reachable combinations at each depth.
// scripts/facet-surface.mjs
import { facets } from '../lib/catalog.mjs';
const counts = facets.map((f) => f.values.length);
const choose = (n, k) => (k === 0 ? 1 : (n / k) * choose(n - 1, k - 1));
for (let depth = 1; depth <= 3; depth++) {
const combos = choose(counts.length, depth);
const perCombo = counts.slice(0, depth).reduce((a, b) => a * b, 1);
console.log(`depth ${depth}: ~${Math.round(combos * perCombo).toLocaleString()} URLs`);
}
Validation: Compare the depth-2 figure against the number of parameterised URLs Googlebot actually requested last month. If the crawled figure is close to the theoretical one, the entire surface is already discoverable.
Step 2 — Classify every parameter
Not all parameters are the same problem. Content-changing parameters alter the result set; ordering parameters reshuffle it; tracking parameters change nothing at all.
// lib/params.js — one classification used by canonical, sitemap, and link logic
export const PARAM_CLASS = {
colour: 'content', size: 'content', brand: 'content', category: 'content',
sort: 'ordering', order: 'ordering', view: 'ordering', page: 'pagination',
utm_source: 'tracking', utm_medium: 'tracking', gclid: 'tracking', ref: 'tracking',
};
export function classify(searchParams) {
const groups = { content: [], ordering: [], pagination: [], tracking: [], unknown: [] };
for (const [k, v] of searchParams) groups[PARAM_CLASS[k] ?? 'unknown'].push([k, v]);
return groups;
}
SEO impact: Tracking parameters must never reach the canonical, ordering parameters must always canonicalise to the unordered URL, and only content parameters are eligible for indexation at all.
Validation: Run the classifier over a month of crawled URLs and inspect the unknown bucket — anything in it is a parameter nobody has assigned a policy to.
Step 3 — Define the indexable allowlist
Promote only the combinations that have demand and inventory. Everything else is a filtered view of a page that already exists.
// lib/indexable-facets.js
export const INDEXABLE = new Set([
'brand', // /shoes/nike/
'colour', // /shoes/black/
'category+brand', // /shoes/running/nike/
'category+colour', // /shoes/running/black/
]);
export function isIndexableCombination(contentParams) {
const key = contentParams.map(([k]) => k).sort().join('+');
return INDEXABLE.has(key);
}
SEO impact: Turns indexability into a reviewable list a merchandiser can reason about, instead of an emergent property of the filter UI.
Validation: Every entry in the set must map to a URL that renders at least a page of results. A combination with three products is a thin page, however much demand it has.
Step 4 — Canonicalise everything outside the allowlist
Non-allowlisted combinations point at their nearest indexable ancestor: strip the non-indexable content parameters, drop ordering and tracking entirely.
// app/shop/[category]/page.jsx
import { classify, isIndexableCombination } from '@/lib/params';
export async function generateMetadata({ params, searchParams }) {
const sp = new URLSearchParams(searchParams);
const { content } = classify(sp);
const indexable = isIndexableCombination(content);
const canonicalParams = new URLSearchParams(indexable ? content : []);
const qs = canonicalParams.toString();
const canonical = `https://example.com/shop/${params.category}/` + (qs ? `?${qs}` : '');
return {
alternates: { canonical },
robots: indexable ? { index: true, follow: true } : { index: false, follow: true },
};
}
SEO impact: Duplicate filtered views stop competing with the category page, and follow keeps equity flowing to the products they link to even while the view itself is suppressed.
Validation: curl -s 'https://example.com/shop/shoes/?colour=black&size=9&sort=price' | grep canonical returns the category or single-facet URL, never the three-parameter one.
Step 5 — Remove the crawl paths, not just the index eligibility
Canonicals fix selection but not discovery. To stop the crawl waste you have to make the non-indexable combinations unreachable by a crawler.
// components/FacetControl.jsx — indexable facets are links, the rest are buttons
export function FacetControl({ facet, value, indexable, onToggle }) {
if (indexable) {
return <a href={`/shop/${facet}/${value}/`} rel="nofollow">{value}</a>;
}
return (
<button type="button" onClick={onToggle} aria-pressed="false">
{value}
</button>
);
}
SEO impact: A crawler parsing the listing page finds only the allowlisted facet URLs. The remaining combinations are still fully usable by visitors — they just are not links, so they are never discovered.
Validation: curl -s https://example.com/shop/shoes/ | grep -oE 'href="[^"]*\?[^"]*"' | wc -l returns zero, or only pagination links.
Step 6 — Keep the allowlist out of drift
The allowlist is only useful if the sitemap, the internal links, and the canonical logic all read from it.
// app/sitemap.js — the sitemap is generated from the same set
import { INDEXABLE } from '@/lib/indexable-facets';
import { expandCombination } from '@/lib/catalog';
export default async function sitemap() {
const urls = [];
for (const combo of INDEXABLE) {
for (const url of await expandCombination(combo)) {
urls.push({ url, changeFrequency: 'weekly', priority: 0.6 });
}
}
return urls;
}
Validation: Diff the sitemap URL set against the set of URLs returning index in their robots directive. The two must be identical; any asymmetry is a bug in one of them.
Decision Tree for a Single Request
Every incoming URL resolves through the same four questions, in order. Implementing this as one function that canonical, robots, and sitemap logic all call is what prevents the three from drifting apart.
HTTP Headers & CDN Directives Reference
| Header / signal | Value on a non-indexable facet URL | Rationale |
|---|---|---|
Link |
<clean-url>; rel="canonical" |
Gives a canonical to responses whose HTML might be truncated or cached oddly |
X-Robots-Tag |
absent on HTML | Facet suppression belongs in the metadata layer where the allowlist lives |
Cache-Control |
public, s-maxage=600 on allowlisted facets only |
Rarely requested combinations should not occupy edge cache capacity |
Vary |
must not include the query string | Query variation is already a distinct cache key; adding Vary fragments it further |
Content-Type |
text/html; charset=utf-8 |
Encoding mismatches on multi-byte facet values produce mojibake in titles |
Link |
rel="next" only within an allowlisted facet |
Pagination sequences below the allowlist should not be advertised at all |
Validation Protocol
Validation has to cover three artefacts that are generated independently and can therefore disagree: the canonical, the robots directive, and the sitemap entry.
Confirm the canonical collapses parameters correctly
for q in '?sort=price' '?utm_source=x' '?colour=black' '?colour=black&size=9'; do
printf '%-28s -> ' "$q"
curl -s "https://example.com/shop/shoes/$q" \
| grep -oE '<link rel="canonical" href="[^"]+"' | head -n 1
done
Confirm non-indexable combinations are not linked
curl -s https://example.com/shop/shoes/ \
| grep -oE 'href="/shop/[^"]*"' | sort -u | wc -l
# Should match the allowlist size plus pagination, not the combinatorial count
Confirm the sitemap and the robots directives agree
curl -s https://example.com/sitemap.xml | grep -oP '(?<=<loc>)[^<]+' | sort > sm.txt
while read -r u; do
curl -s "$u" | grep -q 'noindex' && echo "in sitemap but noindexed: $u"
done < sm.txt
Track parameter crawl volume over time
awk -F'\t' '$2 ~ /\?/ { n++ } END { printf "parameterised crawl share: %.1f%%\n", 100*n/NR }' \
gb-verified.tsv
Troubleshooting
| Symptom | Root cause | Fix |
|---|---|---|
| Crawl volume dominated by parameter URLs | Filter controls rendered as crawlable links | Convert non-allowlisted controls to buttons as in Step 5 |
| Filtered views outranking the category page | Facet URLs self-canonicalise | Canonicalise to the nearest allowlisted ancestor |
| Allowlisted facet page treated as duplicate | Too little unique content beyond the product grid | Add facet-specific copy, or drop the combination from the allowlist |
| Sitemap contains noindexed facet URLs | Sitemap built from routes rather than the allowlist | Generate the sitemap from the same set the canonical logic reads |
| Tracking parameters appearing in the index | Canonical built from the full query string | Strip the tracking class before constructing the canonical |
| Ordering parameters splitting rankings | sort treated as a content parameter |
Reclassify to ordering; the result set is identical, only the order differs |
Why this is a merchandising problem as much as a technical one
The allowlist is where SEO and merchandising meet, and treating it as purely technical is the reason it usually goes unmaintained. Engineers can build the mechanism that decides which combinations are indexable, but the judgement about which combinations have commercial value belongs to the people who know the catalogue. A list maintained in code by the team least able to evaluate its entries is a list that stops changing shortly after launch.
The practical arrangement that works is to split the two concerns explicitly. Engineering owns the tests — demand evidence, an inventory floor, a distinctness threshold — and the enforcement that follows from them. Merchandising owns the proposals and the review cadence. Any combination can be proposed by anyone; only combinations passing all three tests enter the list, and that check is automated rather than argued.
This arrangement also gives the allowlist a natural review rhythm. Inventory changes seasonally, demand changes with the market, and a list that qualified in spring may contain three-product pages by autumn. A quarterly review that runs the tests again and reports the entries that no longer pass turns maintenance into a fifteen-minute decision rather than an audit nobody schedules.
The order in which facet work pays off
Facet programmes tend to start with canonicals because they are the most familiar instrument, and that ordering is backwards. Canonicals stop duplicates competing but do nothing about discovery, so the crawl volume that motivated the work in the first place keeps rising. The order that produces measurable results is to cut the crawl paths first, canonicalise what has already been discovered second, and promote the allowlist third.
Cutting paths first also makes the remaining work smaller. Once the filter component stops emitting anchors, the set of URLs needing canonical treatment stops growing, which turns an open-ended cleanup into a finite one. Teams that reverse the order find themselves canonicalising a set that expands faster than they can work through it.
Pages in This Section
- Controlling Facet URL Explosion in Headless Catalogs — measure the surface and cut the crawl paths that create it
- Deciding Which Facet Combinations to Index — the demand and inventory tests that qualify a combination for the allowlist
- Canonicalising Sorted and Filtered Listings — collapse ordering and tracking parameters without losing the pages you meant to keep
Frequently Asked Questions
Should facet URLs use query parameters or path segments? Use path segments only for the small allowlist you deliberately want indexed, and query parameters for everything else. A path segment reads as a real page and invites crawling, so promoting a combination to a path is the strongest indexation signal available. Keeping the remainder on query strings makes it trivially identifiable and easy to canonicalise in bulk.
How many facet combinations should be indexable? Far fewer than teams expect — typically single-facet pages plus a handful of two-facet pairs with proven demand. A combination qualifies only if it has query volume and enough distinct inventory to be useful. Three-facet combinations almost never pass both tests, and each one admitted multiplies the surface around it.
Does rel=canonical solve facet duplication on its own? It solves selection, not crawling. Google still fetches every variant to read its canonical, so a canonical-only approach leaves the crawl waste untouched even after the duplicates stop competing. Pair canonicals with removing crawl paths so the URLs are neither discovered nor fetched.
What about faceted pages that already rank well? Leave them alone and add them to the allowlist explicitly. A combination that already earns traffic has demonstrated the demand test empirically, which is better evidence than any keyword tool. The allowlist exists to be a deliberate set, not a small one.
Part of: Dynamic Routing & Indexation Workflows
Related
- Pagination Handling in Headless — the other parameter dimension that multiplies against every facet you allow
- Canonical URL Enforcement — the enforcement layer that makes facet canonicals consistent across CMS, framework, and edge
- Robots & Crawl Directives in Headless Stacks — which instrument to use when suppression is genuinely required
- Preventing Indexation Bloat in Decoupled Sites — the site-wide symptom uncontrolled facets produce
- Crawl Budget Impact in Headless — what the reclaimed crawl capacity is worth