diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index f218a8774a1f8a3a79df086a0d05d70028dc3485..52eb20a850166478498f7399f36de3ca1a6ac5d3 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -62,6 +62,8 @@ default:
     URL: 'git@git.drupal.org:project/drupal_cms_page.git'
   - DIR: recipes/drupal_cms_privacy_basic
     URL: 'git@git.drupal.org:project/drupal_cms_privacy_basic.git'
+  - DIR: recipes/drupal_cms_search
+    URL: 'git@git.drupal.org:project/drupal_cms_search.git'
   - DIR: recipes/drupal_cms_seo_basic
     URL: 'git@git.drupal.org:project/drupal_cms_seo_basic.git'
   - DIR: recipes/drupal_cms_seo_tools
diff --git a/components.composer.json b/components.composer.json
index d719d09b09c2c85acc096d3f56f3ab5a37781cd2..a2ac5605293cc2c80a1e9ae232e03f51523b7267 100644
--- a/components.composer.json
+++ b/components.composer.json
@@ -64,6 +64,10 @@
             "type": "path",
             "url": "recipes/drupal_cms_privacy_basic"
         },
+        "search": {
+            "type": "path",
+            "url": "recipes/drupal_cms_search"
+        },
         "seo_basic": {
             "type": "path",
             "url": "recipes/drupal_cms_seo_basic"
diff --git a/recipes/drupal_cms_search/composer.json b/recipes/drupal_cms_search/composer.json
new file mode 100644
index 0000000000000000000000000000000000000000..054bdebdc86aaf98dfdbd72cb833b7406ee89db1
--- /dev/null
+++ b/recipes/drupal_cms_search/composer.json
@@ -0,0 +1,23 @@
+{
+    "name": "drupal/drupal_cms_search",
+    "type": "drupal-recipe",
+    "description": "Provides a simple database-powered search, with autocomplete support.",
+    "require": {
+        "drupal/core": ">=10.4",
+        "drupal/search_api": "1.35",
+        "drupal/search_api_autocomplete": "^1.9",
+        "drupal/search_api_exclude": "^2.0",
+        "drupal/simple_search_form" : "^1.5"
+    },
+    "extra": {
+        "patches": {
+            "drupal/search_api": {
+                "#3483366: Allow to set the same view mode for all entity bundles in Search API plugins": "https://www.drupal.org/files/issues/2024-11-15/3483366-global-view-modes_0.patch"
+            },
+            "drupal/core": {
+                "#3483353: EntityDisplayBase::createCopy() naïvely assumes that the duplicate doesn't already exist": "https://www.drupal.org/files/issues/2024-11-15/3483353-create-copy-entity-display-config-action.patch"
+            }
+        }
+    },
+    "version": "dev-main"
+}
diff --git a/recipes/drupal_cms_search/config/search_api.index.content.yml b/recipes/drupal_cms_search/config/search_api.index.content.yml
new file mode 100644
index 0000000000000000000000000000000000000000..96ec576fbac18d65d8907d675660a5847b38a8da
--- /dev/null
+++ b/recipes/drupal_cms_search/config/search_api.index.content.yml
@@ -0,0 +1,127 @@
+status: true
+dependencies:
+  config:
+    - search_api.server.database
+    - core.entity_view_mode.node.search_index
+  module:
+    - node
+    - search_api
+    - search_api_exclude
+id: content
+name: 'Content'
+description: 'Can search within all content types.'
+read_only: false
+field_settings:
+  rendered_item:
+    label: 'Rendered HTML output'
+    property_path: rendered_item
+    type: text
+    configuration:
+      roles:
+        - anonymous
+      view_mode:
+        'entity:node':
+          ':default': search_index
+  title:
+    label: Title
+    datasource_id: 'entity:node'
+    property_path: title
+    type: text
+    boost: 3.0
+    dependencies:
+      module:
+        - node
+  type:
+    label: 'Content type'
+    datasource_id: 'entity:node'
+    property_path: type
+    type: string
+    dependencies:
+      module:
+        - node
+datasource_settings:
+  'entity:node':
+    bundles:
+      default: true
+      selected: { }
+    languages:
+      default: true
+      selected: { }
+processor_settings:
+  add_url: { }
+  aggregated_field: { }
+  custom_value: { }
+  entity_status: { }
+  entity_type: { }
+  highlight:
+    weights:
+      postprocess_query: 0
+    prefix: '<strong>'
+    suffix: '</strong>'
+    excerpt: true
+    excerpt_always: false
+    excerpt_length: 256
+    exclude_fields: { }
+    highlight: always
+    highlight_partial: true
+  html_filter:
+    weights:
+      preprocess_index: -15
+      preprocess_query: -15
+    all_fields: false
+    fields:
+      - rendered_item
+      - title
+      - type
+    title: true
+    alt: true
+    tags:
+      b: 2
+      h1: 5
+      h2: 3
+      h3: 2
+      strong: 2
+  ignorecase:
+    weights:
+      preprocess_index: -20
+      preprocess_query: -20
+    all_fields: false
+    fields:
+      - rendered_item
+      - title
+      - type
+  language_with_fallback: { }
+  node_exclude: { }
+  rendered_item: { }
+  reverse_entity_references: { }
+  stemmer:
+    weights:
+      preprocess_index: 0
+      preprocess_query: 0
+    all_fields: false
+    fields:
+      - rendered_item
+      - title
+    exceptions:
+      mexican: mexic
+      texan: texa
+  tokenizer:
+    weights:
+      preprocess_index: -6
+      preprocess_query: -6
+    all_fields: false
+    fields:
+      - rendered_item
+      - title
+    spaces: ''
+    ignored: ._-
+    overlap_cjk: 1
+    minimum_word_size: '3'
+tracker_settings:
+  default:
+    indexing_order: lifo
+options:
+  cron_limit: 10
+  index_directly: true
+  track_changes_in_references: true
+server: database
diff --git a/recipes/drupal_cms_search/config/search_api.server.database.yml b/recipes/drupal_cms_search/config/search_api.server.database.yml
new file mode 100644
index 0000000000000000000000000000000000000000..487de597e9ce44cc95ab86c5f2ad379c2eabdbf0
--- /dev/null
+++ b/recipes/drupal_cms_search/config/search_api.server.database.yml
@@ -0,0 +1,13 @@
+status: true
+dependencies:
+  module:
+    - search_api_db
+id: database
+name: 'Database'
+description: 'Uses database tables for indexing content.'
+backend: search_api_db
+backend_config:
+  database: 'default:default'
+  min_chars: 3
+  matching: partial
+  phrase: bigram
diff --git a/recipes/drupal_cms_search/config/search_api_autocomplete.search.content_autocomplete.yml b/recipes/drupal_cms_search/config/search_api_autocomplete.search.content_autocomplete.yml
new file mode 100644
index 0000000000000000000000000000000000000000..14fada9a7dc00edd9cca1c1c4c2eebc3db094e38
--- /dev/null
+++ b/recipes/drupal_cms_search/config/search_api_autocomplete.search.content_autocomplete.yml
@@ -0,0 +1,34 @@
+status: true
+dependencies:
+  config:
+    - search_api.index.content
+    - views.view.search
+  module:
+    - views
+    - search_api_autocomplete
+id: content_autocomplete
+label: Content Autocomplete
+index_id: content
+suggester_settings:
+  live_results:
+    fields: { }
+    highlight:
+      enabled: false
+      field: ''
+    suggest_keys: false
+    view_modes: {  }
+suggester_weights:
+  live_results: 0
+suggester_limits: { }
+search_settings:
+  'views:search':
+    displays:
+      default: true
+      selected: { }
+options:
+  limit: 10
+  min_length: 1
+  show_count: false
+  delay: null
+  submit_button_selector: ':submit'
+  autosubmit: true
diff --git a/recipes/drupal_cms_search/config/views.view.search.yml b/recipes/drupal_cms_search/config/views.view.search.yml
new file mode 100644
index 0000000000000000000000000000000000000000..34191dd11c44d3b6102e2e4f0b1d1fa2ac5b63f2
--- /dev/null
+++ b/recipes/drupal_cms_search/config/views.view.search.yml
@@ -0,0 +1,228 @@
+status: true
+dependencies:
+  config:
+    - search_api.index.content
+  module:
+    - search_api
+    - user
+id: search
+label: Search
+module: views
+description: 'Displays search results.'
+tag: ''
+base_table: search_api_index_content
+base_field: search_api_id
+display:
+  default:
+    id: default
+    display_title: Default
+    display_plugin: default
+    position: 0
+    display_options:
+      title: Search
+      fields:
+        rendered_item:
+          id: rendered_item
+          table: search_api_index_content
+          field: rendered_item
+          relationship: none
+          group_type: group
+          admin_label: ''
+          entity_type: null
+          entity_field: null
+          plugin_id: search_api
+          label: ''
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: true
+            ellipsis: true
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: true
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          link_to_item: false
+          use_highlighting: false
+          multi_type: separator
+          multi_separator: ', '
+      pager:
+        type: mini
+        options:
+          offset: 0
+          pagination_heading_level: h4
+          items_per_page: 10
+          total_pages: null
+          id: 0
+          tags:
+            next: ››
+            previous: ‹‹
+          expose:
+            items_per_page: false
+            items_per_page_label: 'Items per page'
+            items_per_page_options: '5, 10, 25, 50'
+            items_per_page_options_all: false
+            items_per_page_options_all_label: '- All -'
+            offset: false
+            offset_label: Offset
+      exposed_form:
+        type: basic
+        options:
+          submit_button: Find
+          reset_button: false
+          reset_button_label: Reset
+          exposed_sorts_label: 'Sort by'
+          expose_sort_order: true
+          sort_asc_label: Asc
+          sort_desc_label: Desc
+      access:
+        type: perm
+        options:
+          perm: 'access content'
+      cache:
+        type: search_api_tag
+        options: { }
+      empty: { }
+      sorts:
+        search_api_relevance:
+          id: search_api_relevance
+          table: search_api_index_content
+          field: search_api_relevance
+          relationship: none
+          group_type: group
+          admin_label: ''
+          plugin_id: search_api
+          order: DESC
+          expose:
+            label: ''
+            field_identifier: ''
+          exposed: false
+      arguments: { }
+      filters:
+        search_api_fulltext:
+          id: search_api_fulltext
+          table: search_api_index_content
+          field: search_api_fulltext
+          relationship: none
+          group_type: group
+          admin_label: ''
+          plugin_id: search_api_fulltext
+          operator: or
+          value: ''
+          group: 1
+          exposed: true
+          expose:
+            operator_id: search_api_fulltext_op
+            label: ''
+            description: ''
+            use_operator: false
+            operator: search_api_fulltext_op
+            operator_limit_selection: false
+            operator_list: { }
+            identifier: text
+            required: false
+            remember: false
+            multiple: false
+            remember_roles:
+              authenticated: authenticated
+              anonymous: '0'
+              content_editor: '0'
+              administrator: '0'
+            expose_fields: false
+            placeholder: 'Enter search keywords'
+            searched_fields_id: search_api_fulltext_searched_fields
+            value_maxlength: 128
+          is_grouped: false
+          group_info:
+            label: ''
+            description: ''
+            identifier: ''
+            optional: true
+            widget: select
+            multiple: false
+            remember: false
+            default_group: All
+            default_group_multiple: { }
+            group_items: { }
+          parse_mode: terms
+          min_length: null
+          fields: { }
+      style:
+        type: default
+      row:
+        type: search_api
+        options:
+          view_modes:
+            'entity:node':
+              ':default': search_result
+      query:
+        type: search_api_query
+        options:
+          bypass_access: false
+          skip_access: false
+          preserve_facet_query_args: false
+          query_tags: { }
+      relationships: { }
+      header: { }
+      footer: { }
+      display_extenders: { }
+    cache_metadata:
+      max-age: -1
+      contexts:
+        - 'languages:language_interface'
+        - url
+        - url.query_args
+        - 'user.node_grants:view'
+        - user.permissions
+      tags:
+        - 'config:search_api.index.content'
+        - 'search_api_list:content'
+  page_1:
+    id: page_1
+    display_title: Page
+    display_plugin: page
+    position: 1
+    display_options:
+      display_extenders: { }
+      path: search
+    cache_metadata:
+      max-age: -1
+      contexts:
+        - 'languages:language_interface'
+        - url
+        - url.query_args
+        - 'user.node_grants:view'
+        - user.permissions
+      tags:
+        - 'config:search_api.index.content'
+        - 'search_api_list:content'
diff --git a/recipes/drupal_cms_search/recipe.yml b/recipes/drupal_cms_search/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..4a1993b69b9109a3d535efef7d7b4f153edf7368
--- /dev/null
+++ b/recipes/drupal_cms_search/recipe.yml
@@ -0,0 +1,86 @@
+name: Search
+type: Drupal CMS
+description: Provides a simple database-powered search, with autocomplete support.
+
+install:
+  - block
+  # Basic search is for node entity type only at the moment.
+  - node
+  - search_api
+  - search_api_autocomplete
+  - search_api_db
+  - search_api_exclude
+  - simple_search_form
+  - views
+
+config:
+  strict: false
+  import:
+    # Use core node view modes for indexing and displaying results.
+    node:
+      - core.entity_view_mode.node.search_index
+      - core.entity_view_mode.node.search_result
+  actions:
+    # Enable Search API Exclude for all content types.
+    node.type.*:
+      setThirdPartySetting:
+        module: search_api_exclude
+        key: enabled
+        value: 1
+      createForEachIfNotExists:
+        # Create view display "Search result highlighting input" for all available content types.
+        core.entity_view_display.node.%bundle.search_result:
+          status: true
+          targetEntityType: node
+          mode: search_result
+          bundle: '%bundle'
+    # Create view display "Search Index" for all available content types.
+    core.entity_view_display.node.*.default:
+      # Clone the "Default" view display by default.
+      createCopy:
+        mode: search_index
+        use_existing: true
+    # Make sure view display "Search result highlighting input" contains field "Search result excerpt".
+    core.entity_view_display.node.*.search_result:
+      setComponent:
+        name: search_api_excerpt
+        options:
+          weight: 0
+          region: content
+    # Add the search form to the header of the page.
+    block.block.search_form:
+      placeBlockInDefaultTheme:
+        id: search_form
+        # The region accepts an array keyed by theme name.
+        region:
+          olivero: primary_menu
+          drupal_cms_olivero: primary_menu
+        # A fallback used if no match found in the region array.
+        default_region: content
+        # Place the block before any blocks already in the region.
+        position: first
+        plugin: simple_search_form_block
+        settings:
+          label: 'Search form'
+          label_display: '0'
+          provider: simple_search_form
+          action_path: /search
+          get_parameter: text
+          input_type: search_api_autocomplete
+          search_api_autocomplete:
+            search_id: content_autocomplete
+            display: page_1
+            arguments: ''
+            filter: text
+          input_label: Search
+          input_label_display: invisible
+          input_placeholder: 'Enter search keywords'
+          input_css_classes: ''
+          submit_display: true
+          submit_label: Find
+          input_keep_value: false
+          preserve_url_query_parameters: { }
+    user.role.anonymous:
+      grantPermission: 'use search_api_autocomplete for content_autocomplete'
+    user.role.authenticated:
+      grantPermission: 'use search_api_autocomplete for content_autocomplete'
diff --git a/recipes/drupal_cms_search/tests/src/Functional/ComponentValidationTest.php b/recipes/drupal_cms_search/tests/src/Functional/ComponentValidationTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..865cceea8d7a2c4579d165578afc4f3f393135f9
--- /dev/null
+++ b/recipes/drupal_cms_search/tests/src/Functional/ComponentValidationTest.php
@@ -0,0 +1,42 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\drupal_cms_search\Functional;
+
+use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait;
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * @group drupal_cms_search
+ */
+class ComponentValidationTest extends BrowserTestBase {
+
+  use RecipeTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['block', 'node'];
+
+  public function test(): void {
+    // The PlaceBlock config action has a core bug, where it doesn't account
+    // for the possibility of there being no blocks in a region. As a
+    // workaround, prevent that from happening by placing a useless block into
+    // the content region.
+    $this->drupalPlaceBlock('system_powered_by_block');
+
+    $dir = realpath(__DIR__ . '/../../..');
+
+    // The recipe should apply cleanly.
+    $this->applyRecipe($dir);
+    // Apply it again to prove that it is idempotent.
+    $this->applyRecipe($dir);
+  }
+
+}
diff --git a/recipes/drupal_cms_starter/composer.json b/recipes/drupal_cms_starter/composer.json
index 1bd8c8094dde9d7be09307930608ec761269a6e5..cb571598d3ab5f63c122e2471da3beb546bb4014 100644
--- a/recipes/drupal_cms_starter/composer.json
+++ b/recipes/drupal_cms_starter/composer.json
@@ -14,6 +14,7 @@
         "drupal/drupal_cms_media_tools": "*",
         "drupal/drupal_cms_olivero": "*",
         "drupal/drupal_cms_privacy_basic": "*",
+        "drupal/drupal_cms_search": "*",
         "drupal/drupal_cms_seo_basic": "*",
         "drupal/drupal_cms_dashboard": "*",
         "drupal/easy_email_express": "1.0.1",