Skip to content

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's seo.canonicalOverride replaces 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: manufacturer and taxonomy attributes shown as a list or as swatches. A rule for onSale, inStock, featured, price or rating is ignored, and so is one for an attribute shown as a number range (length-mm as "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 (landingRules on 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 Pages in 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, or rules, [] = none; the response has mode inherit/off/own and the effectiveRules), and GET /v1/admin/merchandising/nodes/{nodeId}/landing-pages. Invalid rules are 400 with a message, unknown ids 404.
  • MCP: productRanking landing and manageProductRanking setLanding / 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 minProducts of 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 excluded by an override;
  • no visible sub-category has the rule's segment as its slug (a child called brand would 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=N self-canonical, anything more noindex, follow with the landing page as canonical.
  • One URL per page. /c/power-tools?manufacturer=DeWalt (exactly one facet, one value, a landing page, no q) 301s to /c/power-tools/brand/dewalt, keeping p, sort and 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-links in 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 in data-facet-href, which the phone filter sheet (facet-sheet.js) stages from, so filtering on a phone is unchanged.
  • 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.

/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: /search is 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.