diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 44b114152e85b90a88f3579da64559bdb1b8e7d9..2c0466ca80e503253596cf6dd4465e67c275b4e6 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -310,14 +310,14 @@ phpunit:
         _TARGET_DB_VERSION: '3'
         # @see https://git.drupalcode.org/project/drupal/-/commit/10466dba9d7322ed55165dd9224edb2153c9b27a
         _TARGET_PHP: '8.3-ubuntu'
-        _FOR_EVERY_MR_COMMIT: 'true'
+        _FOR_EVERY_MR_COMMIT: 'false'
       - _TARGET_DB_TYPE: 'mariadb'
         _TARGET_DB_VERSION: '10.6'
         _FOR_EVERY_MR_COMMIT: 'false'
       - _TARGET_DB_TYPE: 'mysql'
         # @todo Delete this when https://www.drupal.org/project/gitlab_templates/issues/3447105 lands.
         _TARGET_DB_VERSION: '8'
-        _FOR_EVERY_MR_COMMIT: 'false'
+        _FOR_EVERY_MR_COMMIT: 'true'
       - _TARGET_DB_TYPE: 'pgsql'
         _TARGET_DB_VERSION: '16'
         _FOR_EVERY_MR_COMMIT: 'false'
diff --git a/openapi.yml b/openapi.yml
index 7e7e0d4ff6310b76e6dda8c9461ed6cdbe90aa9f..9ffe8fefecb32b09d9fe11ca11550ec64d564979 100644
--- a/openapi.yml
+++ b/openapi.yml
@@ -855,6 +855,13 @@ paths:
   '/xb/api/v0/content/{entity_type}':
     get:
       description: Provides api for page listing.
+      parameters:
+        - in: query
+          name: search
+          required: false
+          description: Search term to filter content by title
+          schema:
+            type: string
       responses:
         200:
           description: Page list generated successfully.
diff --git a/src/AutoSave/AutoSaveManager.php b/src/AutoSave/AutoSaveManager.php
index b4c54825c64ff87c62e0f443b82d672c8224de3a..1f98ec4ee3862241871d196bf7a053385b6b2c03 100644
--- a/src/AutoSave/AutoSaveManager.php
+++ b/src/AutoSave/AutoSaveManager.php
@@ -20,6 +20,7 @@ use Drupal\Core\TypedData\TypedDataInterface;
 use Drupal\experience_builder\AutoSaveEntity;
 use Drupal\experience_builder\Controller\ApiContentControllers;
 use Drupal\experience_builder\Entity\XbHttpApiEligibleConfigEntityInterface;
+use Drupal\experience_builder\Plugin\Field\FieldType\ComponentTreeItem;
 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
 use Symfony\Component\Validator\ConstraintViolationList;
 use Symfony\Component\Validator\ConstraintViolationListInterface;
