Drupal is a registered trademark of Dries Buytaert
Release: Leaflet 10.4.11 Minor update available for module leaflet (10.4.11). Release: Session Inspector 1.0.8 Minor update available for module session_inspector (1.0.8). Release: Migrate QA 2.0.4 Minor update available for module migrate_qa (2.0.4). Release: CKEditor Description List 3.0.0 Major update available for module ckeditor_descriptionlist (3.0.0). Release: FlowDrop 2.4.0 Minor update available for module flowdrop (2.4.0). Release: JWT Token Refresh 1.0.4 Minor update available for module jwt_token_refresh (1.0.4). Release: ConReg 1.0.0-beta1 First beta version released for module conreg (1.0.0-beta1). Release: AI Image Studio 1.0.0-beta8 New beta version released for module ai_image_studio (1.0.0-beta8). Usage Milestone: Role Theme Switcher Module role_theme_switcher crossed 1,000 active installs. Module Revived: Decoupled Router 2.0.7 Module decoupled_router updated after 11 months of inactivity (2.0.7).

Taxonomy Facets

79 sites Security covered
View on drupal.org

This module provides progressive content filtering using taxonomy terms as facets, with each filter combination resulting in a clean, server-rendered URL. This approach allows the entire faceted site to be exported as static HTML, maintaining full navigability without JavaScript or a live backend, making it ideal for CDNs and offline use. It also supports generating static sites with Tome and creating SEO-friendly sitemaps with Simple XML Sitemap.

Taxonomy Facets

Progressive content filtering with clean URLs, using taxonomy terms as facets - 100% server-rendered, with no JavaScript, so a faceted site can be exported to static HTML and the navigation still works.

Faceted browsing, the sidebars you see on every shop and catalogue, almost always means JavaScript and a live query backend. That requires a server to keep running, a database to secure, and leaves a pile of pages that crawlers and offline readers never reach. Taxonomy Facets takes the opposite route: every combination of filters is its own plain, server-rendered URL (/browse/main/europe/italian), so the whole faceted site can be exported to flat HTML and still browse like a live CMS. No JavaScript, no search API, no client-side state - just links. The payoff is faceted navigation you can serve from a CDN for next to nothing, with no server-side attack surface, where every facet page is a distinct, crawlable, SEO-friendly URL. Run it as an ordinary Drupal site, or freeze the whole thing to static HTML and serve it anywhere - the choice is yours.

View the (dummy) demo site: https://tf.thenoisyspider.com

The same site frozen to pure static HTML with the Tome submodule: https://tf-html.thenoisyspider.com/browse/ - notice that faceted filtering still works even though these are just flat HTML files served by a plain web server (or CDN): no application server, no database, no JavaScript.

Table of contents

Introduction

Taxonomy Facets turns your vocabularies into a faceted browsing experience over clean URLs. A node listing lives at a configurable base path (default is /browse), and each path segment is a taxonomy term's URL alias:

/browse/main/europe/italian     -> Italian main courses
/browse/europe/meat/pork        -> pork recipes from any European cuisine

