diff --git a/modules/project_browser_devel/src/Plugin/ProjectBrowserSource/RandomDataPlugin.php b/modules/project_browser_devel/src/Plugin/ProjectBrowserSource/RandomDataPlugin.php
index 82fb3614876a706e573999c65fc9218da1beb45b..82088a988c39b2eb72a7c9b2dd602e06692b88da 100644
--- a/modules/project_browser_devel/src/Plugin/ProjectBrowserSource/RandomDataPlugin.php
+++ b/modules/project_browser_devel/src/Plugin/ProjectBrowserSource/RandomDataPlugin.php
@@ -5,6 +5,8 @@ namespace Drupal\project_browser_devel\Plugin\ProjectBrowserSource;
 use Drupal\Component\Utility\Random;
 use Drupal\Core\Cache\CacheBackendInterface;
 use Drupal\project_browser\Plugin\ProjectBrowserSourceBase;
+use Drupal\project_browser\ProjectBrowser\Filter\BooleanFilter;
+use Drupal\project_browser\ProjectBrowser\Filter\MultipleChoiceFilter;
 use Drupal\project_browser\ProjectBrowser\Project;
 use Drupal\project_browser\ProjectBrowser\ProjectsResultsPage;
 use Symfony\Component\DependencyInjection\ContainerInterface;
@@ -114,6 +116,38 @@ class RandomDataPlugin extends ProjectBrowserSourceBase {
     return $categories;
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function getFilterDefinitions(): array {
+    $filters = [];
+
+    $categories = $this->getCategories();
+    $choices = array_combine(
+      array_column($categories, 'id'),
+      array_column($categories, 'name'),
+    );
+    $filters['categories'] = new MultipleChoiceFilter($choices, [], $this->t('Categories'), NULL);
+
+    $filters['maintained'] = new BooleanFilter(
+      TRUE,
+      $this->t('Only show actively maintained projects'),
+      NULL,
+    );
+    $filters['security_advisories'] = new BooleanFilter(
+      TRUE,
+      $this->t('Only show projects covered by a security policy'),
+      NULL,
+    );
+    $filters['developed'] = new BooleanFilter(
+      TRUE,
+      $this->t('Only show projects under active development'),
+      NULL,
+    );
+
+    return $filters;
+  }
+
   /**
    * {@inheritdoc}
    */
diff --git a/src/Element/ProjectBrowser.php b/src/Element/ProjectBrowser.php
index 2646cf1b050f19f6c8bf71599d3aad455e529502..78b822d8b6be395cbea991dab4a1612388122360 100644
--- a/src/Element/ProjectBrowser.php
+++ b/src/Element/ProjectBrowser.php
@@ -141,11 +141,11 @@ final class ProjectBrowser implements ElementInterface, ContainerFactoryPluginIn
       $active_plugins[$source->getPluginId()] = $source->getPluginDefinition()['label'];
     }
 
