Catalogue SEO¶
How category pages (/c/**), their filtered variants, brand landing pages and site search present
themselves to search engines: which URLs are indexable, what their canonical is, and which go in
the sitemap.
Which listing URLs are indexed¶
Every category page follows one rule (ListingIndexing, used by MerchandisingPageController):
| Request | robots | canonical |
|---|---|---|
/c/tools (no parameters, only tracking parameters, or p=1) |
indexable | https://{domain}/c/tools |
/c/tools?p=3 |
indexable | https://{domain}/c/tools?p=3 |
a facet (?manufacturer=…, ?colour=…, ?price=…), q, sort, pageSizeOverride, price_min/price_max, or any other parameter |
noindex, follow |
https://{domain}/c/tools |
- Pagination. Page 2 onwards is its own page with a self canonical: the products listed there
are only linked from it, so it must not claim to be a copy of page 1. Its title gets
– Page N(PageTitles). - Refinements. Filtered, sorted and searched URLs stay out of the index but their links are
followed, and their canonical names the clean category. Google prefers a single signal; both
point the same way here. If Search Console ever reports a category as excluded by
noindex, drop the canonical from the noindexed variants. - Canonicals are absolute, on the site's primary domain (
domainURL), so a page reached through a secondary domain still names the primary one. A node'sseo.canonicalOverridereplaces its own path as the canonical. - Tracking parameters (
utm_*,gclid,gbraid,wbraid,dclid,fbclid,msclkid,mc_cid,mc_eid,_gl,_ga;FacetParams.isTracking) are ignored: they don't filter the products, get no filter chip, are left out of filter links and don't affect indexing. Before this, every unknown parameter was taken as a facet, so an ad click (?gclid=…) or a newsletter link (?utm_source=…) showed an empty category. - Facet value links are
rel="nofollow": crawlers have nothing to gain from the combinations. The exception is a value with a brand landing page.
Brand landing pages¶
A category can have an indexable page per brand: /c/tools/power-tools/brand/dewalt lists the
category's DeWalt products under its own heading, title, intro and meta description, with a self
canonical. Pages are generated by a rule, not authored one by one, and only for brands with enough
products to be worth a page.
Rules¶
// on a node (MerchandisingNode.facetLandingRules), or for the whole tree
// (MerchandisingTree.defaultFacetLandingRules)
[
{
"facetId": "manufacturer", // Brand; or a taxonomy attribute id such as "colour"
"pathSegment": "brand", // optional; default "brand" for manufacturer, else the facet id
"minProducts": 5, // optional; default the site setting facet-landing-min-products (3)
"overrides": [ // optional, per value
{
"value": "DeWalt",
"heading": "DeWalt cordless power tools", // H1; default "{value} {category}"
"metaTitle": "DeWalt Power Tools | Trade Prices", // <title> as written; default "{heading} | {site}"
"metaDescription": "…", // default the intro, else "Shop {heading} at {site}: N products."
"intro": "Brushless 18V kit for site work.", // text under the H1; default none
"excluded": false // true: never generate this page
}
]
}
]
- A node's own list replaces the tree default entirely;
[]on a node switches pages off there.null(the default) inherits. - Only list facets can have pages:
manufacturerand taxonomy attributes shown as a list or as swatches. A rule foronSale,inStock,featured,priceorratingis ignored, and so is one for an attribute shown as a number range (length-mmas "40–60 mm" has no values to give pages to). - Rules travel with site transfer and can be written in a site bundle's
merchandising/JSON (landingRuleson a tree or a node in the demo-site generator).
Editing the rules¶
Three surfaces, one path: FacetLandingRuleService validates and saves for all of them.
- Admin. Merchandising trees → {tree}. The Landing pages card lists the tree's default rules; Edit default rules opens the editor. Each category row has a Landing pages button whose editor chooses between Use the tree's default rules, No landing pages here and Its own rules, and lists the pages the category has now with their URLs and product counts (refreshed after a save). A rule is a filter (Brand, or a list or swatch attribute of the active taxonomy), an optional path segment and minimum, and per-value page copy (heading, meta title, meta description, intro) or an exclusion. A category with its own rules or none shows a badge in the tree.
- REST (
Admin Landing Pagesin the API reference):GET/PUT /v1/admin/merchandising/trees/{treeId}/landing-rules(the default;rules: []removes it),GET/PUT /v1/admin/merchandising/nodes/{nodeId}/landing-rules(inherit: true, orrules,[]= none; the response hasmodeinherit/off/ownand theeffectiveRules), andGET /v1/admin/merchandising/nodes/{nodeId}/landing-pages. Invalid rules are 400 with a message, unknown ids 404. - MCP:
productRankinglandingandmanageProductRankingsetLanding/inheritLanding, with the category by id, path or name (see MCP).
A rule is refused when its facet isn't manufacturer or an attribute id of the active taxonomy,
or is a toggle, price, rating, a yes/no or number attribute, or an attribute shown as a number
range; when its path segment isn't lower-case letters, digits and single hyphens (at most 40); when
minProducts is below 1; when an override has no value or two overrides name the same one; or when
two rules share a facet or a segment. A list holds at most 10 rules of at most 200 overrides. Saved
rules are trimmed, with segments lower-cased.
Which pages exist¶
FacetLandingPageService decides. A value of the rule's facet gets a page when:
- the category is visible, has a canonical path and no canonical override;
- at least
minProductsof the category's products carry it, and not every one of them (a brand that is the whole category would duplicate the category page); - it isn't
excludedby an override; - no visible sub-category has the rule's segment as its slug (a child called
brandwould own those URLs; the rule is skipped and a warning logged).
Counts come from one facet-only Typesense query per category and facet
(MerchandisingQueryService.facetValueCounts, per_page=0), cached per pod for five minutes (cache
facetValueCounts, keyed by site, node, the node's last-modified time and facet). When the counts
can't be fetched there are no pages, and landing URLs 404 rather than show an unchecked page.
URL. /c/{category path}/{segment}/{slug}. The slug is the value lower-cased, accents dropped,
other characters collapsed to - (Thorne & Platt → thorne-platt), and is resolved back against
the live counts, so nothing is stored per brand; of two values with the same slug, the one with more
products gets it. A fixed brand segment can't collide with a brand slug and only collides with a
child category literally called brand; the flat form (/c/power-tools/dewalt) would collide with
any child category named like a brand. A real category path always wins: only a path that isn't a node
is tried as a landing page.
Colour (and other attribute) pages. The segment names the facet, so a colour rule gives fashion
sites /c/womens/dresses/colour/red (Navy Blue → /colour/navy-blue), with the same thresholds,
copy, linking, redirect and sitemap entries as brand pages. A colour shown as swatches keeps its
swatch: on an unfiltered category the swatch links to the colour's page, and on the page it's the
ticked value. The "Shop {category} by colour" list sits beside the brand one. A category with both
rules gets both sets. The Halden demo's Hi-Vis Clothing has its own rules: brand pages and
/c/workwear-and-ppe/hi-vis/colour/orange and /yellow, with their own headings and intros.
On the storefront¶
- The page renders the category template (every theme) with the value selected, the landing
heading as H1 and
<title>source, the intro under it, the landing meta description, a breadcrumb ending in the brand, and the same indexing rule on its own path: indexable with a self canonical,?p=Nself-canonical, anything morenoindex, followwith the landing page as canonical. - One URL per page.
/c/power-tools?manufacturer=DeWalt(exactly one facet, one value, a landing page, noq) 301s to/c/power-tools/brand/dewalt, keepingp,sortand tracking parameters. - Linked from the category. Every landing page is linked from its category (a sitemap entry alone
leaves an orphan, which ranks poorly):
- a "Shop {category} by brand" list under the products (
fragment/landing-links, styled.nova-landing-linksin Nova and the themes on it; its own markup in Bootstrap). It also shows on the landing pages, with the current one marked, so they link to each other; - on an unfiltered category the Brand facet's values that have pages link to them, without
rel="nofollow". The toggle URL stays indata-facet-href, which the phone filter sheet (facet-sheet.js) stages from, so filtering on a phone is unchanged.
- a "Shop {category} by brand" list under the products (
- Filtering a landing page. Its filter rail is the category's with the brand ticked, so every refinement goes to an ordinary filtered category URL (noindex) and unticking the brand returns to the category. On phones a filter link to another path navigates straight away rather than being staged.
- Sitemap. Each listed category is followed by its landing pages.
Brand × anything (two brands, brand and colour, brand and price) stays non-indexable by design.
Site search¶
/search is always noindex, follow: search engines ask sites to keep internal search result
pages out of their index. Category pages are the indexable listings.
Sitemap¶
The worker's SiteMapGenerator lists, in order: CMS pages, category pages, collections, products.
Category pages come from MerchandisingTreeService.findSitemapNodes: the primary tree's nodes,
depth first in display order, without
- hidden nodes and everything under them;
- nodes marked
seo.excludeFromSitemap(their children are still listed); - nodes with a
seo.canonicalOverride(their URL isn't their canonical); - nodes known to be empty (
CategoryCountService; a node whose count can't be fetched is kept).
Each entry is the node's canonical path on the primary domain, with lastmod when the node has a
last-modified date, followed by the node's brand landing pages. Only page 1
of a listing is listed.
The job runs every three hours (badger.sitemap.generation-schedule) over every non-staging site and
stores each sitemap for /sitemap.xml to serve. A failure is contained: an entry the generator
rejects is left out with a warning, and a site whose sitemap can't be built is logged and skipped.
Before this, one bad entry ended the whole run, so every site after it in the list had no sitemap (on
dev, all the demo sites).
Cross-domain canonicals¶
A CMS page (or collection or product) whose canonical path is an absolute URL on another host, such as
a page on dmrt whose canonical is https://www.derbymrt.org.uk/ after the site moved to its own
domain, is a cross-domain canonical: legitimate, and it tells search engines to credit the other
URL. The page keeps it: the head emits <link rel="canonical" href="https://www.derbymrt.org.uk/">
exactly as entered. The sitemap leaves the page out on purpose (that URL belongs in the other site's
sitemap), logged at debug rather than as a failure (SiteMapGenerator.isCrossDomainCanonical). An
absolute canonical on the site's own primary domain is listed, over https even when written with
http:// or as //domain/….
Tests¶
ListingIndexingTest: clean, paginated, refined and tracked URLs; absolute canonicals.MerchandisingPageControllerTest: facet and sort URLs are noindex with the plain canonical, later pages self-canonical, tracking params neither filter nor stop indexing, absolute and overridden canonicals.FacetParamsTest,FacetRailModelTest: tracking parameters are not facets, get no chip and are left out of links.SearchControllerTest:/searchis noindex, tracking params ignored.FacetLandingPageServiceTest: slugs, thresholds, the whole-category rule, overrides and exclusions, tree defaults and opt-out, unsupported facets, hidden/overridden/shadowed categories.MerchandisingQueryServiceTest: value counts for brands and taxonomy attributes.MerchandisingPageControllerTest: landing pages render with the value pinned, their canonical, breadcrumb and rail; extra filters make them noindex; unknown slugs 404; the single-value filter redirect; facet links to landing pages only on an unfiltered category.LandingLinksRenderTest: the facet links and the "Shop by brand" list in every theme.MetaDescriptionHeadRenderTest: a landing page's title and description in every theme's head.MerchandisingTreeServiceTest: which nodes the sitemap lists.SiteMapGeneratorTest: category entries, their place in the file, empty and uncounted categories, brand and colour landing pages, cross-domain canonicals left out and same-domain ones kept.FacetLandingRuleServiceTest: which facets a rule can use (brand, list and swatch attributes; not toggles, numbers or ranges), validation and normalisation, tree default and node inherit/off/own.FacetLandingPageServiceTest: colour pages beside brand pages, with overrides and exclusions.MerchandisingQueryServiceTest: a swatch attribute is counted, a number range has no values.MerchandisingPageControllerTest: a colour landing page with its swatch ticked, swatch links and the single-colour redirect.MerchandisingLandingAdminTest: the admin card, row badges and editor data; the admin endpoints.LandingPagesAdminControllerTest: the REST endpoints.ProductRankingToolsTest: the MCP actions.MetaDescriptionHeadRenderTest: a cross-domain canonical in every theme's page head.