A hierarchical term is written as its full ancestor trail, root first, so the hierarchy is visible in the address bar: the Cuisine term Italian (under Europe) is /browse/europe/italian, and Dalmatinska (Europe > Balkan > Dalmatinska) is /browse/europe/balkan/dalmatinska. The selection stays single-select per vocabulary - only the leaf term filters; its ancestors are just the readable trail. Segments are ordered canonically (vocabularies alphabetical by machine name, each vocabulary's trail contiguous and root first), so every filter combination has exactly one URL.

The listing shows the nodes that match the intersection of all the terms in the URL. Because every facet state is its own URL, the whole experience is a graph of plain HTML pages - no client-side scripting.

How it works

  • Intersection across vocabularies. Terms from different vocabularies are AND-ed together. Multiple terms from a single multi-value vocabulary are also AND-ed (the node must have all of them).
  • Hierarchical cascade. Selecting a parent term also matches nodes tagged with any of its descendants, to any depth.
  • Facet menus. A configurable block renders one vocabulary as nested HTML links. Selecting a term applies it (replacing any current term from the same vocabulary) while preserving the other facets; active facets get a "remove" link.
  • Drill-down (optional, per block). With the "Collapse hierarchy" block option, the menu starts at the top level and selecting a term both applies the filter and reveals its children; every other branch stays closed. Because the open state is just the selected term's own page, this needs no JavaScript and drills down identically in a static export. Openable items get a CSS disclosure caret (pointing right when closed, down when open) via the is-collapsed / is-open item classes, which themes can restyle.
  • Contextual counts. Each term shows how many results selecting it would yield given the currently active filters; a parent's count sums its descendants', counting each node once.
  • Multilingual. Listings are language-aware: each language shows only nodes available in that language, facet menus use translated term names, and facet URLs use the term's alias in the current language. A placed language switcher block links each facet page to its equivalent in the target language (using that language's term aliases), rather than a prefix-swapped path that would not resolve.
  • No dead-end links. A facet is only offered when selecting it would return results given the filters already applied, so the menu never links to an empty listing (and a static export never has a broken facet link). Controlled by the "Hide facet links with no results" setting (on by default); turn it off to show every term.
  • Filters follow the visitor onto content pages. On a filtered listing, each result's links carry the active selection as a ?filters= query string; the facet blocks on the content page read it back, so after clicking through to an item the sidebar still shows the selection (active trail, remove link, drill-down state) and further facet clicks continue filtering. Fully server-rendered - still no JavaScript. Controlled by the "Keep active filters on content pages" setting (on by default). Only the title and read-more links are rewritten; field formatters that link to content emit their own links and are not covered. On a static export the query string is stripped (a static host serves one file per page regardless of query), so exported content pages keep the plain unfiltered sidebar; non-Tome static crawlers should strip the parameter themselves.
  • Access aware. Results are produced by access-checked entity queries and respect node access and publication status.

Requirements

This module requires:

  • Node (core)
  • Taxonomy (core)
  • Path alias (core)
  • Pathauto

Every term used as a facet must have a URL alias - a term contributes one path segment (its alias) to the URL, and a hierarchical term also contributes a segment for each ancestor on its trail, so aliases must be unique single segments (no slashes). Pathauto is required so those aliases are generated and kept in sync automatically as terms are added and renamed; configure a [term:name] pattern for taxonomy terms (see Configuration) so a segment reads like italian and the Italian cuisine's page is /browse/europe/italian.

Installation

Install as you would normally install a contributed Drupal module. See Installing modules for further information.

Configuration

  1. Add facet fields. On each content type you want to facet, add an entity reference field targeting each facet vocabulary.

  2. Generate term URL aliases. Configure a Pathauto pattern for taxonomy terms (e.g. [term:name]) and bulk-generate aliases.

  3. Configure the module at Administration > Configuration > Search and metadata > Taxonomy Facets (/admin/config/search/taxonomy_facets):

    • Base path - the first URL segment of the listing (default browse). It must be a single segment that is not an existing alias or route; the form rejects clashes. Changing this takes effect immediately for all generated links; the old path returns 404 straight away, so regenerate your sitemap and re-run any static export after changing it. If you change the value via Drush (drush config:set) rather than the admin form, also run drush cr to rebuild the router.
    • Content types - which node types appear (leave empty for all).
    • Results per page.
  4. Place facet menus at Administration > Structure > Block layout (admin/structure/block).

    Add a Taxonomy Facets: facet menu block for each vocabulary and choose the vocabulary in the block settings.

    Repeat for each vocabulary you want to expose as a facet.

  5. Visit the base path (e.g. /browse) and start filtering. For example:

Submodules

URL Export (taxonomy_facets_export)

The shared foundation for the static-export and sitemap integrations below. It enumerates every valid facet listing URL (pruned by maximum depth and minimum result count) and provides two Drush commands:

  • drush taxonomy_facets:export-urls (alias tf-urls) - print every facet listing URL.
  • drush taxonomy_facets:count (alias tf-count) - estimate how many pages an export or sitemap will produce before you generate it.

You usually don't enable this directly. The Tome and XML Sitemap submodules both depend on it, so enabling either one pulls it in automatically. Enable it on its own only if you want its Drush commands to feed a different static site generator or crawler - any tool that can consume a list of URLs.

Requires: nothing beyond the main module.