-    return [
-      'active_plugins' => $active_plugins,
+    $settings = [
+      'active_plugins' => [],
       'module_path' => $this->moduleHandler->getModule('project_browser')->getPath(),
-      'special_ids' => static::getSpecialIds(),
-      'sort_options' => $sort_options,
+      'special_ids' => $this->getSpecialIds(),
+      'sort_options' => [],
       'maintenance_options' => MaintenanceStatus::asOptions(),
       'security_options' => SecurityStatus::asOptions(),
       'development_options' => DevelopmentStatus::asOptions(),
@@ -153,6 +153,13 @@ final class ProjectBrowser implements ElementInterface, ContainerFactoryPluginIn
       'current_sources_keys' => $current_sources_keys,
       'package_manager' => $package_manager,
     ];
+
+    foreach ($current_sources as $plugin_id => $source) {
+      $settings['sort_options'][$plugin_id] = array_values($source->getSortOptions());
+      $settings['active_plugins'][$plugin_id] = $source->getPluginDefinition()['label'];
+      $settings['filters'][$plugin_id] = $source->getFilterDefinitions();
+    }
+    return $settings;
   }
 
   /**
diff --git a/src/Plugin/ProjectBrowserSource/DrupalDotOrgJsonApi.php b/src/Plugin/ProjectBrowserSource/DrupalDotOrgJsonApi.php
index 527329d4db0957f26604ee350a4a39117aef7414..2865b4bb85493e885308b72d9d04385b6424702a 100644
--- a/src/Plugin/ProjectBrowserSource/DrupalDotOrgJsonApi.php
+++ b/src/Plugin/ProjectBrowserSource/DrupalDotOrgJsonApi.php
@@ -9,6 +9,8 @@ use Drupal\Core\StringTranslation\StringTranslationTrait;
 use Drupal\project_browser\DevelopmentStatus;
 use Drupal\project_browser\MaintenanceStatus;
 use Drupal\project_browser\Plugin\ProjectBrowserSourceBase;
+use Drupal\project_browser\ProjectBrowser\Filter\BooleanFilter;
+use Drupal\project_browser\ProjectBrowser\Filter\MultipleChoiceFilter;
 use Drupal\project_browser\ProjectBrowser\Project;
 use Drupal\project_browser\ProjectBrowser\ProjectsResultsPage;
 use Drupal\project_browser\SecurityStatus;
@@ -248,37 +250,35 @@ class DrupalDotOrgJsonApi extends ProjectBrowserSourceBase {
   }
 
   /**
-   * Get a list of values for the "Development Status" vocabulary.
-   *
-   * @return array[]
-   *   List of terms (id and name) for the vocabulary.
+   * {@inheritdoc}
    */