@@ -66,6 +67,16 @@ class AutoSaveManager implements EventSubscriberInterface {
     $data = self::normalizeEntity($entity);
     $data_hash = self::generateHash($data);
     $original_hash = $this->getUnchangedHash($entity);
+    // đź’ˇ If you are debugging why an entry is being created, but you didn't
+    // expect one to be, the code below can be evaluated in a debugger and will
+    // show you which field varies.
+    // @code
+    // $original = self::normalizeEntity($this->entityTypeManager->getStorage($entity->getEntityTypeId())->loadUnchanged($entity->id()))
+    // $data_hash = \array_map(self::generateHash(...), $data)
+    // $original_hash = \array_map(self::generateHash(...), $original)
+    // \array_diff($data_hash, $original_hash)
+    // \array_diff($original_hash, $data_hash)
+    // @endcode
     if ($original_hash !== NULL && \hash_equals($original_hash, $data_hash)) {
       // We've reset back to the original values. Clear the auto-save entry but
       // keep the hash.
@@ -126,6 +137,11 @@ class AutoSaveManager implements EventSubscriberInterface {
       }
       $normalized[$name] = \array_map(
         static function (FieldItemInterface $item): array {
+          if ($item instanceof ComponentTreeItem) {
+            // Optimize component inputs to ensure the normalized value is
+            // determinative.
+            $item->optimizeInputs();
+          }
           $value = $item->toArray();
           foreach (\array_filter($item->getProperties(), static fn (TypedDataInterface $property) => $property instanceof PrimitiveInterface) as $property) {
             \assert($property instanceof PrimitiveInterface);
@@ -175,16 +191,17 @@ class AutoSaveManager implements EventSubscriberInterface {
   /**
    * Gets all auto-save data.
    *
-   * @return array<string, array{data: array, owner: int, updated: int, entity_type: string, entity_id: string|int, label: string, langcode: ?string}>
+   * @return array<string, array{data: array, owner: int, updated: int, entity_type: string, entity_id: string|int, label: string, langcode: ?string, entity: ?EntityInterface}>
    *   All auto-save data entries.
    */
-  public function getAllAutoSaveList(): array {
-    return \array_map(static fn (object $entry) => $entry->data +
+  public function getAllAutoSaveList(bool $with_entities = FALSE): array {
+    return \array_map(fn (object $entry) => $entry->data +
     // Append the owner and updated data into each entry.
     [
       // Remove the unique session key for anonymous users.
       'owner' => \is_numeric($entry->owner) ? (int) $entry->owner : 0,
       'updated' => $entry->updated,
+      'entity' => $with_entities ? $this->entityTypeManager->getStorage($entry->data['entity_type'])->create($entry->data['data']) : NULL,
     ], $this->getTempStore()->getAll());
   }
 
diff --git a/src/Controller/ApiAutoSaveController.php b/src/Controller/ApiAutoSaveController.php
index c9306531f39579af0daac98cd8951feffcb104d6..62fc8a9bed4f0c64260ba6c29681ea2a3a9297d9 100644
--- a/src/Controller/ApiAutoSaveController.php
+++ b/src/Controller/ApiAutoSaveController.php
@@ -112,9 +112,9 @@ final class ApiAutoSaveController extends ApiControllerBase {
     // User display names depend on configuration.
     $cache->addCacheableDependency($this->configFactory->get('user.settings'));
 
-    // Remove 'data' key because this will reduce the amount of data sent to the
-    // client and back to the server.
-    $all = \array_map(fn(array $item) => \array_diff_key($item, ['data' => '']), $all);
+    // Remove 'data' and 'entity' key because this will reduce the amount of
+    // data sent to the client and back to the server.
+    $all = \array_map(fn(array $item) => \array_diff_key($item, \array_flip(['data', 'entity'])), $all);
 
     $withUserDetails = \array_map(fn(array $item) => [
       // @phpstan-ignore-next-line
@@ -139,7 +139,7 @@ final class ApiAutoSaveController extends ApiControllerBase {
   public function post(Request $request): JsonResponse {
     $client_auto_saves = \json_decode($request->getContent(), TRUE);
     \assert(\is_array($client_auto_saves));
-    $all_auto_saves = $this->autoSaveManager->getAllAutoSaveList();
+    $all_auto_saves = $this->autoSaveManager->getAllAutoSaveList(TRUE);
     if ($validation_response = self::validateExpectedAutoSaves($client_auto_saves, $all_auto_saves)) {
       return $validation_response;
     }
@@ -160,8 +160,7 @@ final class ApiAutoSaveController extends ApiControllerBase {
     $access_error_labels = [];
     $access_error_cache = new CacheableMetadata();
     $loadedEntities = [];
-    foreach ($publish_auto_saves as $autoSaveKey => $auto_save) {
-      $entity = $this->entityTypeManager->getStorage($auto_save['entity_type'])->create($auto_save['data']);
+    foreach ($publish_auto_saves as $autoSaveKey => ['entity' => $entity]) {
       assert($entity instanceof EntityInterface);
       // Auto-saves always are updates to existing entities. This just used
       // EntityStorageInterface::create() to construct an entity object from
diff --git a/src/Controller/ApiContentControllers.php b/src/Controller/ApiContentControllers.php
index e76a773239ade6b9dadc6ce4095aa28dda9cfab0..6501fe4d606f526a881a5bdbce0e466b494df0e8 100644
--- a/src/Controller/ApiContentControllers.php
+++ b/src/Controller/ApiContentControllers.php
@@ -9,11 +9,14 @@ use Drupal\Core\Cache\CacheableJsonResponse;
 use Drupal\Core\Cache\CacheableMetadata;
 use Drupal\Core\Entity\EntityInterface;
 use Drupal\Core\Entity\EntityPublishedInterface;
+use Drupal\Core\Entity\EntityReferenceSelection\SelectionPluginManagerInterface;
 use Drupal\Core\Entity\EntityTypeInterface;
 use Drupal\Core\Entity\ContentEntityInterface;
 use Drupal\Core\Entity\EntityTypeManagerInterface;
 use Drupal\Core\Entity\FieldableEntityInterface;
 use Drupal\Core\Entity\Query\QueryInterface;
+use Drupal\Core\Language\LanguageManagerInterface;
+use Drupal\Component\Transliteration\TransliterationInterface;
 use Drupal\Core\Render\RenderContext;
 use Drupal\Core\Render\RendererInterface;
 use Drupal\Core\Routing\RouteProviderInterface;
@@ -24,6 +27,7 @@ use Drupal\experience_builder\Resource\XbResourceLink;
 use Drupal\experience_builder\Resource\XbResourceLinkCollection;
 use Drupal\experience_builder\XbUriDefinitions;
 use http\Exception\InvalidArgumentException;
+use Symfony\Component\DependencyInjection\Attribute\Autowire;
 use Symfony\Component\HttpFoundation\JsonResponse;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
@@ -38,11 +42,20 @@ use Symfony\Component\HttpFoundation\Response;
  */
 final class ApiContentControllers {
 
+  /**
+   * The maximum number of entity search results to return.
+   */
+  private const int MAX_SEARCH_RESULTS = 50;
+
   public function __construct(
     private readonly EntityTypeManagerInterface $entityTypeManager,
     private readonly RendererInterface $renderer,
     private readonly AutoSaveManager $autoSaveManager,
+    private readonly SelectionPluginManagerInterface $selectionManager,
     private readonly RouteProviderInterface $routeProvider,
+    private readonly LanguageManagerInterface $languageManager,
+    #[Autowire(service: 'transliteration')]
+    private readonly TransliterationInterface $transliteration,
   ) {}
 
   public function post(Request $request, string $entity_type): JsonResponse {
@@ -107,60 +120,239 @@ final class ApiContentControllers {
    *
    * @see https://www.drupal.org/project/experience_builder/issues/3500052#comment-15966496
    */
-  public function list(string $entity_type): CacheableJsonResponse {
-    // @todo introduce pagination in https://www.drupal.org/i/3502691
+  public function list(string $entity_type, Request $request): CacheableJsonResponse {
+    $langcode = $this->languageManager->getCurrentLanguage()->getId();
     $storage = $this->entityTypeManager->getStorage($entity_type);
-    $query_cacheability = (new CacheableMetadata())
-      ->addCacheContexts($storage->getEntityType()->getListCacheContexts())
-      ->addCacheTags($storage->getEntityType()->getListCacheTags());
-    $url_cacheability = new CacheableMetadata();
-    // We don't need to worry about the status of the page, as we need both
-    // published and unpublished pages on the frontend.
+
+    // Setup cacheability metadata
+    $query_cacheability = $this->createInitialCacheability($storage);
+
+    // Create an entity query with access check
+    /** @var \Drupal\Core\Entity\ContentEntityTypeInterface $field_definition */
+    $field_definition = $this->entityTypeManager->getDefinition($entity_type);
+    $revision_created_field_name = $field_definition->getRevisionMetadataKey('revision_created');
     $entity_query = $storage->getQuery()->accessCheck(TRUE);
-    $ids = $this->executeQueryInRenderContext($entity_query, $query_cacheability);
+
+    // Prepare search term and determine if we're performing a search
+    $search = $this->prepareSearchTerm($request, $langcode);
+    $search_ids = [];
+
+    if ($search === '') {
+      // Only apply sorting and range limiting when not searching
+      $entity_query->sort((string) $revision_created_field_name, direction: 'DESC')
+        ->range(0, self::MAX_SEARCH_RESULTS);
+    }
+    else {
+      // Handle search functionality when search term is not empty
+      $label_key = $storage->getEntityType()->getKey('label');
+
+      // @todo Remove this in https://www.drupal.org/project/experience_builder/issues/3498525.
+      if ($label_key === FALSE) {
+        throw new \LogicException('Unhandled.');
+      }
+
+      // Get matching entity IDs through selection handler
+      $matching_ids = $this->getMatchingEntityIds($entity_type, $search);
+
+      // Find matching unsaved entities
+      $matching_unsaved_ids = $this->getMatchingUnsavedIds($search, $langcode, $entity_type);
+
+      // Return empty response if no matches found
+      if (empty($matching_ids) && empty($matching_unsaved_ids)) {
+        return $this->createEmptyResponse($query_cacheability);
+      }
+      $search_ids = $this->filterAndMergeIds($matching_ids, $matching_unsaved_ids);
+    }
+
+    // Get entity IDs and filter by search results if needed.
+    $ids = !empty($search_ids) ? $search_ids : $this->executeQueryInRenderContext($entity_query, $query_cacheability);
+
+    // Load entities and prepare content list.
     /** @var \Drupal\Core\Entity\EntityPublishedInterface[] $content_entities */
     $content_entities = $storage->loadMultiple($ids);
     $content_list = [];
 
     foreach ($content_entities as $content_entity) {
       $id = (int) $content_entity->id();
-      $generated_url = $content_entity->toUrl()->toString(TRUE);
-
-      $autoSaveData = $this->autoSaveManager->getAutoSaveEntity($content_entity);
-      // Expose available entity operations.
-      $linkCollection = $this->getEntityOperations($content_entity);
-      $autoSaveEntity = $autoSaveData->isEmpty() ? NULL : $autoSaveData->entity;
-
-      // @todo Dynamically use the entity 'path' key to determine which field is
-      //   the path in https://drupal.org/i/3503446.
-      $autoSavePath = NULL;
-      if ($autoSaveEntity instanceof FieldableEntityInterface && $autoSaveEntity->hasField('path')) {
-        $autoSavePath = $autoSaveEntity->get('path')->first()?->getValue()['alias'] ?? \sprintf('/%s', \ltrim($autoSaveEntity->toUrl()->getInternalPath(), '/'));
-      }
-
-      $content_list[$id] = [
-        'id' => $id,
-        'title' => $content_entity->label(),
-        'status' => $content_entity->isPublished(),
-        'path' => $generated_url->getGeneratedUrl(),
-        'autoSaveLabel' => $autoSaveEntity?->label(),
-        'autoSavePath' => $autoSavePath,
-        // @see https://jsonapi.org/format/#document-links
-        'links' => $linkCollection->asArray(),
-      ];
-      $url_cacheability->addCacheableDependency($generated_url)
-        ->addCacheableDependency($linkCollection);
+      $content_list[$id] = $this->prepareEntityData($content_entity, $query_cacheability, $id);
     }
+
     $json_response = new CacheableJsonResponse($content_list);
+    $this->addSearchCacheability($query_cacheability);
     // @todo add cache contexts for query params when introducing pagination in https://www.drupal.org/i/3502691.
-    $json_response->addCacheableDependency($query_cacheability)
-      ->addCacheableDependency($url_cacheability);
-    if (isset($autoSaveData)) {
-      $json_response->addCacheableDependency($autoSaveData);
+    $json_response->addCacheableDependency($query_cacheability);
+
+    return $json_response;
+  }
+
+  /**
+   * Prepares entity data for the response.
+   *
+   * @param \Drupal\Core\Entity\EntityPublishedInterface $content_entity
+   *   The content entity to prepare data for.
+   * @param \Drupal\Core\Cache\CacheableMetadata $url_cacheability
+   *   The cacheability metadata object to add URL dependencies to.
+   * @param int $id
+   *   The entity ID.
+   *
+   * @return array
+   *   An associative array containing the prepared entity data.
+   */
+  private function prepareEntityData(EntityPublishedInterface $content_entity, CacheableMetadata $url_cacheability, int $id): array {
+    $generated_url = $content_entity->toUrl()->toString(TRUE);
+
+    $autoSaveData = $this->autoSaveManager->getAutoSaveEntity($content_entity);
+    // Expose available entity operations.
+    $linkCollection = $this->getEntityOperations($content_entity);
+    $autoSaveEntity = $autoSaveData->isEmpty() ? NULL : $autoSaveData->entity;
+
+    // @todo Dynamically use the entity 'path' key to determine which field is
+    //   the path in https://drupal.org/i/3503446.
+    $autoSavePath = NULL;
+    if ($autoSaveEntity instanceof FieldableEntityInterface && $autoSaveEntity->hasField('path')) {
+      $autoSavePath = $autoSaveEntity->get('path')->first()?->getValue()['alias'] ?? \sprintf('/%s', \ltrim($autoSaveEntity->toUrl()->getInternalPath(), '/'));
     }
+
+    $url_cacheability->addCacheableDependency($generated_url)
+      ->addCacheableDependency($linkCollection)
+      ->addCacheableDependency($autoSaveData);
+
+    return [
+      'id' => $id,
+      'title' => $content_entity->label(),
+      'status' => $content_entity->isPublished(),
+      'path' => $generated_url->getGeneratedUrl(),
+      'autoSaveLabel' => $autoSaveEntity?->label(),
+      'autoSavePath' => $autoSavePath,
+      // @see https://jsonapi.org/format/#document-links
+      'links' => $linkCollection->asArray(),
+    ];
+  }
+
+  /**
+   * Creates initial cacheability metadata based on entity storage.
+   *
+   * @param \Drupal\Core\Entity\EntityStorageInterface $storage
+   *   The entity storage.
+   *
+   * @return \Drupal\Core\Cache\CacheableMetadata
+   *   The initialized cacheability metadata.
+   */
+  private static function createInitialCacheability($storage): CacheableMetadata {
+    return (new CacheableMetadata())
+      ->addCacheContexts($storage->getEntityType()->getListCacheContexts())
+      ->addCacheTags($storage->getEntityType()->getListCacheTags());
+  }
+
+  /**
+   * Prepares and normalizes the search term from the request.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The HTTP request object.
+   * @param string $langcode
+   *   The language code to use for transliteration.
+   *
+   * @return string
+   *   The normalized search term.
+   */
+  private function prepareSearchTerm(Request $request, string $langcode): string {
+    if ($request->query->has('search')) {
+      $search = trim((string) $request->query->get('search'));
+      return $this->transliteration->transliterate(mb_strtolower($search), $langcode);
+    }
+    return '';
+  }
+
+  /**
+   * Gets entity IDs matching the search term using selection handler.
+   *
+   * @param string $entity_type
+   *   The entity type ID.
+   * @param string $search
+   *   The search term to match against entities.
+   *
+   * @return array
+   *   An array of entity IDs that match the search criteria.
+   */
+  private function getMatchingEntityIds(string $entity_type, string $search): array {
+    /** @var \Drupal\Core\Entity\EntityReferenceSelection\SelectionInterface $selection_handler */
+    $selection_handler = $this->selectionManager->getInstance([
+      'target_type' => $entity_type,
+      'handler' => 'default',
+      'match_operator' => 'CONTAINS',
+    ]);
+
+    $matching_data = $selection_handler->getReferenceableEntities(
+      $search,
+      'CONTAINS',
+      self::MAX_SEARCH_RESULTS
+    );
+
+    return isset($matching_data[$entity_type]) ? array_keys($matching_data[$entity_type]) : [];
+  }
+
+  /**
+   * Gets unsaved entity IDs matching the search term.
+   *
+   * @param string $search
+   *   The search term to match against entities.
+   * @param string $langcode
+   *   The language code to use for transliteration.
+   *
+   * @return array
+   *   An array of entity IDs that match the search criteria.
+   */
+  private function getMatchingUnsavedIds(string $search, string $langcode, string $entity_type_id): array {
+    $matching_unsaved_ids = [];
+    $unsaved_entries = \array_filter($this->autoSaveManager->getAllAutoSaveList(TRUE), static fn (array $entry): bool => $entry['entity_type'] === $entity_type_id);
+
+    foreach ($unsaved_entries as ['entity' => $entity]) {
+      \assert($entity instanceof EntityInterface);
+      $label = $this->transliteration->transliterate(mb_strtolower((string) $entity->label()), $langcode);
+      if (str_contains(mb_strtolower($label), $search)) {
+        $matching_unsaved_ids[] = $entity->id();
+      }
+    }
+
+    return $matching_unsaved_ids;
+  }
+
+  /**
+   * Creates an empty response with proper cacheability metadata.
+   *
+   * @param \Drupal\Core\Cache\CacheableMetadata $query_cacheability
+   *   The cacheability metadata to attach to the response.
+   *
+   * @return \Drupal\Core\Cache\CacheableJsonResponse
+   *   An empty JSON response with cacheability metadata attached.
+   */
+  private static function createEmptyResponse(CacheableMetadata $query_cacheability): CacheableJsonResponse {
+    $json_response = new CacheableJsonResponse([]);
+    self::addSearchCacheability($query_cacheability);
+    $json_response->addCacheableDependency($query_cacheability);
     return $json_response;
   }
 
+  /**
+   * Filters and merges entity IDs based on search results.
+   *
+   * @param array $matching_ids
+   *   The array of entity IDs that match the search term.
+   * @param array $matching_unsaved_ids
+   *   The array of unsaved entity IDs that match the search term.
+   *
+   * @return array
+   *   The filtered and merged array of entity IDs.
+   */
+  private static function filterAndMergeIds(array $matching_ids, array $matching_unsaved_ids): array {
+    // Sort by newest first (keys will be numeric IDs) and limit to max results
+    $ids = array_unique(array_merge($matching_ids, $matching_unsaved_ids));
+    arsort($ids);
+    $ids = array_slice($ids, 0, self::MAX_SEARCH_RESULTS, TRUE);
+
+    return $ids;
+  }
+
   /**
    * Duplicates entity.
    *
@@ -227,6 +419,17 @@ final class ApiContentControllers {
     return new TranslatableMarkup('Untitled @singular_entity_type_label', ['@singular_entity_type_label' => $entity_type->getSingularLabel()]);
   }
 
+  /**
+   * Adds search-related cache contexts and tags to cacheability metadata.
+   *
+   * @param \Drupal\Core\Cache\CacheableMetadata $query_cacheability
+   *   The cacheability metadata object to add contexts and tags to.
+   */
+  private static function addSearchCacheability(CacheableMetadata $query_cacheability): void {
+    $query_cacheability->addCacheContexts(['url.query_args:search']);
+    $query_cacheability->addCacheTags([AutoSaveManager::CACHE_TAG]);
+  }
+
   public function getEntityOperations(EntityPublishedInterface $content_entity): XbResourceLinkCollection {
     $links = new XbResourceLinkCollection([]);
     // Link relation type => route name.
diff --git a/src/Plugin/Field/FieldType/ComponentTreeItem.php b/src/Plugin/Field/FieldType/ComponentTreeItem.php
index a809da6a8f1d72940ccf4551fb0d62f486f01db2..8eb5d74c498e00bd1e1026caeaf882feb2ccc8e8 100644
--- a/src/Plugin/Field/FieldType/ComponentTreeItem.php
+++ b/src/Plugin/Field/FieldType/ComponentTreeItem.php
@@ -591,13 +591,7 @@ class ComponentTreeItem extends FieldItemBase {
     if ($input_values === NULL && $source->requiresExplicitInput()) {
       throw new \LogicException(sprintf('Missing input for component instance with UUID %s', $component_instance_uuid));
     }
-
-    // Allow component source plugins to normalize the stored data.
-    $inputs = $this->getInputs();
-    if ($inputs !== NULL && $source = $this->getComponent()?->getComponentSource()) {
-      $inputs = $source->optimizeExplicitInput($inputs);
-      $this->setInput($inputs);
-    }
+    $this->optimizeInputs();
     // @todo Omit defaults that are stored at the content type template level, e.g. in core.entity_view_display.node.article.default.yml
     // $template_tree = '@todo';
     // $template_inputs = '@todo';
@@ -652,4 +646,20 @@ class ComponentTreeItem extends FieldItemBase {
     return $changed;
   }
 
+  public function optimizeInputs(): void {
+    $source = $this->getComponent()?->getComponentSource();
+    if ($source === NULL) {
+      // This could be running against data that has not been validated, in
+      // which case there is nothing we can do without a valid component or
+      // source.
+      return;
+    }
+    // Allow component source plugins to normalize the stored data.
+    $inputs = $this->getInputs();
+    if ($inputs !== NULL) {
+      $inputs = $source->optimizeExplicitInput($inputs);
+      $this->setInput($inputs);
+    }
+  }
+
 }
diff --git a/tests/src/Functional/XbContentEntityHttpApiTest.php b/tests/src/Functional/XbContentEntityHttpApiTest.php
index b30db9a376bf7f6c0eea5f512cc8f23725b4f8db..a529cc16db988fbaf3198d244957f3f8dae15421 100644
--- a/tests/src/Functional/XbContentEntityHttpApiTest.php
+++ b/tests/src/Functional/XbContentEntityHttpApiTest.php
@@ -93,7 +93,14 @@ final class XbContentEntityHttpApiTest extends HttpApiTestBase {
     $this->drupalLogin($user);
     // We have a cache tag for page 2 as it's the homepage, set in system.site
     // config.
-    $body = $this->assertExpectedResponse('GET', $url, [], 200, ['user.permissions'], [AutoSaveManager::CACHE_TAG, 'config:system.site', 'http_response', 'xb_page:2', 'xb_page_list'], 'UNCACHEABLE (request policy)', 'MISS');
+    $expected_tags = [
+      AutoSaveManager::CACHE_TAG,
+      'config:system.site',
+      'http_response',
+      'xb_page:2',
+      'xb_page_list',
+    ];
+    $body = $this->assertExpectedResponse('GET', $url, [], 200, ['url.query_args:search', 'user.permissions'], $expected_tags, 'UNCACHEABLE (request policy)', 'MISS');
     $no_auto_save_expected_pages = [
       // Page 1 has a path alias.
       '1' => [
@@ -141,7 +148,39 @@ final class XbContentEntityHttpApiTest extends HttpApiTestBase {
       $no_auto_save_expected_pages,
       $body
     );
-    $this->assertExpectedResponse('GET', $url, [], 200, ['user.permissions'], [AutoSaveManager::CACHE_TAG, 'config:system.site', 'http_response', 'xb_page:2', 'xb_page_list'], 'UNCACHEABLE (request policy)', 'HIT');
+    $this->assertExpectedResponse('GET', $url, [], 200, ['url.query_args:search', 'user.permissions'], $expected_tags, 'UNCACHEABLE (request policy)', 'HIT');
+
+    // Test searching by query parameter
+    $search_url = Url::fromUri('base:/xb/api/v0/content/xb_page', ['query' => ['search' => 'Page 1']]);
+    // Because page 2 isn't in these results, we don't get its cache tag.
+    $expected_tags_without_page_2 = \array_diff($expected_tags, ['xb_page:2']);
+    // Confirm that the cache is not hit when a different request is made with query parameter.
+    $search_body = $this->assertExpectedResponse('GET', $search_url, [], 200, ['url.query_args:search', 'user.permissions'], $expected_tags_without_page_2, 'UNCACHEABLE (request policy)', 'MISS');
+    $this->assertEquals(
+      [
+        '1' => [
+          'id' => 1,
+          'title' => 'Page 1',
+          'status' => TRUE,
+          'path' => base_path() . 'page-1',
+          'autoSaveLabel' => NULL,
+          'autoSavePath' => NULL,
+          'links' => [
+            // @todo https://www.drupal.org/i/3498525 should remove the hardcoded `xb_page` from these.
+            XbUriDefinitions::LINK_REL_EDIT => Url::fromUri('base:/xb/xb_page/1')->toString(),
+            XbUriDefinitions::LINK_REL_DUPLICATE => Url::fromUri('base:/xb/api/v0/content/xb_page')->toString(),
+          ],
+        ],
+      ],
+      $search_body
+    );
+    // Confirm that the cache is hit when the same request is made again.
+    $this->assertExpectedResponse('GET', $search_url, [], 200, ['url.query_args:search', 'user.permissions'], $expected_tags_without_page_2, 'UNCACHEABLE (request policy)', 'HIT');
+
+    // Test searching by query parameter - substring match.
+    $substring_search_url = Url::fromUri('base:/xb/api/v0/content/xb_page', ['query' => ['search' => 'age']]);
+    $substring_search_body = $this->assertExpectedResponse('GET', $substring_search_url, [], 200, ['url.query_args:search', 'user.permissions'], $expected_tags, 'UNCACHEABLE (request policy)', 'MISS');
+    $this->assertEquals($no_auto_save_expected_pages, $substring_search_body);
 
     $autoSaveManager = $this->container->get(AutoSaveManager::class);
     $page_1 = Page::load(1);
@@ -156,7 +195,7 @@ final class XbContentEntityHttpApiTest extends HttpApiTestBase {
     $page_2->set('path', ['alias' => "/the-new-path"]);
     $autoSaveManager->saveEntity($page_2);
 
-    $body = $this->assertExpectedResponse('GET', $url, [], 200, ['user.permissions'], [AutoSaveManager::CACHE_TAG, 'config:system.site', 'http_response', 'xb_page:2', 'xb_page_list'], 'UNCACHEABLE (request policy)', 'MISS');
+    $body = $this->assertExpectedResponse('GET', $url, [], 200, ['url.query_args:search', 'user.permissions'], $expected_tags, 'UNCACHEABLE (request policy)', 'MISS');
     $auto_save_expected_pages = $no_auto_save_expected_pages;
     $auto_save_expected_pages['1']['autoSaveLabel'] = 'The updated title.';
     $auto_save_expected_pages['1']['autoSavePath'] = '/the-updated-path';
@@ -166,7 +205,7 @@ final class XbContentEntityHttpApiTest extends HttpApiTestBase {
       $auto_save_expected_pages,
       $body
     );
-    $this->assertExpectedResponse('GET', $url, [], 200, ['user.permissions'], [AutoSaveManager::CACHE_TAG, 'config:system.site', 'http_response', 'xb_page:2', 'xb_page_list'], 'UNCACHEABLE (request policy)', 'HIT');
+    $this->assertExpectedResponse('GET', $url, [], 200, ['url.query_args:search', 'user.permissions'], $expected_tags, 'UNCACHEABLE (request policy)', 'HIT');
 
     // Confirm that if path alias is empty, the system path is used, not the
     // existing alias if set.
@@ -178,7 +217,7 @@ final class XbContentEntityHttpApiTest extends HttpApiTestBase {
     $page_2->set('path', NULL);
     $autoSaveManager->saveEntity($page_2);
 
-    $body = $this->assertExpectedResponse('GET', $url, [], 200, ['user.permissions'], [AutoSaveManager::CACHE_TAG, 'config:system.site', 'http_response', 'xb_page:2', 'xb_page_list'], 'UNCACHEABLE (request policy)', 'MISS');
+    $body = $this->assertExpectedResponse('GET', $url, [], 200, ['url.query_args:search', 'user.permissions'], $expected_tags, 'UNCACHEABLE (request policy)', 'MISS');
     $auto_save_expected_pages['1']['autoSavePath'] = '/page/1';
     $auto_save_expected_pages['2']['autoSavePath'] = '/page/2';
     $this->assertEquals(
@@ -188,12 +227,12 @@ final class XbContentEntityHttpApiTest extends HttpApiTestBase {
 
     $autoSaveManager->delete($page_1);
     $autoSaveManager->delete($page_2);
-    $body = $this->assertExpectedResponse('GET', $url, [], 200, ['user.permissions'], [AutoSaveManager::CACHE_TAG, 'config:system.site', 'http_response', 'xb_page:2', 'xb_page_list'], 'UNCACHEABLE (request policy)', 'MISS');
+    $body = $this->assertExpectedResponse('GET', $url, [], 200, ['url.query_args:search', 'user.permissions'], $expected_tags, 'UNCACHEABLE (request policy)', 'MISS');
     $this->assertEquals(
       $no_auto_save_expected_pages,
       $body
     );
-    $this->assertExpectedResponse('GET', $url, [], 200, ['user.permissions'], [AutoSaveManager::CACHE_TAG, 'config:system.site', 'http_response', 'xb_page:2', 'xb_page_list'], 'UNCACHEABLE (request policy)', 'HIT');
+    $this->assertExpectedResponse('GET', $url, [], 200, ['url.query_args:search', 'user.permissions'], $expected_tags, 'UNCACHEABLE (request policy)', 'HIT');
   }
 
   /**
@@ -212,9 +251,7 @@ final class XbContentEntityHttpApiTest extends HttpApiTestBase {
     assert($user instanceof UserInterface);
     $this->drupalLogin($user);
 
-    // We have a cache tag for page 2 as it's the homepage, set in system.site
-    // config.
-    $body = $this->assertExpectedResponse('GET', $url, [], 200, Cache::mergeContexts(['user.permissions'], $extraCacheContexts), Cache::mergeTags([AutoSaveManager::CACHE_TAG, 'config:system.site', 'http_response', 'xb_page:2', 'xb_page_list'], $extraCacheTags), 'UNCACHEABLE (request policy)', 'MISS');
+    $body = $this->assertExpectedResponse('GET', $url, [], 200, Cache::mergeContexts(['url.query_args:search', 'user.permissions'], $extraCacheContexts), Cache::mergeTags([AutoSaveManager::CACHE_TAG, 'config:system.site', 'http_response', 'xb_page:2', 'xb_page_list'], $extraCacheTags), 'UNCACHEABLE (request policy)', 'MISS');
     assert(\is_array($body));
     assert(\array_key_exists('1', $body) && \array_key_exists('links', $body['1']));
     $this->assertEquals(
@@ -293,7 +330,7 @@ final class XbContentEntityHttpApiTest extends HttpApiTestBase {
     $user = $this->createUser([Page::EDIT_PERMISSION, Page::DELETE_PERMISSION], 'administer_xb_page_user');
     assert($user instanceof UserInterface);
     $this->drupalLogin($user);
-    $body = $this->assertExpectedResponse('GET', $url, [], 200, ['user.permissions'], [AutoSaveManager::CACHE_TAG, 'config:system.site', 'http_response', 'xb_page:2', 'xb_page_list'], 'UNCACHEABLE (request policy)', 'MISS');
+    $body = $this->assertExpectedResponse('GET', $url, [], 200, ['url.query_args:search', 'user.permissions'], [AutoSaveManager::CACHE_TAG, 'config:system.site', 'http_response', 'xb_page:2', 'xb_page_list'], 'UNCACHEABLE (request policy)', 'MISS');
     assert(\is_array($body));
     assert(\array_key_exists('2', $body) && \array_key_exists('links', $body['2']));
     $this->assertEquals(
diff --git a/tests/src/Kernel/Controller/ApiContentControllersListTest.php b/tests/src/Kernel/Controller/ApiContentControllersListTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..2ae183c2d305a56a51c873367403650c149174f2
--- /dev/null
+++ b/tests/src/Kernel/Controller/ApiContentControllersListTest.php
@@ -0,0 +1,442 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\experience_builder\Kernel\Controller;
+
+// cspell:ignore Gábor Hojtsy uniquesearchterm gàbor autosave searchterm
+
+use Drupal\Component\Transliteration\TransliterationInterface;
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\experience_builder\AutoSave\AutoSaveManager;
+use Drupal\experience_builder\Controller\ApiContentControllers;
+use Drupal\experience_builder\Entity\Page;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\Tests\user\Traits\UserCreationTrait;
+use Symfony\Component\HttpFoundation\JsonResponse;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Tests the ApiContentControllers::list() method.
+ *
+ * @group experience_builder
+ * @coversDefaultClass \Drupal\experience_builder\Controller\ApiContentControllers
+ */
+class ApiContentControllersListTest extends KernelTestBase {
+  use UserCreationTrait;
+
+  /**
+   * Base path for the content API endpoint.
+   *
+   * @todo Strip `xb_page` in https://www.drupal.org/i/3498525, and add test coverage for other content entity types.
+   */
+  private const API_BASE_PATH = '/api/xb/content/xb_page';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'experience_builder',
+    'system',
+    'xb_test_page',
+    'user',
+    'field',
+    'text',
+    'filter',
+    'path_alias',
+    'path',
+    'media',
+    'image',
+  ];
+
+  /**
+   * The API Content controller service.
+   *
+   * @var \Drupal\experience_builder\Controller\ApiContentControllers
+   */
+  protected ApiContentControllers $apiContentController;
+
+  /**
+   * The AutoSaveManager service.
+   *
+   * @var \Drupal\experience_builder\AutoSave\AutoSaveManager
+   */
+  protected AutoSaveManager $autoSaveManager;
+
+  /**
+   * The entity type manager service.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected EntityTypeManagerInterface $entityTypeManager;
+
+  /**
+   * The transliteration service.
+   *
+   * @var \Drupal\Component\Transliteration\TransliterationInterface
+   */
+  protected TransliterationInterface $transliteration;
+
+  /**
+   * Test pages.
+   *
+   * @var \Drupal\experience_builder\Entity\Page[]
+   */
+  protected $pages = [];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    // Skip the test for PostgreSQL and SQLite databases.
+    $database_type = $this->container->get('database')->driver();
+    if ($database_type === 'pgsql' || $database_type === 'sqlite') {
+      $this->markTestSkipped('This test is only for MySQL/MariaDB database drivers.');
+    }
+
+    $this->installEntitySchema('user');
+    $this->installEntitySchema('xb_page');
+    $this->installEntitySchema('path_alias');
+    $this->installEntitySchema('media');
+    $this->installConfig(['system', 'field', 'filter', 'path_alias']);
+
+    // Create a user with appropriate permissions.
+    $this->setUpCurrentUser([], ['access content', Page::CREATE_PERMISSION, Page::EDIT_PERMISSION, Page::DELETE_PERMISSION]);
+
+    $this->apiContentController = $this->container->get(ApiContentControllers::class);
+    $this->autoSaveManager = $this->container->get(AutoSaveManager::class);
+    $this->entityTypeManager = $this->container->get(EntityTypeManagerInterface::class);
+    $this->transliteration = $this->container->get('transliteration');
+
+    $this->createTestPages();
+  }
+
+  /**
+   * Creates test pages for the tests.
+   */
+  protected function createTestPages(): void {
+    $page1 = Page::create([
+      'title' => "Published XB Page",
+      'status' => TRUE,
+      'path' => ['alias' => "/page-1"],
+    ]);
+    $page1->save();
+    $this->pages['published'] = $page1;
+
+    $page2 = Page::create([
+      'title' => "Unpublished XB Page",
+      'status' => FALSE,
+    ]);
+    $page2->save();
+    $this->pages['unpublished'] = $page2;
+
+    // Create page with unique searchable title.
+    $page3 = Page::create([
+      'title' => "UniqueSearchTerm XB Page",
+      'status' => TRUE,
+      'path' => ['alias' => "/page-3"],
+    ]);
+    $page3->save();
+    $this->pages['searchable'] = $page3;
+
+    // Create a page with diacritical marks (accents) in title.
+    $page4 = Page::create([
+      'title' => "Gábor Hojtsy Page",
+      'status' => TRUE,
+      'path' => ['alias' => "/page-4"],
+    ]);
+    $page4->save();
+    $this->pages['accented'] = $page4;
+  }
+
+  /**
+   * Creates auto-save data for an entity.
+   *
+   * @param \Drupal\Core\Entity\ContentEntityInterface $entity
+   *   The entity to create auto-save data for.
+   * @param string $label
+   *   The label to use in auto-save data.
+   * @param string|null $path
+   *   The path alias to use in auto-save data.
+   */
+  protected function createAutoSaveData(ContentEntityInterface $entity, string $label, ?string $path = NULL): void {
+    $autoSaveEntity = $entity::create($entity->toArray());
+    $autoSaveEntity->set('title', $label);
+    if ($path !== NULL) {
+      $autoSaveEntity->set('path', ['alias' => $path]);
+    }
+    $this->autoSaveManager->saveEntity($autoSaveEntity);
+  }
+
+  /**
+   * Helper method to execute list request and return parsed response data.
+   *
+   * @param array $query
+   *   Optional query parameters.
+   *
+   * @return array
+   *   Parsed JSON response data.
+   */
+  protected function executeListRequest(array $query = []): array {
+    $request = Request::create(self::API_BASE_PATH, 'GET', $query);
+    $response = $this->apiContentController->list(Page::ENTITY_TYPE_ID, $request);
+    self::assertInstanceOf(JsonResponse::class, $response);
+
+    $content = $response->getContent();
+    self::assertNotEmpty($content);
+
+    return json_decode($content, TRUE);
+  }
+
+  /**
+   * Helper method to validate page data in response.
+   *
+   * @param array $response_data
+   *   The response data to validate.
+   * @param array $expected_search_result_data
+   *   Expected search result data to validate against.
+   * @param array $expected_auto_save_data
+   *   Optional auto-save data to validate.
+   */
+  protected function assertValidPageData(array $response_data, array $expected_search_result_data, array $expected_auto_save_data = []): void {
+    // Assert that all expected fields are present and correct
+    foreach ($expected_search_result_data as $key => $expected_value) {
+      self::assertArrayHasKey($key, $response_data, "Response should contain key: {$key}");
+      self::assertSame($expected_value, $response_data[$key], "Value for {$key} should match expected value");
+    }
+
+    if (!empty($expected_auto_save_data['label'])) {
+      self::assertSame($expected_auto_save_data['label'], $response_data['autoSaveLabel']);
+    }
+
+    if (isset($expected_auto_save_data['path'])) {
+      self::assertSame($expected_auto_save_data['path'], $response_data['autoSavePath']);
+    }
+  }
+
+  /**
+   * Tests basic list functionality with no search parameter.
+   *
+   * @covers ::list
+   */
+  public function testBasicList(): void {
+    $response = $this->apiContentController->list('xb_page', Request::create(self::API_BASE_PATH, 'GET'));
+
+    self::assertInstanceOf(JsonResponse::class, $response, 'Response should be a JsonResponse');
+    $content = $response->getContent();
+    self::assertNotEmpty($content, 'Response content should not be empty');
+
+    $data = json_decode($content, TRUE);
+    self::assertIsArray($data, 'Response data should be an array');
+
+    self::assertCount(count($this->pages), $data, 'Response should contain all test pages');
+
+    foreach ($this->pages as $page) {
+      $page_id = (int) $page->id();
+      self::assertArrayHasKey($page_id, $data, "Page {$page_id} should be in the results");
+      $this->assertValidPageData($data[$page_id], $this->getPageData($page));
+    }
+
+    $cache_metadata = $response->getCacheableMetadata();
+
+    // Expected cache tags should include entity list tag, auto-save tag,
+    // and individual entity tags
+    $expected_cache_tags = [
+      'xb_page_list',
+      AutoSaveManager::CACHE_TAG,
+    ];
+    $actual_cache_tags = $cache_metadata->getCacheTags();
+
+    $expected_cache_contexts = [
+      'user.permissions',
+      'url.query_args:search',
+    ];
+    $actual_cache_contexts = $cache_metadata->getCacheContexts();
+    self::assertEquals($expected_cache_tags, $actual_cache_tags, 'All expected cache tags should be present');
+    self::assertEquals($expected_cache_contexts, $actual_cache_contexts, 'All expected cache contexts should be present');
+  }
+
+  /**
+   * Tests list method with search parameter.
+   *
+   * @covers ::list
+   */
+  public function testListWithSearch(): void {
+    $data = $this->executeListRequest(['search' => 'UniqueSearchTerm']);
+    self::assertCount(1, $data, 'Search should return exactly one result');
+    $page_id = (int) $this->pages['searchable']->id();
+    self::assertArrayHasKey($page_id, $data, "Searchable page should be in the results");
+    $this->assertValidPageData($data[$page_id], $this->getPageData($this->pages['searchable']));
+
+    $data = $this->executeListRequest(['search' => 'XB Page']);
+    self::assertGreaterThan(1, count($data), 'Search should return multiple results');
+
+    $data = $this->executeListRequest(['search' => 'NoMatchingTerm']);
+    self::assertEmpty($data, 'Search with no matches should return empty array');
+
+    $data = $this->executeListRequest(['search' => 'uniquesearchterm']);
+    self::assertCount(1, $data, 'Search should be case-insensitive');
+
+    $data = $this->executeListRequest(['search' => 'Gábor']);
+    self::assertCount(1, $data, 'Search with accented character should match page');
+    $page_id = (int) $this->pages['accented']->id();
+    self::assertArrayHasKey($page_id, $data, "Accented page should be in the results");
+    $this->assertValidPageData($data[$page_id], $this->getPageData($this->pages['accented']));
+
+    $data = $this->executeListRequest(['search' => 'gabor']);
+    self::assertCount(1, $data, 'Search without accent should match page with accented character');
+    $page_id = (int) $this->pages['accented']->id();
+    self::assertArrayHasKey($page_id, $data, "Accented page should be in the results");
+    $this->assertValidPageData($data[$page_id], $this->getPageData($this->pages['accented']));
+
+    $data = $this->executeListRequest(['search' => 'puBliSHed']);
+    self::assertCount(2, $data, 'Search with mixed case should match published and unpublished page');
+  }
+
+  /**
+   * Tests search when no searchable content entities (currently only pages) exist yet.
+   *
+   * @covers ::list
+   */
+  public function testEmptyEntityList(): void {
+    foreach ($this->pages as $page) {
+      $page->delete();
+    }
+    $this->pages = [];
+
+    $data = $this->executeListRequest();
+    self::assertSame([], $data, 'Search should return empty array when no entities exist');
+
+    // Now create a temporary page with auto-save data and then delete it
+    // to test interaction with orphaned auto-save data
+    $temp_page = Page::create([
+      'title' => "Temporary Page for AutoSave",
+      'status' => TRUE,
+    ]);
+    $temp_page->save();
+
+    $this->createAutoSaveData($temp_page, "AutoSave Only Content", "/autosave-only");
+
+    // Verify auto-save data was created by checking that it would be found if entity existed
+    $temp_page_id = (int) $temp_page->id();
+    $data = $this->executeListRequest(['search' => 'AutoSave Only Content']);
+    self::assertCount(1, $data, 'Auto-save data should be found when entity exists');
+    self::assertArrayHasKey($temp_page_id, $data, 'Page with auto-save data should be in results');
+
+    // Now delete the page, leaving orphaned auto-save data
+    $temp_page->delete();
+
+    // Test search with no entities but orphaned auto-save data should return empty
+    $data = $this->executeListRequest(['search' => 'AutoSave Only Content']);
+    self::assertSame([], $data, 'Search should return empty results when auto-save data exists but no entities exist');
+
+    // Test search with general term should also return empty
+    $data = $this->executeListRequest(['search' => 'Temporary']);
+    self::assertSame([], $data, 'Search should return empty results when searching for deleted entity content');
+  }
+
+  /**
+   * Tests that search results are sorted by most recently updated first.
+   *
+   * @covers ::list
+   * @covers ::filterAndMergeIds
+   */
+  public function testSearchSortOrder(): void {
+    // Create test pages with the same search term but different titles
+    $pages_data = [
+      'page1' => "XB Search Term One",
+      'page2' => "XB Search Term Two",
+      'page3' => "XB Search Term Three",
+    ];
+
+    $page_ids = [];
+
+    foreach ($pages_data as $key => $title) {
+      $page = Page::create([
+        'title' => $title,
+        'status' => TRUE,
+      ]);
+      $page->save();
+      $this->pages[$key] = $page;
+      $page_ids[$key] = (int) $page->id();
+    }
+
+    // Update the pages in reverse order to change their revision timestamps
+    // This ensures page1 is most recently updated, followed by page2, then page3
+    $update_order = array_reverse(array_keys($pages_data));
+    foreach ($update_order as $key) {
+      $this->pages[$key]->set('title', "{$pages_data[$key]} Updated");
+      $this->pages[$key]->save();
+    }
+
+    $data = $this->executeListRequest(['search' => 'XB Search Term']);
+    self::assertCount(3, $data, 'Search should return all three matching pages');
+
+    // Get the IDs in order they appear in the results
+    $result_ids = array_map(function ($item) {
+      return $item['id'];
+    }, $data);
+
+    $result_ids = array_keys($result_ids);
+
+    // Verify the order is by most recently updated (page1, page2, page3)
+    self::assertSame($page_ids['page1'], $result_ids[2]);
+    self::assertSame($page_ids['page2'], $result_ids[1]);
+    self::assertSame($page_ids['page3'], $result_ids[0]);
+  }
+
+  /**
+   * Tests auto-save entries in search results.
+   *
+   * @covers ::list
+   * @covers ::filterAndMergeIds
+   */
+  public function testSearchWithAutoSave(): void {
+    $page = Page::create([
+      'title' => "Original Title Page",
+      'status' => TRUE,
+      'path' => ['alias' => "/original-page"],
+    ]);
+    $page->save();
+
+    $this->createAutoSaveData($page, "AutoSave SearchTerm Title", "/autosave-path");
+    $data = $this->executeListRequest(['search' => 'AutoSave SearchTerm']);
+    self::assertCount(1, $data, 'Search should find the page with matching auto-save data');
+
+    // Verify that both original and auto-save data are correctly included
+    $page_id = (int) $page->id();
+    self::assertArrayHasKey($page_id, $data);
+    self::assertSame($page_id, $data[$page_id]['id']);
+    self::assertSame($page->label(), $data[$page_id]['title']);
+    self::assertSame("AutoSave SearchTerm Title", $data[$page_id]['autoSaveLabel']);
+    self::assertSame("/autosave-path", $data[$page_id]['autoSavePath']);
+
+    $data = $this->executeListRequest(['search' => 'autosave searchterm']);
+    self::assertCount(1, $data, 'Search should be case-insensitive for auto-save data');
+
+    $data = $this->executeListRequest(['search' => 'Original Title']);
+    self::assertCount(1, $data, 'Search should find original title when auto-save exists');
+  }
+
+  /**
+   * Extracts essential data from a Page entity for test assertions.
+   *
+   * @param \Drupal\experience_builder\Entity\Page $page
+   *   The Page entity to extract data from.
+   *
+   * @return array
+   *   An array containing the page's ID, title, status, and path.
+   */
+  private function getPageData(Page $page) {
+    return [
+      'id' => (int) $page->id(),
+      'title' => $page->label(),
+      'status' => $page->isPublished(),
+      'path' => $page->toUrl()->toString(),
+    ];
+  }
+
+}
diff --git a/ui/src/components/navigation/Navigation.tsx b/ui/src/components/navigation/Navigation.tsx
index 382b12f9be18786c8ca4fa0c6d8f4e4e3fe02f9c..3175637f86e4d1178678f9aebb1f406de281f7ab 100644
--- a/ui/src/components/navigation/Navigation.tsx
+++ b/ui/src/components/navigation/Navigation.tsx
@@ -42,7 +42,7 @@ const ContentGroup = ({
 }) => {
   if (items.length === 0) {
     return (
-      <Callout.Root size="1" color="gray">
+      <Callout.Root size="1" color="gray" data-testid="xb-navigation-results">
         <Callout.Icon>
           <InfoCircledIcon />
         </Callout.Icon>
@@ -56,12 +56,18 @@ const ContentGroup = ({
       <Heading as="h5" size="1" color="gray">
         {title}
       </Heading>
-      <Flex direction="column" gap="2" mt="2">
+      <Flex
+        data-testid="xb-navigation-results"
+        direction="column"
+        gap="2"
+        mt="2"
+      >
         {items.map((item) => {
           return (
             <Flex
               direction={'row'}
               align={'center'}
+              role={'list'}
               mr="4"
               p="1"
               pr="2"
@@ -70,6 +76,7 @@ const ContentGroup = ({
               data-xb-page-id={item.id}
             >
               <Flex
+                role={'listitem'}
                 className={styles.pageLink}
                 flexGrow="1"
                 onClick={onSelect ? () => onSelect(item) : undefined}
@@ -224,7 +231,7 @@ const Navigation = ({
       <Flex direction="row" gap="2" mb="4">
         <form
           className={styles.search}
-          onSubmit={(event: FormEvent<HTMLFormElement>) => {
+          onChange={(event: FormEvent<HTMLFormElement>) => {
             event.preventDefault();
             const form = event.currentTarget;
             const formElements = form.elements as typeof form.elements & {
@@ -237,6 +244,7 @@ const Navigation = ({
             id="xb-navigation-search"
             placeholder="Search…"
             radius="medium"
+            aria-label="Search Pages"
             size="1"
           >
             <TextField.Slot>
diff --git a/ui/src/components/pageInfo/PageInfo.tsx b/ui/src/components/pageInfo/PageInfo.tsx
index b874ce0025a2232e2785d2e587f5e40752d97f31..9bf9aaea0f0e503f2cc23ac512e3480ff7d23341 100644
--- a/ui/src/components/pageInfo/PageInfo.tsx
+++ b/ui/src/components/pageInfo/PageInfo.tsx
@@ -14,6 +14,7 @@ import {
   Popover,
 } from '@radix-ui/themes';
 import { useAppSelector } from '@/app/hooks';
+import { debounce } from 'lodash';
 import { selectPageData } from '@/features/pageData/pageDataSlice';
 import type { ReactElement } from 'react';
 import { useEffect } from 'react';
@@ -30,6 +31,7 @@ import {
 } from '@/services/content';
 import useEditorNavigation from '@/hooks/useEditorNavigation';
 import { useErrorBoundary } from 'react-error-boundary';
+import { useState } from 'react';
 import type { ContentStub } from '@/types/Content';
 import ErrorCard from '@/components/error/ErrorCard';
 import PageStatus from '@/components/pageStatus/PageStatus';
@@ -68,12 +70,16 @@ const PageInfo = () => {
   const entity_form_fields = useAppSelector(selectPageData);
   const title =
     entity_form_fields[`${xbSettings.entityTypeKeys.label}[0][value]`];
-
+  const [searchTerm, setSearchTerm] = useState<string>('');
   const {
     data: pageItems,
     isLoading: isPageItemsLoading,
     error: pageItemsError,
-  } = useGetContentListQuery('xb_page');
+  } = useGetContentListQuery({
+    // @todo Generalize in https://www.drupal.org/i/3498525
+    entityType: 'xb_page',
+    search: searchTerm,
+  });
   const entityId = useAppSelector(selectEntityId);
   const entityType = useAppSelector(selectEntityType);
   const baseUrl = getBaseUrl();
@@ -136,6 +142,21 @@ const PageInfo = () => {
     setEditorEntity('xb_page', String(item.id));
   }
 
+  useEffect(() => {
+    const debouncedSearch = debounce((term: string) => {
+      if (term.length === 0 || term.length >= 3) {
+        setSearchTerm(term);
+      }
+    }, 400);
+
+    // Set up the debounced search effect
+    debouncedSearch(searchTerm);
+
+    return () => {
+      debouncedSearch.cancel();
+    };
+  }, [searchTerm]);
+
   useEffect(() => {
     if (isCreateContentSuccess) {
       setEditorEntity(
@@ -188,7 +209,7 @@ const PageInfo = () => {
                   loading={isPageItemsLoading}
                   items={pageItems || []}
                   onNewPage={handleNewPage}
-                  onSearch={handleNonWorkingBtn}
+                  onSearch={(value) => setSearchTerm(value)}
                   onSelect={handleOnSelect}
                   onRename={handleNonWorkingBtn}
                   onDuplicate={handleDuplication}
diff --git a/ui/src/services/content.ts b/ui/src/services/content.ts
index 820be5ee8322dec19c55b94f5508d2ecf36c1ce5..4b9ac8742d8bcbd1e70d78e24c128b010441f153 100644
--- a/ui/src/services/content.ts
+++ b/ui/src/services/content.ts
@@ -20,15 +20,36 @@ export interface CreateContentRequest {
   entity_id?: string;
   entity_type: string;
 }
+
+export interface ContentListParams {
+  entityType: string;
+  search?: string;
+}
+
 export const contentApi = createApi({
   reducerPath: 'contentApi',
   baseQuery,
   tagTypes: ['Content'],
   endpoints: (builder) => ({
-    getContentList: builder.query<ContentStub[], string>({
-      query: (entityType) => `/xb/api/v0/content/${entityType}`,
+    getContentList: builder.query<ContentStub[], ContentListParams>({
+      query: ({ entityType, search }) => {
+        const params = new URLSearchParams();
+        if (search) {
+          const normalizedSearch = search.toLowerCase().trim();
+          params.append('search', normalizedSearch);
+        }
+        return {
+          url: `/xb/api/v0/content/${entityType}`,
+          params: search ? params : undefined,
+        };
+      },
       transformResponse: (response: ContentListResponse) => {
-        return Object.values(response);
+        // Convert response object to array while preserving order
+        // Sort by ID in descending order to maintain newest first order
+        return Object.values(response).sort((a, b) => {
+          // Convert IDs to numbers for comparison, newer IDs are typically higher
+          return Number(b.id) - Number(a.id);
+        });
       },
       providesTags: [{ type: 'Content', id: 'LIST' }],
     }),
diff --git a/ui/tests/e2e/navigation.cy.js b/ui/tests/e2e/navigation.cy.js
index 22b6230aeef49dcacf5d4f20c795ae83b8e68d10..01e81f0170eb53e0efb4d0ba4d4fe58e2b6c0e18 100644
--- a/ui/tests/e2e/navigation.cy.js
+++ b/ui/tests/e2e/navigation.cy.js
@@ -1,5 +1,6 @@
 const navigationButtonTestId = 'xb-navigation-button';
 const navigationContentTestId = 'xb-navigation-content';
+const navigationResultsTestId = 'xb-navigation-results';
 const navigationNewButtonTestId = 'xb-navigation-new-button';
 const navigationNewPageButtonTestId = 'xb-navigation-new-page-button';
 
@@ -43,6 +44,41 @@ describe('Navigation functionality', () => {
       .and('contain.text', 'Empty Page');
   });
 
+  it('Verify if search works', () => {
+    cy.loadURLandWaitForXBLoaded({ url: 'xb/xb_page/1' });
+    cy.findByTestId(navigationButtonTestId).click();
+    cy.findByLabelText('Search Pages').clear();
+    cy.findByLabelText('Search Pages').type('ome');
+    cy.findByTestId(navigationResultsTestId)
+      .findAllByRole('listitem')
+      .should(($children) => {
+        const count = $children.length;
+        expect(count).to.be.eq(1);
+        expect($children.text()).to.contain('Homepage');
+        expect($children.text()).to.not.contain('Empty Page');
+      });
+    cy.findByLabelText('Search Pages').clear();
+    cy.findByLabelText('Search Pages').type('NonExistentPage');
+    cy.findByTestId(navigationResultsTestId)
+      .findAllByRole('listitem')
+      .should(($children) => {
+        const count = $children.length;
+        expect(count).to.be.eq(0);
+      });
+    cy.findByTestId(navigationResultsTestId)
+      .findByText('No pages found', { exact: false })
+      .should('exist');
+    cy.findByLabelText('Search Pages').clear();
+    cy.findByTestId(navigationResultsTestId)
+      .findAllByRole('listitem')
+      .should(($children) => {
+        const count = $children.length;
+        expect(count).to.be.eq(3);
+        expect($children.text()).to.contain('Homepage');
+        expect($children.text()).to.contain('Empty Page');
+      });
+  });
+
   it('Clicking "New page" creates a new page and navigates to it', () => {
     cy.loadURLandWaitForXBLoaded({ url: 'xb/xb_page/1' });
 
@@ -214,8 +250,8 @@ describe('Navigation functionality', () => {
       cy.wait('@deletePage').its('response.statusCode').should('eq', 204);
       // Wait for the GET request to the list endpoint which should be triggered by the deletion of a page.
       cy.wait('@getList').its('response.statusCode').should('eq', 200);
-      cy.url().should('not.contain', '/xb/xb_page/4');
-      cy.url().should('contain', '/xb/xb_page/1');
+      cy.url().should('not.contain', '/xb/xb_page/3');
+      cy.url().should('contain', '/xb/xb_page/5');
       cy.findByTestId(navigationButtonTestId).click();
       cy.findByTestId(navigationContentTestId)
         .should('exist')