Tome Static export (taxonomy_facets_tome)

Integrates with Tome Static to export the entire faceted site - every valid URL combination - to flat HTML files that continue working as a fully navigable, server-less site.

Requires:drupal/tome (the tome_static submodule). Also depends on the URL Export submodule, which is enabled automatically.

Install:

composer require drupal/tome
drush en tome_static taxonomy_facets_tome

Run the export:

drush cr && drush tome:static

Always clear caches before exporting. Stale Drupal render-cache entries from a previous run can otherwise be written into the new static files unchanged.

The output lands in the directory set by $settings['tome_static_directory'] in your settings.php (default ../html relative to the web root). This is intentionally an environment-level setting rather than a UI option - the right output path differs between local dev, CI, and production deploys. Set it in settings.local.php so it does not get committed or synced across environments:

// settings.local.php
$settings['tome_static_directory'] = '/path/to/output';

Pager pages are seeded up front together with the listing pages (the module already knows every listing's result count), so no extra configuration is needed beyond the output directory.

Export performance tips:

The export runs in two phases. Phase 1 renders every seeded page. Phase 2 ("Processing related assets and paths") processes the CSS, JS, font and image-derivative references found in the HTML. Left to itself, Tome would also re-discover every facet listing link on every page during phase 2 - on a large faceted site that is millions of duplicate paths, hours of churn and an out-of-memory risk in the parent Drush process. This module prevents that: because every valid listing URL (including pager pages) is already seeded in phase 1, it tells Tome to ignore listing links found in HTML, leaving phase 2 with only the genuine assets. A few things help further:

  • Raise --path-count (paths per worker process, default 5). Each worker bootstraps a full Drupal to process its chunk, so larger chunks amortize the bootstrap cost:

    drush tome:static --process-count=2 --path-count=20
    
  • Keep the output directory outside your Docker/Lando project root. The default ../html path sits inside Mutagen's sync tree, so every file Tome writes is mirrored back into the Docker VM - roughly doubling I/O. Put it somewhere outside the synced tree:

    // settings.local.php
    $settings['tome_static_directory'] = getenv('HOME') . '/tome-out';
    

    If you run Drupal inside a container (DDEV, Lando, etc.): the path above is a host path and the container cannot write to it unless you mount it explicitly. With DDEV, add a bind mount in .ddev/docker-compose.tome-output.yaml:

    services:
      web:
        volumes:
          - type: bind
            source: ${HOME}/tome-out
            target: /home/tome-out
    

    Then set tome_static_directory to the container-side path and restart:

    // settings.ddev.php or settings.local.php
    $settings['tome_static_directory'] = '/home/tome-out';
    
    ddev restart && ddev drush cr && ddev drush tome:static
    

    Output lands in ~/tome-out on the host with no Mutagen mirroring.

    Because the container path is now a bind mountpoint, Tome logs a harmless rmdir(...): Permission denied warning at the start of each build - it empties the directory contents fine and only fails to remove the mountpoint itself, which does not matter. Ignore it.

  • Stop the page and render caches bloating the database on large exports. A static build renders every page once, and with many facet combinations each page is unique, so the page and render caches never get a hit - they only accumulate. On a big site (tens of thousands of pages) they can grow the database by many gigabytes and fill the disk mid-build. Route those cache bins to a null backend on the command line only (web requests keep their normal caches). Drupal core ships the factory but registers no service for it, so add one:

    # sites/default/services.cli.yml
    services:
      cache.backend.null:
        class: Drupal\Core\Cache\NullBackendFactory
    
    // settings.local.php - scoped to an actual `tome:static` run, not all CLI,
    // so `drush cr` still clears the real bins the live site reads (see the
    // Troubleshooting note below for what goes wrong if you scope this to
    // PHP_SAPI === 'cli' instead).
    $is_tome_static_build = PHP_SAPI === 'cli'
      && in_array('tome:static', $_SERVER['argv'] ?? [], TRUE);
    if ($is_tome_static_build) {
      $settings['container_yamls'][] = $app_root . '/' . $site_path . '/services.cli.yml';
      $settings['cache']['bins']['page'] = 'cache.backend.null';
      $settings['cache']['bins']['dynamic_page_cache'] = 'cache.backend.null';
      $settings['cache']['bins']['render'] = 'cache.backend.null';
    }
    

    The exported HTML is byte-identical; you just stop the database ballooning.

  • Pre-warm image derivatives before exporting. Drupal generates image style derivatives (resized/cropped versions) the first time each image is displayed. During phase 2, Tome requests every image URL it finds in the HTML, so any derivative that does not exist yet has to be generated on the fly - which is slow. Generate them all up front by visiting the live site in a browser and paging through the listing, or run a dedicated warm-up script:

    ddev exec drush php:script web/modules/contrib/taxonomy_facets/scripts/warm_image_derivatives.php
    

    The script loops over every managed image file and every active image style, creates any missing derivative, and reports how many it generated. After it runs, Tome's phase 2 only copies files rather than generating them.

Check the export size first. A full faceted export can be far larger than you expect - every facet combination is its own page. Before running the export, get an exact count from the Tome Static export settings tab (/admin/config/search/taxonomy_facets/tome): click Calculate page count.

It reports the facet listing pages (including pager pages), the node detail pages, and the estimated total HTML files for the current depth/min-results settings. The count runs as a batch, so it scales to any site size without timing out. If the total is too large, raise the minimum results or lower the maximum depth. The same figure is available on the command line:

drush taxonomy_facets:count                       # every page, all depths
drush taxonomy_facets:count --max-depth=2 --min-results=5

What gets exported:

Every valid facet combination for every configured content language, including the root listing (/browse), single-facet pages (/browse/europe/italian), and multi-facet intersections (/browse/main/europe/italian), and every pager page of each listing. Terms with no URL alias in a given language are skipped for that language.

Adjusting the export scope:

By default the module exports all valid combinations (no depth limit, minimum 1 result) - every reachable page gets a file. To narrow the export, go to the Tome Static export tab on the Taxonomy Facets settings page (/admin/config/search/taxonomy_facets/tome) or use Drush:

drush config:set taxonomy_facets_tome.settings max_depth 2
drush config:set taxonomy_facets_tome.settings min_results 3

XML Sitemap (taxonomy_facets_simple_sitemap)

Adds a URL generator plugin for Simple XML Sitemap that lists every qualifying facet listing page. SEO-friendly defaults exclude thin-content combinations (fewer than 5 results) and combinations deeper than two vocabularies.

Requires:drupal/simple_sitemap (version 4.x). Also depends on the URL Export submodule, which is enabled automatically.

Install:

composer require drupal/simple_sitemap
drush en simple_sitemap taxonomy_facets_simple_sitemap

Wire the generator to your sitemap type:

Go to Administration > Configuration > Search and metadata > Simple XML Sitemap > Sitemap types (/admin/config/search/simplesitemap/types) and edit your sitemap type (usually Default hreflang). Enable the Taxonomy Facets listing generator checkbox and save.

Regenerate the sitemap:

drush simple-sitemap:generate

The sitemap is then available at /sitemap.xml.

Adjusting the SEO defaults:

Go to the XML Sitemap tab on the Taxonomy Facets settings page (/admin/config/search/taxonomy_facets/sitemap), or use Drush:

drush config:set taxonomy_facets_simple_sitemap.settings max_depth 1
drush config:set taxonomy_facets_simple_sitemap.settings min_results 10
drush simple-sitemap:generate
Setting Default Meaning max_depth 2 Maximum facet vocabularies per combination (0 = unlimited) min_results 5 Minimum results; combinations below this threshold are excluded

The XML Sitemap tab also has a Calculate URL count button that reports exactly how many facet listing URLs the current settings produce (across all languages), so you can size the sitemap before generating it.

Multilingual: one sitemap entry is generated per valid combination per configured content language. German paths (e.g. /de/browse/hauptgericht) use the term's German alias automatically.

Known limitations

  • Single-select per vocabulary. Selecting a term replaces any current term from the same vocabulary. Choosing several terms from one (multi-value) vocabulary at once is planned for a later release; the query engine already supports it.
  • Term URL aliases required. A facet term with no URL alias cannot appear in a clean URL. Generate aliases (e.g. with Pathauto) for all facet terms.
  • No automatic upgrade from 1.x/2.x. The 3.x branch is a clean-room rewrite with different routes and configuration. Configure it from scratch rather than upgrading in place.

Upgrading

Within the 3.x branch, update as usual and run the database updates so new settings are added to your configuration:

composer update drupal/taxonomy_facets
drush updatedb
drush cache:rebuild

Upgrading from 3.0.x to 3.1.x adds two settings (with backward-compatible defaults): Result counts on facet links (count_display, defaults to contextual - the previous behaviour) and Hide facet links with no results (hide_empty, on by default, so dead-end facets are no longer linked). Review them at Administration > Configuration > Search and metadata > Taxonomy Facets after updating.

Troubleshooting

Most issues below relate to the Tome Static export, which is the most resource-intensive part of the module on a large site.

  • The export runs out of memory, or the database "goes away" / crashes mid-export. Tome renders pages in parallel (default --process-count=5), and each worker bootstraps a full Drupal, so peak memory is several hundred MB per worker on top of the database. On a memory-constrained host the OS (or Docker) kills the largest process - often the database - which surfaces as SQLSTATE[HY000] [2002], MySQL server has gone away, or a container exit code 137. Two levers help:

    • Lower the worker count to reduce peak memory (slower, but stable):

      drush tome:static --process-count=2   # or 1 on very small hosts
      
    • Give the container more memory if you run under Docker (DDEV, Lando, etc.). In Docker Desktop, Settings > Resources > Memory - raise it well above the sum of your database buffer pool and the per-worker footprint (8 GB+ is comfortable for a large export). After a crash the database may start in crash-recovery; do a clean ddev restart before retrying.

  • Another user may be running a static build on the next run. A crashed or interrupted export leaves Tome's "building" state flag set. If you are sure no build is running, answer yes to continue - it is safe after a failed run.

  • The export contains recursive /de/de/de/... paths or /.../feed pages. On a path-prefix multilingual site, Tome can re-prefix links it discovers by crawling HTML, recursing one language prefix deeper each pass. The taxonomy_facets_tome submodule prunes these (and taxonomy feeds) automatically; if you still see them, clear caches (drush cr) so the cleanup subscriber is registered, and confirm the submodule is enabled.

  • A term is missing from the listing or facet menu. It has no URL alias in the current language. Generate aliases for all facet terms (see Requirements); terms without an alias are skipped.

  • Changing the base path returns 404s. After changing base_path via Drush rather than the admin form, rebuild the router with drush cr. Regenerate any sitemap and re-run any static export, as previously generated links point at the old path.

  • drush cr doesn't seem to clear the live site's cache (an old page keeps serving x-drupal-cache: HIT after a render-affecting change, even across a container restart). If you followed the null-backend tip above with the simpler if (PHP_SAPI === 'cli') scoping instead of the tome:static-only check, every CLI process - including drush cr itself - routes the page, dynamic_page_cache and render bins to a null backend. Clearing a null backend is a no-op, so drush cr never touches the real database-backed tables the web server reads. Scope the override to an actual tome:static invocation as shown above, or drush cr will silently stop working for those three bins.

Maintainers

  • Dan Kantic - dark-o is open to contract work
  • Kostia Bohach - _shy
  • Julia Kantic - peculiarjulia is open to tech writing contract work

Activity

Tracked releases
13
Tracked since
Jun 2026
Latest release
1 month ago
Releases (12 mo)
13 ▲ from 0
Maintenance
Active

Release Timeline

Releases

Version Type Release date
3.3.2 Stable Jul 16, 2026
3.3.1 Stable Jul 13, 2026
3.3.x-dev Dev Jul 13, 2026
3.3.0 Stable Jul 12, 2026
3.2.1 Stable Jul 10, 2026
3.2.0 Stable Jul 10, 2026
3.1.0 Stable Jul 4, 2026
3.0.0 Stable Jun 30, 2026
3.0.0-rc3 Pre-release Jun 27, 2026
3.0.0-rc2 Pre-release Jun 24, 2026
3.0.0-rc1 Pre-release Jun 24, 2026
3.0.0-alpha1 Pre-release Jun 23, 2026
3.0.x-dev Dev Jun 23, 2026