diff --git a/modules/project_browser_devel/src/Plugin/ProjectBrowserSource/RandomDataPlugin.php b/modules/project_browser_devel/src/Plugin/ProjectBrowserSource/RandomDataPlugin.php
index 2ca6469923dbf063003eb13ad7c9a1099d2165fc..3a29c7fb5e153bed92e33db361c07c0b0cf6a7bd 100644
--- a/modules/project_browser_devel/src/Plugin/ProjectBrowserSource/RandomDataPlugin.php
+++ b/modules/project_browser_devel/src/Plugin/ProjectBrowserSource/RandomDataPlugin.php
@@ -9,7 +9,6 @@ use Drupal\Core\Url;
 use Drupal\project_browser\Attribute\ProjectBrowserSource;
 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;
@@ -119,14 +118,7 @@ final class RandomDataPlugin extends ProjectBrowserSourceBase {
    * {@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 = parent::getFilterDefinitions();
 
     $filters['security_advisory_coverage'] = new BooleanFilter(
       TRUE,
diff --git a/src/Plugin/DrupalDotOrgSourceBase.php b/src/Plugin/DrupalDotOrgSourceBase.php
index 74a362459e5d4ae58fa0a5a6ace6cecde3a8d496..359ec51dc7cbcd08237fb3011ce977140e4f6839 100644
--- a/src/Plugin/DrupalDotOrgSourceBase.php
+++ b/src/Plugin/DrupalDotOrgSourceBase.php
@@ -13,7 +13,6 @@ use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
 use Drupal\Core\StringTranslation\StringTranslationTrait;
 use Drupal\Core\Url;
 use Drupal\project_browser\ProjectBrowser\Filter\BooleanFilter;
-use Drupal\project_browser\ProjectBrowser\Filter\MultipleChoiceFilter;
 use Drupal\project_browser\ProjectBrowser\Project;
 use GuzzleHttp\ClientInterface;
 use Psr\Log\LoggerInterface;
@@ -209,14 +208,7 @@ abstract class DrupalDotOrgSourceBase extends ProjectBrowserSourceBase implement
    * {@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 = parent::getFilterDefinitions();
 
     $filters['security_advisory_coverage'] = new BooleanFilter(
       TRUE,
diff --git a/src/Plugin/ProjectBrowserSource/Recipes.php b/src/Plugin/ProjectBrowserSource/Recipes.php
index f8283a8a3c561209407a58e1edf71c7b6f9ac327..1c0819bb9534adc5aafce7b6e021e903a6ea3736 100644
--- a/src/Plugin/ProjectBrowserSource/Recipes.php
+++ b/src/Plugin/ProjectBrowserSource/Recipes.php
@@ -17,6 +17,7 @@ use Drupal\Core\StringTranslation\TranslatableMarkup;
 use Drupal\Core\Url;
 use Drupal\project_browser\Attribute\ProjectBrowserSource;
 use Drupal\project_browser\Plugin\ProjectBrowserSourceBase;
+use Drupal\project_browser\ProjectBrowser\Filter\TextFilter;
 use Drupal\project_browser\ProjectBrowser\Project;
 use Drupal\project_browser\ProjectBrowser\ProjectsResultsPage;
 use Drupal\project_browser\ProjectType;
@@ -62,6 +63,15 @@ final class Recipes extends ProjectBrowserSourceBase {
     );
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function getFilterDefinitions(): array {
+    return [
+      'search' => new TextFilter('', $this->t('Search'), NULL),
+    ];
+  }
+
   /**
    * {@inheritdoc}
    */
diff --git a/src/Plugin/ProjectBrowserSourceBase.php b/src/Plugin/ProjectBrowserSourceBase.php
index 92a9d3b6186617697933859c835a38d71ed5f8b8..c51c6c1fd8b780307192316bf21ff20c9523cebf 100644
--- a/src/Plugin/ProjectBrowserSourceBase.php
+++ b/src/Plugin/ProjectBrowserSourceBase.php
@@ -6,6 +6,7 @@ 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\Filter\TextFilter;
 use Drupal\project_browser\ProjectBrowser\ProjectsResultsPage;
 
 /**
@@ -23,7 +24,9 @@ abstract class ProjectBrowserSourceBase extends PluginBase implements ProjectBro
    * {@inheritdoc}
    */
   public function getFilterDefinitions(): array {
-    $filters = [];
+    $filters = [
+      'search' => new TextFilter('', $this->t('Search'), NULL),
+    ];
 
     $categories = $this->getCategories();
     if (is_array($categories)) {
diff --git a/src/ProjectBrowser/Filter/FilterBase.php b/src/ProjectBrowser/Filter/FilterBase.php
index a84a7ede9ca5397e1369ee463dfb513291411f85..c5a81b4f33fe26ae0092cdd0cc477caf57400533 100644
--- a/src/ProjectBrowser/Filter/FilterBase.php
+++ b/src/ProjectBrowser/Filter/FilterBase.php
@@ -22,7 +22,8 @@ abstract class FilterBase implements \JsonSerializable {
       '_type' => match (static::class) {
         BooleanFilter::class => 'boolean',
         MultipleChoiceFilter::class => 'multiple_choice',
-        default => throw new \UnhandledMatchError('Unexpected class ' . static::class),
+        TextFilter::class => 'text',
+        default => throw new \UnhandledMatchError("Unknown filter type."),
       },
     ] + get_object_vars($this);
 
diff --git a/src/ProjectBrowser/Filter/TextFilter.php b/src/ProjectBrowser/Filter/TextFilter.php
new file mode 100644
index 0000000000000000000000000000000000000000..77b8827954a01ba81a19b82321dcdec581578d8a
--- /dev/null
+++ b/src/ProjectBrowser/Filter/TextFilter.php
@@ -0,0 +1,16 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\project_browser\ProjectBrowser\Filter;
+
+/**
+ * Defines a filter that matches some text.
+ */
+final class TextFilter extends FilterBase {
+
+  public function __construct(public string $value, mixed ...$arguments) {
+    parent::__construct(...$arguments);
+  }
+
+}
diff --git a/sveltejs/public/build/bundle.js b/sveltejs/public/build/bundle.js
index 1f08f0e940735a257cf0e693dc30fba071e420a7..6ab303721ff78d20ecbe605d0f4438ff3090dafb 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 a55a93570d0b0daefc94f1720feb89420cbc00b6..bc7ad9184da97ca23d5fe3bd230bd982a766b9bb 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 46ff0b192918f5a45f7281f3dc6d8ac552d90bfd..77f62ce984900b3df81b8992e528bbc91c62c656 100644
--- a/sveltejs/src/ProjectBrowser.svelte
+++ b/sveltejs/src/ProjectBrowser.svelte
@@ -145,10 +145,6 @@
     await load();
   }
 
-  async function onSearch(event) {
-    await onFilterChange(event);
-  }
-
   async function onToggle(val) {
     if (val !== toggleView) toggleView = val;
     preferredView.set(val);
@@ -198,7 +194,7 @@
     }
   });
 
-  window.onload = { onSearch };
+  window.onload = { onFilterChange };
   // Removes initial loader if it exists.
   const initialLoader = document.getElementById('initial-loader');
   if (initialLoader) {
@@ -211,7 +207,6 @@
     <div slot="head">
       <Search
         bind:this={searchComponent}
-        on:search={onSearch}
         on:sort={onSort}
         on:FilterChange={onFilterChange}
         {refreshLiveRegion}
diff --git a/sveltejs/src/Search/Search.svelte b/sveltejs/src/Search/Search.svelte
index e3084894223763878be6a548bb3012cf993e74f4..d233e05addec117b04e8ac86908c3c714cc7183a 100644
--- a/sveltejs/src/Search/Search.svelte
+++ b/sveltejs/src/Search/Search.svelte
@@ -4,7 +4,7 @@
   import BooleanFilter from './BooleanFilter.svelte';
   import MultipleChoiceFilter from '../MultipleChoiceFilter.svelte';
   import SearchSort from './SearchSort.svelte';
-  import { FULL_MODULE_PATH, DARK_COLOR_SCHEME } from '../constants';
+  import TextFilter from './TextFilter.svelte';
 
   const { Drupal } = window;
   const dispatch = createEventDispatcher();
@@ -12,12 +12,6 @@
   const filters = getContext('filters');
 
   export let refreshLiveRegion;
-  export const filter = (row, text) =>
-    Object.values(row).filter(
-      (item) =>
-        item && item.toString().toLowerCase().indexOf(text.toLowerCase()) > 1,
-    ).length > 0;
-  export let index = -1;
 
   export let filterDefinitions;
   export let sorts;
@@ -29,14 +23,6 @@
   let sortText = sorts[$sort];
   let filterComponent;
 
-  export async function onSearch() {
-    dispatch('search', {
-      filter,
-      filters: $filters,
-      index,
-    });
-    refreshLiveRegion();
-  }
   function onFilterChange(event) {
     // This function might have been called directly when clearing or resetting
     // the filters, so we can't rely on the presence of an event.
@@ -62,29 +48,20 @@
     refreshLiveRegion();
   }
 
-  function clearText() {
-    $filters.search = '';
-    onSearch();
-    document.getElementById('pb-text').focus();
-  }
-
   /**
    * Sets all filters to a falsy value.
-   *
-   * After this is called, hasFilterValues() will return false.
    */
   function clearFilters() {
-    $filters.search = '';
     Object.entries(filterDefinitions).forEach(([name, definition]) => {
       const { _type } = definition;
 
-      if (_type === 'boolean') {
-        $filters[name] = false;
-      } else if (_type === 'multiple_choice') {
-        $filters[name] = [];
-      } else {
-        $filters[name] = null;
-      }
+      const falsyValuesByType = {
+        boolean: false,
+        multiple_choice: [],
+        text: '',
+      };
+      $filters[name] =
+        _type in falsyValuesByType ? falsyValuesByType[_type] : null;
     });
     onFilterChange();
   }
@@ -102,65 +79,8 @@
   }
 </script>
 
-<form class="search__form-container">
-  <div
-    class="search__bar-container search__form-item js-form-item form-item js-form-type-textfield form-type--textfield"
-    role="search"
-  >
-    <label for="pb-text" class="form-item__label">{Drupal.t('Search')}</label>
-    <div class="search__search-bar">
-      <input
-        class="search__search_term form-text form-element form-element--type-text"
-        type="search"
-        id="pb-text"
-        name="text"
-        bind:value={$filters.search}
-        on:keydown={(e) => {
-          if (e.key === 'Enter') {
-            e.preventDefault();
-            onSearch(e);
-          }
-          if (e.key === 'Escape') {
-            e.preventDefault();
-            clearText();
-          }
-        }}
-      />
-      {#if $filters.search}
-        <button
-          class="search__search-clear"
-          id="clear-text"
-          type="button"
-          on:click={clearText}
-          aria-label={Drupal.t('Clear search text')}
-          tabindex="-1"
-        >
-          <img
-            src="{FULL_MODULE_PATH}/images/cross{DARK_COLOR_SCHEME
-              ? '--dark-color-scheme'
-              : ''}.svg"
-            alt=""
-          />
-        </button>
-      {/if}
-      <button
-        class="search__search-submit"
-        type="button"
-        on:click={onSearch}
-        aria-label={Drupal.t('Search')}
-      >
-        <img
-          class="search__search-icon"
-          id="search-icon"
-          src="{FULL_MODULE_PATH}/images/search-icon{DARK_COLOR_SCHEME
-            ? '--dark-color-scheme'
-            : ''}.svg"
-          alt=""
-        />
-      </button>
-    </div>
-  </div>
-  {#if Object.keys(filterDefinitions).length !== 0}
+{#if Object.keys(filterDefinitions).length !== 0}
+  <form class="search__form-container">
     <div class="search__form-filters-container">
       <div class="search__form-filters">
         {#each Object.entries(filterDefinitions) as [name, filter]}
@@ -178,6 +98,12 @@
               on:FilterChange={onFilterChange}
               bind:this={filterComponent}
             />
+          {:else if filter._type === 'text'}
+            <TextFilter
+              {name}
+              refresh={refreshLiveRegion}
+              on:FilterChange={onFilterChange}
+            />
           {/if}
         {/each}
       </div>
@@ -224,5 +150,5 @@
         <SearchSort on:sort bind:sortText refresh={refreshLiveRegion} {sorts} />
       </div>
     </div>
-  {/if}
-</form>
+  </form>
+{/if}
diff --git a/sveltejs/src/Search/TextFilter.svelte b/sveltejs/src/Search/TextFilter.svelte
new file mode 100644
index 0000000000000000000000000000000000000000..9ed9ee52f2e111c98d920e847706223ed40953a2
--- /dev/null
+++ b/sveltejs/src/Search/TextFilter.svelte
@@ -0,0 +1,80 @@
+<script>
+  import { createEventDispatcher, getContext } from 'svelte';
+  import { DARK_COLOR_SCHEME, FULL_MODULE_PATH } from '../constants';
+
+  const { Drupal } = window;
+  const filters = getContext('filters');
+  const dispatch = createEventDispatcher();
+  export let name;
+  export let refresh;
+  function onClick() {
+    dispatch('FilterChange', {
+      filters: $filters,
+    });
+    refresh();
+  }
+
+  function clearText() {
+    $filters.search = '';
+    onClick();
+    document.getElementById('pb-text').focus();
+  }
+</script>
+
+<div
+  class="search__bar-container search__form-item js-form-item form-item js-form-type-textfield form-type--textfield"
+  role="search"
+>
+  <label for="pb-text" class="form-item__label">{Drupal.t('Search')}</label>
+  <div class="search__search-bar">
+    <input
+      class="search__search_term form-text form-element form-element--type-text"
+      type="search"
+      id="pb-text"
+      {name}
+      bind:value={$filters[name]}
+      on:keydown={(e) => {
+        if (e.key === 'Enter') {
+          e.preventDefault();
+          onClick();
+        }
+        if (e.key === 'Escape') {
+          e.preventDefault();
+          clearText();
+        }
+      }}
+    />
+    {#if $filters[name]}
+      <button
+        class="search__search-clear"
+        id="clear-text"
+        type="button"
+        on:click={clearText}
+        aria-label={Drupal.t('Clear search text')}
+        tabindex="-1"
+      >
+        <img
+          src="{FULL_MODULE_PATH}/images/cross{DARK_COLOR_SCHEME
+            ? '--dark-color-scheme'
+            : ''}.svg"
+          alt=""
+        />
+      </button>
+    {/if}
+    <button
+      class="search__search-submit"
+      type="button"
+      on:click={onClick}
+      aria-label={Drupal.t('Search')}
+    >
+      <img
+        class="search__search-icon"
+        id="search-icon"
+        src="{FULL_MODULE_PATH}/images/search-icon{DARK_COLOR_SCHEME
+          ? '--dark-color-scheme'
+          : ''}.svg"
+        alt=""
+      />
+    </button>
+  </div>
+</div>
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 72a77815f651a38b256679d8b02e08601bd1009d..7e167c9dde957db8fdabdc1de8f4f87a49ebbd30 100644
--- a/tests/modules/project_browser_test/src/Plugin/ProjectBrowserSource/ProjectBrowserTestMock.php
+++ b/tests/modules/project_browser_test/src/Plugin/ProjectBrowserSource/ProjectBrowserTestMock.php
@@ -14,7 +14,6 @@ use Drupal\Core\Url;
 use Drupal\project_browser\Attribute\ProjectBrowserSource;
 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 Psr\Log\LoggerInterface;
@@ -262,14 +261,7 @@ final class ProjectBrowserTestMock extends ProjectBrowserSourceBase {
    * {@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 = parent::getFilterDefinitions();
 
     $filters['security_advisory_coverage'] = new BooleanFilter(
       TRUE,
diff --git a/tests/src/FunctionalJavascript/ProjectBrowserUiTestJsonApi.php b/tests/src/FunctionalJavascript/ProjectBrowserUiTestJsonApi.php
index 8ecae7289e0c8cf20e58c4bded3bda2ae2ff075e..a953c0442dd9cb2f5a97689fa7a5b4915e3c0ba6 100644
--- a/tests/src/FunctionalJavascript/ProjectBrowserUiTestJsonApi.php
+++ b/tests/src/FunctionalJavascript/ProjectBrowserUiTestJsonApi.php
@@ -337,19 +337,21 @@ class ProjectBrowserUiTestJsonApi extends WebDriverTestBase {
     // 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'])
+      ->set('enabled_sources', ['project_browser_test_mock'])
       ->save();
 
-    $this->drupalGet('admin/modules/browse/recipes');
-    // Recipes doesn't define any filters so no filters are displayed.
+    // Make the mock source show no filters, and ensure that we never see any.
+    \Drupal::state()->set('filters_to_define', []);
+    $this->drupalGet('admin/modules/browse/project_browser_test_mock');
     $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 = ['maintenance_status', 'security_advisory_coverage'];
-    \Drupal::state()->set('filters_to_define', $filters_to_define);
-
-    $this->drupalGet('admin/modules/browse/project_browser_test_mock');
+    \Drupal::state()->set('filters_to_define', [
+      'maintenance_status',
+      'security_advisory_coverage',
+    ]);
+    $this->getSession()->reload();
     // Drupal.org test mock defines only two filters (actively maintained filter
     // and security coverage filter).
     $this->assertElementIsVisible('css', '.search__form-filters-container');