-  protected function getDevelopmentStatuses(): array {
-    return $this->getVocabularyData('development_status');
-  }
+  public function getFilterDefinitions(): array {
+    $filters = [];
 
-  /**
-   * Get a list of values for the "Maintenance Status" vocabulary.
-   *
-   * @return array[]
-   *   List of terms (id and name) for the vocabulary.
-   */
-  protected function getMaintenanceStatuses(): array {
-    return $this->getVocabularyData('maintenance_status');
-  }
+    $categories = $this->getCategories();
+    $choices = array_combine(
+      array_column($categories, 'id'),
+      array_column($categories, 'name'),
+    );
+    $filters['categories'] = new MultipleChoiceFilter($choices, [], $this->t('Categories'), NULL);
 
-  /**
-   * Get a list of values for the "Security Coverage" vocabulary.
-   *
-   * @return array[]
-   *   List of terms (id and name) for the vocabulary.
-   */
-  protected function getSecurityCoverages(): array {
-    // There is an additional 'revoked' value, but we do NOT want those modules.
-    return [
-      ['id' => 'covered', 'name' => $this->t('Covered')],
-      ['id' => 'not-covered', 'name' => $this->t('Not covered')],
-    ];
+    $filters['maintained'] = new BooleanFilter(
+      TRUE,
+      $this->t('Only show actively maintained projects'),
+      NULL,
+    );
+    $filters['security_advisories'] = new BooleanFilter(
+      TRUE,
+      $this->t('Only show projects covered by a security policy'),
+      NULL,
+    );
+    $filters['developed'] = new BooleanFilter(
+      TRUE,
+      $this->t('Only show projects under active development'),
+      NULL,
+    );
+
+    return $filters;
   }
 
   /**
diff --git a/src/Plugin/ProjectBrowserSourceBase.php b/src/Plugin/ProjectBrowserSourceBase.php
index 6d41c41b35650cefd165310b241aa7475c419c88..d544e7cf288f86c08562583e2d21fd134553edb6 100644
--- a/src/Plugin/ProjectBrowserSourceBase.php
+++ b/src/Plugin/ProjectBrowserSourceBase.php
@@ -5,6 +5,7 @@ namespace Drupal\project_browser\Plugin;
 use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
 use Drupal\Core\Plugin\PluginBase;
 use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\project_browser\ProjectBrowser\Filter\MultipleChoiceFilter;
 use Drupal\project_browser\ProjectBrowser\ProjectsResultsPage;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 
@@ -30,6 +31,23 @@ abstract class ProjectBrowserSourceBase extends PluginBase implements ProjectBro
     );
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function getFilterDefinitions(): array {
+    $filters = [];
+
+    $categories = $this->getCategories();
+    if ($categories) {
+      $choices = array_combine(
+        array_column($categories, 'id'),
+        array_column($categories, 'name'),
+      );
+      $filters['categories'] = new MultipleChoiceFilter($choices, [], $this->t('Categories'), NULL);
+    }
+    return $filters;
+  }
+
   /**
    * Returns the available sort options that plugins will parse.
    *
diff --git a/src/Plugin/ProjectBrowserSourceInterface.php b/src/Plugin/ProjectBrowserSourceInterface.php
index c9cb3766afda7a85a7d1158cf290adb0e8d2f980..87cb55bac99e73f1ad282ef145ba54ed91b305a7 100644
--- a/src/Plugin/ProjectBrowserSourceInterface.php
+++ b/src/Plugin/ProjectBrowserSourceInterface.php
@@ -44,6 +44,15 @@ interface ProjectBrowserSourceInterface {
    */
   public function getCategories(): array;
 
+  /**
+   * Defines the filters that this source will respect.
+   *
+   * @return \Drupal\project_browser\ProjectBrowser\Filter\FilterBase[]
+   *   The filters that this source will respect when querying for projects,
+   *   keyed by machine name.
+   */
+  public function getFilterDefinitions(): array;
+
   /**
    * Returns the available sort options that plugins will parse.
    *
diff --git a/src/ProjectBrowser/Filter/BooleanFilter.php b/src/ProjectBrowser/Filter/BooleanFilter.php
new file mode 100644
index 0000000000000000000000000000000000000000..88738ef10bc7b5c78ea826e53780e1b49cf8dfab
--- /dev/null
+++ b/src/ProjectBrowser/Filter/BooleanFilter.php
@@ -0,0 +1,16 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\project_browser\ProjectBrowser\Filter;
+
+/**
+ * Defines a filter that can either be on, or off.
+ */
+final class BooleanFilter extends FilterBase {
+
+  public function __construct(public bool $value, mixed ...$arguments) {
+    parent::__construct(...$arguments);
+  }
+
+}
diff --git a/src/ProjectBrowser/Filter/FilterBase.php b/src/ProjectBrowser/Filter/FilterBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..a48129bdc272c6aa6dfe58246d67b3dddde9bee9
--- /dev/null
+++ b/src/ProjectBrowser/Filter/FilterBase.php
@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\project_browser\ProjectBrowser\Filter;
+
+/**
+ * A base class for all filters that can be defined by source plugins.
+ */
+abstract class FilterBase implements \JsonSerializable {
+
+  public function __construct(
+    public readonly string|\Stringable $name,
+    public readonly string|\Stringable|null $group,
+  ) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  final public function jsonSerialize(): array {
+    return [
+      '_type' => match (static::class) {
+        BooleanFilter::class => 'boolean',
+        MultipleChoiceFilter::class => 'multiple_choice',
+      },
+    ] + get_object_vars($this);
+  }
+
+}
diff --git a/src/ProjectBrowser/Filter/MultipleChoiceFilter.php b/src/ProjectBrowser/Filter/MultipleChoiceFilter.php
new file mode 100644
index 0000000000000000000000000000000000000000..50c01e9397c33ca8f88d0f90182a46277fcb8da4
--- /dev/null
+++ b/src/ProjectBrowser/Filter/MultipleChoiceFilter.php
@@ -0,0 +1,23 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\project_browser\ProjectBrowser\Filter;
+
+/**
+ * Defines a filter to choose any number of options from a list.
+ */
+final class MultipleChoiceFilter extends FilterBase {
+
+  public function __construct(
+    public readonly array $choices,
+    public readonly array $value,
+    mixed ...$arguments,
+  ) {
+    // Everything $value should be present in $choices.
+    assert(array_diff($value, array_keys($choices)) === []);
+
+    parent::__construct(...$arguments);
+  }
+
+}
diff --git a/sveltejs/public/build/bundle.js b/sveltejs/public/build/bundle.js
index 716ae1595f609b2bce6ade934e8a4e93a1e58c92..72b2e9d392a7047b50fb6ce67a8300ce721d3002 100644
Binary files a/sveltejs/public/build/bundle.js and b/sveltejs/public/build/bundle.js differ
diff --git a/sveltejs/public/build/bundle.js.map b/sveltejs/public/build/bundle.js.map
index 46a61313dd45984c8ad3945b14051e91c978c23a..c73c1212d862fbebbe51c2a4f74655ab390e8e39 100644
Binary files a/sveltejs/public/build/bundle.js.map and b/sveltejs/public/build/bundle.js.map differ
diff --git a/sveltejs/src/ProjectBrowser.svelte b/sveltejs/src/ProjectBrowser.svelte
index 648f03c4136392d4c2915ae273d6cb2b2dd550e1..ced684c64919976d35ebcf2aeea1ba032c4d5f09 100644
--- a/sveltejs/src/ProjectBrowser.svelte
+++ b/sveltejs/src/ProjectBrowser.svelte
@@ -7,6 +7,7 @@
   import Tabs from './Tabs.svelte';
   import { numberFormatter } from './util';
   import {
+    sourceFilters,
     filters,
     rowsCount,
     moduleCategoryFilter,
@@ -23,6 +24,7 @@
   } from './stores';
   import MediaQuery from './MediaQuery.svelte';
   import {
+    FILTERS,
     ACTIVELY_MAINTAINED_ID,
     COVERED_ID,
     ALL_VALUES_ID,
@@ -250,6 +252,9 @@
     $categoryCheckedTrack[$activeTab] = $moduleCategoryFilter;
     $moduleCategoryFilter = [];
     $activeTab = event.detail.pluginId;
+    if ($activeTab in FILTERS) {
+      $sourceFilters = FILTERS[$activeTab];
+    }
     $moduleCategoryFilter =
       typeof $categoryCheckedTrack[$activeTab] !== 'undefined'
         ? $categoryCheckedTrack[$activeTab]
@@ -260,7 +265,6 @@
     if (typeof sortMatch === 'undefined') {
       $sort = $sortCriteria[0].id;
     }
-
     // Move to page 0 when switching sources as there's no guarantee the new
     // source has enough results to reach whatever the current page is.
     page.set(0);
diff --git a/sveltejs/src/Search/Search.svelte b/sveltejs/src/Search/Search.svelte
index 9f846b1c26f7d076c6a1143a578319791438fd3a..227bd434eefe6baef8cc5ab0b136d5b1b4b956e2 100644
--- a/sveltejs/src/Search/Search.svelte
+++ b/sveltejs/src/Search/Search.svelte
@@ -6,6 +6,7 @@
   import { Filter } from '../ProjectGrid.svelte';
   import SearchSort from './SearchSort.svelte';
   import {
+    sourceFilters,
     filters,
     filtersVocabularies,
     moduleCategoryFilter,
@@ -207,96 +208,113 @@
       />
     </div>
   </div>
-  <div class="search__form-filters-container">
-    <div class="search__form-filters">
-      <Filter
-        on:selectCategory={onSelectCategory}
-        bind:this={filterComponent}
-      />
-      <FilterGroup
-        filterTitle={Drupal.t('Security Advisory Coverage')}
-        filterData={SECURITY_OPTIONS}
-        filterType="securityCoverage"
-        changeHandler={onAdvancedFilter}
-        let:id
-        let:label
-      />
-      <FilterGroup
-        filterTitle={Drupal.t('Maintenance Status')}
-        filterData={MAINTENANCE_OPTIONS}
-        filterType="maintenanceStatus"
-        changeHandler={onAdvancedFilter}
-        let:id
-        let:label
-      >
-        <label
-          slot="label"
-          class="search__checkbox-label"
-          for={`maintenanceStatus${id}`}
-        >
-          {label}
-        </label>
-      </FilterGroup>
-      <FilterGroup
-        filterTitle={Drupal.t('Development Status')}
-        filterData={DEVELOPMENT_OPTIONS}
-        filterType="developmentStatus"
-        changeHandler={onAdvancedFilter}
-        let:id
-        let:label
+  {#if $sourceFilters.length !== 0}
+    <div class="search__form-filters-container">
+      <div class="search__form-filters">
+        {#if 'categories' in $sourceFilters}
+          <Filter
+            on:selectCategory={onSelectCategory}
+            bind:this={filterComponent}
+          />
+        {/if}
+        {#if 'security_advisories' in $sourceFilters}
+          <FilterGroup
+            filterTitle={Drupal.t('Security Advisory Coverage')}
+            filterData={SECURITY_OPTIONS}
+            filterType="securityCoverage"
+            changeHandler={onAdvancedFilter}
+            let:id
+            let:label
+          />
+        {/if}
+        {#if 'maintained' in $sourceFilters}
+          <FilterGroup
+            filterTitle={Drupal.t('Maintenance Status')}
+            filterData={MAINTENANCE_OPTIONS}
+            filterType="maintenanceStatus"
+            changeHandler={onAdvancedFilter}
+            let:id
+            let:label
+          >
+            <label
+              slot="label"
+              class="search__checkbox-label"
+              for={`maintenanceStatus${id}`}
+            >
+              {label}
+            </label>
+          </FilterGroup>
+        {/if}
+        {#if 'developed' in $sourceFilters}
+          <FilterGroup
+            filterTitle={Drupal.t('Development Status')}
+            filterData={DEVELOPMENT_OPTIONS}
+            filterType="developmentStatus"
+            changeHandler={onAdvancedFilter}
+            let:id
+            let:label
+          >
+            <label
+              slot="label"
+              class="search__checkbox-label"
+              for={`developmentStatus${id}`}
+            >
+              {label}
+            </label>
+          </FilterGroup>
+        {/if}
+      </div>
+      <div
+        class="search__form-sort js-form-item js-form-type-select form-type--select js-form-item-type form-item--type"
       >
-        <label
-          slot="label"
-          class="search__checkbox-label"
-          for={`developmentStatus${id}`}
+        <section
+          class="search__filters"
+          aria-label={Drupal.t('Search results')}
         >
-          {label}
-        </label>
-      </FilterGroup>
-    </div>
-    <div
-      class="search__form-sort js-form-item js-form-type-select form-type--select js-form-item-type form-item--type"
-    >
-      <section class="search__filters" aria-label={Drupal.t('Search results')}>
-        <div class="search__results-count">
-          {#each $moduleCategoryFilter as category}
-            <FilterApplied
-              id={category}
-              label={$moduleCategoryVocabularies[category]}
-              clickHandler={() => {
-                $moduleCategoryFilter.splice(
-                  $moduleCategoryFilter.indexOf(category),
-                  1,
-                );
-                $moduleCategoryFilter = $moduleCategoryFilter;
-                onSelectCategory();
-              }}
-            />
-          {/each}
+          <div class="search__results-count">
+            {#each $moduleCategoryFilter as category}
+              <FilterApplied
+                id={category}
+                label={$moduleCategoryVocabularies[category]}
+                clickHandler={() => {
+                  $moduleCategoryFilter.splice(
+                    $moduleCategoryFilter.indexOf(category),
+                    1,
+                  );
+                  $moduleCategoryFilter = $moduleCategoryFilter;
+                  onSelectCategory();
+                }}
+              />
+            {/each}
 
-          {#if $filters.securityCoverage !== ALL_VALUES_ID || $filters.maintenanceStatus !== ALL_VALUES_ID || $filters.developmentStatus !== ALL_VALUES_ID || $moduleCategoryFilter.length}
-            <button
-              class="search__filter-button"
-              type="button"
-              on:click|preventDefault={() =>
-                filterResets(ALL_VALUES_ID, ALL_VALUES_ID, ALL_VALUES_ID)}
-            >
-              {Drupal.t('Clear filters')}
-            </button>
-          {/if}
-          {#if !($filters.maintenanceStatus === ACTIVELY_MAINTAINED_ID && $filters.securityCoverage === COVERED_ID && $filters.developmentStatus === ALL_VALUES_ID && $moduleCategoryFilter.length === 0)}
-            <button
-              class="search__filter-button"
-              type="button"
-              on:click|preventDefault={() =>
-                filterResets(ACTIVELY_MAINTAINED_ID, ALL_VALUES_ID, COVERED_ID)}
-            >
-              {Drupal.t('Recommended filters')}
-            </button>
-          {/if}
-        </div>
-      </section>
-      <SearchSort on:sort bind:sortText refresh={refreshLiveRegion} />
+            {#if $filters.securityCoverage !== ALL_VALUES_ID || $filters.maintenanceStatus !== ALL_VALUES_ID || $filters.developmentStatus !== ALL_VALUES_ID || $moduleCategoryFilter.length}
+              <button
+                class="search__filter-button"
+                type="button"
+                on:click|preventDefault={() =>
+                  filterResets(ALL_VALUES_ID, ALL_VALUES_ID, ALL_VALUES_ID)}
+              >
+                {Drupal.t('Clear filters')}
+              </button>
+            {/if}
+            {#if !($filters.maintenanceStatus === ACTIVELY_MAINTAINED_ID && $filters.securityCoverage === COVERED_ID && $filters.developmentStatus === ALL_VALUES_ID && $moduleCategoryFilter.length === 0)}
+              <button
+                class="search__filter-button"
+                type="button"
+                on:click|preventDefault={() =>
+                  filterResets(
+                    ACTIVELY_MAINTAINED_ID,
+                    ALL_VALUES_ID,
+                    COVERED_ID,
+                  )}
+              >
+                {Drupal.t('Recommended filters')}
+              </button>
+            {/if}
+          </div>
+        </section>
+        <SearchSort on:sort bind:sortText refresh={refreshLiveRegion} />
+      </div>
     </div>
-  </div>
+  {/if}
 </form>
diff --git a/sveltejs/src/constants.js b/sveltejs/src/constants.js
index c70fba462c5f55e49efaf2baeb8f9759d9e041a3..3f5ede0781e97954a5f416d314973b0edf084f2b 100644
--- a/sveltejs/src/constants.js
+++ b/sveltejs/src/constants.js
@@ -21,3 +21,4 @@ export const DARK_COLOR_SCHEME =
   matchMedia('(prefers-color-scheme: dark)').matches;
 export const ACTIVE_PLUGINS = drupalSettings.project_browser.active_plugins;
 export const PACKAGE_MANAGER = drupalSettings.project_browser.package_manager;
+export const FILTERS = drupalSettings.project_browser.filters || {};
diff --git a/sveltejs/src/stores.js b/sveltejs/src/stores.js
index 46858cd0195f440367740bfd1ac9c27dc0aecf6f..d02dc0f539ffe894d456d447af4d29ff50d318ed 100644
--- a/sveltejs/src/stores.js
+++ b/sveltejs/src/stores.js
@@ -2,9 +2,21 @@
 import { writable } from 'svelte/store';
 
 import {
-  DEFAULT_SOURCE_ID, SORT_OPTIONS,
+  DEFAULT_SOURCE_ID, SORT_OPTIONS, FILTERS,
 } from './constants';
 
+// Store the selected tab.
+const storedActiveTab = JSON.parse(sessionStorage.getItem('activeTab')) || DEFAULT_SOURCE_ID;
+let activeFilters = {};
+if (sessionStorage.getItem('sourceFilters')){
+  activeFilters = JSON.parse(sessionStorage.getItem('sourceFilters'));
+}
+else if (storedActiveTab in FILTERS) {
+  activeFilters = FILTERS[storedActiveTab];
+}
+export const sourceFilters = writable(activeFilters);
+sourceFilters.subscribe((val) => sessionStorage.setItem('sourceFilters', JSON.stringify(val)));
+
 // Store for applied advanced filters.
 const storedFilters = JSON.parse(sessionStorage.getItem('advancedFilter')) || {
   developmentStatus: '',
@@ -42,8 +54,6 @@ const storedPage = JSON.parse(sessionStorage.getItem('page')) || 0;
 export const page = writable(storedPage);
 page.subscribe((val) => sessionStorage.setItem('page', JSON.stringify(val)));
 
-// Store the selected tab.
-const storedActiveTab = JSON.parse(sessionStorage.getItem('activeTab')) || DEFAULT_SOURCE_ID;
 export const activeTab = writable(storedActiveTab);
 activeTab.subscribe((val) => sessionStorage.setItem('activeTab', JSON.stringify(val)));
 
diff --git a/tests/modules/project_browser_test/src/Plugin/ProjectBrowserSource/ProjectBrowserTestMock.php b/tests/modules/project_browser_test/src/Plugin/ProjectBrowserSource/ProjectBrowserTestMock.php
index bdf1b6716348b86b1c6276b3c13f4dabe77beebb..712022a82740e3b62a7f52fb51ced20c390d4dba 100644
--- a/tests/modules/project_browser_test/src/Plugin/ProjectBrowserSource/ProjectBrowserTestMock.php
+++ b/tests/modules/project_browser_test/src/Plugin/ProjectBrowserSource/ProjectBrowserTestMock.php
@@ -11,6 +11,8 @@ use Drupal\Core\State\StateInterface;
 use Drupal\project_browser\DevelopmentStatus;
 use Drupal\project_browser\MaintenanceStatus;
 use Drupal\project_browser\Plugin\ProjectBrowserSourceBase;
+use Drupal\project_browser\ProjectBrowser\Filter\BooleanFilter;
+use Drupal\project_browser\ProjectBrowser\Filter\MultipleChoiceFilter;
 use Drupal\project_browser\ProjectBrowser\Project;
 use Drupal\project_browser\ProjectBrowser\ProjectsResultsPage;
 use Drupal\project_browser\SecurityStatus;
@@ -333,6 +335,48 @@ class ProjectBrowserTestMock extends ProjectBrowserSourceBase {
     return $categories;
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function getFilterDefinitions(): array {
+    $filters = [];
+
+    $categories = $this->getCategories();
+    $choices = array_combine(
+      array_column($categories, 'id'),
+      array_column($categories, 'name'),
+    );
+    $filters['categories'] = new MultipleChoiceFilter($choices, [], $this->t('Categories'), NULL);
+
+    $filters['maintained'] = new BooleanFilter(
+      TRUE,
+      $this->t('Only show actively maintained projects'),
+      NULL,
+    );
+    $filters['security_advisories'] = new BooleanFilter(
+      TRUE,
+      $this->t('Only show projects covered by a security policy'),
+      NULL,
+    );
+    $filters['developed'] = new BooleanFilter(
+      TRUE,
+      $this->t('Only show projects under active development'),
+      NULL,
+    );
+
+    $filters_to_define = $this->state->get('filters_to_define');
+    if ($filters_to_define !== NULL) {
+      // Only keep those filters which needs to be defined according to
+      // $filters_to_define.
+      foreach ($filters as $filter_key => $filter_value) {
+        if (!in_array($filter_key, $filters_to_define, TRUE)) {
+          unset($filters[$filter_key]);
+        }
+      }
+    }
+    return $filters;
+  }
+
   /**
    * {@inheritdoc}
    */
diff --git a/tests/src/FunctionalJavascript/ProjectBrowserUiTestJsonApi.php b/tests/src/FunctionalJavascript/ProjectBrowserUiTestJsonApi.php
index 97371ef5570e97b13a80509d77438149e923954d..04b2cc4228111401de9ed7c394670b74f1bb7c53 100644
--- a/tests/src/FunctionalJavascript/ProjectBrowserUiTestJsonApi.php
+++ b/tests/src/FunctionalJavascript/ProjectBrowserUiTestJsonApi.php
@@ -446,6 +446,50 @@ class ProjectBrowserUiTestJsonApi extends WebDriverTestBase {
     $this->assertEquals($results_before, $results_after);
   }
 
+  /**
+   * Tests filters are displayed if they are defined by source.
+   */
+  public function testFiltersShownIfDefinedBySource(): void {
+    if (version_compare(\Drupal::VERSION, '10.3', '<')) {
+      $this->markTestSkipped('This test requires Drupal 10.3 or later.');
+    }
+    $assert_session = $this->assertSession();
+    $page = $this->getSession()->getPage();
+    // Enable module for extra source plugin.
+    $this->container->get('module_installer')->install(['project_browser_devel']);
+    $this->config('project_browser.admin_settings')
+      ->set('enabled_sources', ['recipes', 'project_browser_test_mock'])
+      ->save();
+
+    $this->drupalGet('admin/modules/browse');
+    $this->assertTrue($assert_session->waitForText('Recipes'));
+    $page->pressButton('Recipes');
+    // Recipes doesn't define any filters so no filters are displayed.
+    $this->assertNull($assert_session->waitForElementVisible('css', '.search__form-filters-container'));
+
+    // Set the names of filters which will be defined by the test mock.
+    // @see \Drupal\project_browser_test\Plugin\ProjectBrowserSource\ProjectBrowserTestMock::getFilterDefinitions()
+    $filters_to_define = ['maintained', 'security_advisories'];
+    \Drupal::state()->set('filters_to_define', $filters_to_define);
+
+    $this->drupalGet('admin/modules/browse');
+    $this->assertTrue($assert_session->waitForText('Project Browser Mock Plugin'));
+    $page->pressButton('Project Browser Mock Plugin');
+    // Drupal.org test mock defines only two filters (actively maintained filter
+    // and security coverage filter).
+    $assert_session->waitForElementVisible('css', '.search__form-filters-container');
+    $this->assertTrue($assert_session->waitForText('Maintenance Status'));
+    $assert_session->waitForElementVisible('css', self::MAINTENANCE_OPTION_SELECTOR);
+    $this->assertTrue($assert_session->waitForText('Security Advisory Coverage'));
+    $assert_session->waitForElementVisible('css', self::SECURITY_OPTION_SELECTOR);
+    // Make sure no other filters are displayed.
+    $this->assertFalse($assert_session->waitForText('Development Status'));
+    $this->assertNull($assert_session->waitForElementVisible('css', self::DEVELOPMENT_OPTION_SELECTOR));
+    $this->assertFalse($assert_session->waitForText('Filter by category'));
+    // Make sure category filter element is not visible.
+    $this->assertNull($assert_session->waitForElementVisible('css', 'div.search__form-filters-container > div.search__form-filters > section > fieldset > div'));
+  }
+
   /**
    * Tests the view mode toggle keeps its state.
    */