diff --git a/experience_builder.routing.yml b/experience_builder.routing.yml
index 9736b30da7e44e96553f047d779e8217e79ef82d..fdef394b93a0a247beab34babefb65e1e0ba98c6 100644
--- a/experience_builder.routing.yml
+++ b/experience_builder.routing.yml
@@ -38,7 +38,7 @@ experience_builder.api.content.update:
 experience_builder.api.content.create:
   path: '/xb/api/content-create/xb_page'
   defaults:
-    _controller: 'Drupal\experience_builder\Controller\ApiContentCreateXbPage'
+    _controller: 'Drupal\experience_builder\Controller\ApiContentControllers::post'
   requirements:
     _entity_create_access: 'xb_page:xb_page'
   methods: ['POST']
@@ -70,6 +70,16 @@ entity.component.delete_form:
   requirements:
     _entity_access: 'component.delete'
 
+# @todo https://www.drupal.org/i/3498525 should generalize this to all eligible content entity types by using a route callback
+experience_builder.api.content.list:
+  path: '/xb/api/content/xb_page'
+  defaults:
+    _controller: 'Drupal\experience_builder\Controller\ApiContentControllers::list'
+  requirements:
+    # @todo update in https://www.drupal.org/i/3502048
+    _permission: 'administer xb_page'
+  methods: [GET]
+
 experience_builder.api.component_inputs_form:
   path: '/xb-field-form/{entity_type}/{entity}'
   defaults:
diff --git a/experience_builder.services.yml b/experience_builder.services.yml
index 6cd42453add27fb684cc8817098b4c392d1117ef..9e00b4b63448227c88013a30a41cc08c0e9566e5 100644
--- a/experience_builder.services.yml
+++ b/experience_builder.services.yml
@@ -97,7 +97,7 @@ services:
   Drupal\experience_builder\Controller\ApiLogController: {}
   Drupal\experience_builder\Controller\ApiPreviewController: {}
   Drupal\experience_builder\Controller\ApiContentUpdateForDemoController: {}
-  Drupal\experience_builder\Controller\ApiContentCreateXbPage: {}
+  Drupal\experience_builder\Controller\ApiContentControllers: {}
   Drupal\experience_builder\Controller\ComponentStatusController: {}
   Drupal\experience_builder\Controller\ExperienceBuilderController: {}
   Drupal\experience_builder\Controller\ApiPublishAllController: {}
diff --git a/openapi.yml b/openapi.yml
index c2ef8a258ed517c3fa95e8f2f375400ee644c2fa..a36d132547c49d5397984203712e3c26d3c0e27a 100644
--- a/openapi.yml
+++ b/openapi.yml
@@ -30,6 +30,50 @@ info:
 # @see https://spec.openapis.org/oas/v3.1.0.html#paths-object
 # @see https://spec.openapis.org/oas/v3.1.0.html#path-item-object
 paths:
+  '/xb/api/content/xb_page':
+    get:
+      description: Provides api for page listing.
+      responses:
+        200:
+          description: Page list generated successfully.
+          content:
+            application/json:
+              schema:
+                type: object
+                minProperties: 0
+                additionalProperties: false
+                patternProperties:
+                  '^[0-9]$':
+                    type: object
+                    required:
+                      - id
+                      - title
+                      - status
+                      - path
+                    properties:
+                      id:
+                        type: integer
+                        description: The page ID.
+                      title:
+                        type: string
+                        description: The page title.
+                      status:
+                        type: boolean
+                        description: The page status.
+              examples:
+                pageList:
+                  value:
+                    '1':
+                      id: 1
+                      title: Home
+                      status: true
+                      path: /home
+                    '2':
+                      id: 2
+                      title: About
+                      status: false
+                      path: /about
+
   '/api/layout/{entityTypeId}/{entityId}':
     description: TODO
     get:
diff --git a/src/Controller/ApiContentControllers.php b/src/Controller/ApiContentControllers.php
new file mode 100644
index 0000000000000000000000000000000000000000..735c01d92cfe855d8587dea38979fb3e8e62a9b3
--- /dev/null
+++ b/src/Controller/ApiContentControllers.php
@@ -0,0 +1,116 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\experience_builder\Controller;
+
+use Drupal\Core\Cache\CacheableJsonResponse;
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Entity\Query\QueryInterface;
+use Drupal\Core\Render\RenderContext;
+use Drupal\Core\Render\RendererInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Symfony\Component\HttpFoundation\JsonResponse;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * HTTP API for interacting with XB-eligible Content entity types.
+ *
+ * @internal This HTTP API is intended only for the XB UI. These controllers
+ *   and associated routes may change at any time.
+ *
+ * @todo https://www.drupal.org/i/3498525 should generalize this to all eligible content entity types
+ */
+final class ApiContentControllers {
+
+  use StringTranslationTrait;
+
+  public function __construct(
+    private readonly EntityTypeManagerInterface $entityTypeManager,
+    private readonly RendererInterface $renderer,
+  ) {}
+
+  public function post(): JsonResponse {
+    // Note: this intentionally does not catch content entity type storage
+    // handler exceptions: the generic XB API exception subscriber handles them.
+    // @see \Drupal\experience_builder\EventSubscriber\ApiExceptionSubscriber
+    $page = $this->entityTypeManager->getStorage('xb_page')->create([
+      'title' => $this->t('Untitled page'),
+      'status' => FALSE,
+    ]);
+    $page->save();
+
+    return new JsonResponse([
+      'entity_type' => $page->getEntityTypeId(),
+      'entity_id' => $page->id(),
+    ], RESPONSE::HTTP_CREATED);
+  }
+
+  /**
+   * Returns a list of XB Page content entities, with only high-level metadata.
+   *
+   * TRICKY: there are reasons XB has its own internal HTTP API rather than
+   * using Drupal core's JSON:API. As soon as this method is updated to return
+   * all fields instead of just high-level metadata, those reasons may start to
+   * outweigh the downsides of adding a dependency on JSON:API.
+   *
+   * @see https://www.drupal.org/project/experience_builder/issues/3500052#comment-15966496
+   */
+  public function list(): CacheableJsonResponse {
+    // @todo introduce pagination in https://www.drupal.org/i/3502691
+    $storage = $this->entityTypeManager->getStorage('xb_page');
+    $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.
+    $entity_query = $storage->getQuery()->accessCheck(TRUE);
+    $ids = $this->executeQueryInRenderContext($entity_query, $query_cacheability);
+    /** @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);
+      $content_list[$id] = [
+        'id' => $id,
+        'title' => $content_entity->label(),
+        'status' => $content_entity->isPublished(),
+        'path' => $generated_url->getGeneratedUrl(),
+      ];
+      $url_cacheability->addCacheableDependency($generated_url);
+    }
+    $json_response = new CacheableJsonResponse($content_list);
+    // @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);
+    return $json_response;
+  }
+
+  /**
+   * Executes the query in a render context, to catch bubbled cacheability.
+   *
+   * @param \Drupal\Core\Entity\Query\QueryInterface $query
+   *   The query to execute to get the return results.
+   * @param \Drupal\Core\Cache\CacheableMetadata $query_cacheability
+   *   The value object to carry the query cacheability.
+   *
+   * @return array
+   *   Returns IDs of entities.
+   *
+   * @see \Drupal\jsonapi\Controller\EntityResource::executeQueryInRenderContext()
+   */
+  private function executeQueryInRenderContext(QueryInterface $query, CacheableMetadata $query_cacheability) : array {
+    $context = new RenderContext();
+    $results = $this->renderer->executeInRenderContext($context, function () use ($query) {
+      return $query->execute();
+    });
+    if (!$context->isEmpty()) {
+      $query_cacheability->addCacheableDependency($context->pop());
+    }
+    return $results;
+  }
+
+}
diff --git a/src/Controller/ApiContentCreateXbPage.php b/src/Controller/ApiContentCreateXbPage.php
deleted file mode 100644
index fd4a54fd822a1fbb6834021a1c31e3bbf0a08fcf..0000000000000000000000000000000000000000
--- a/src/Controller/ApiContentCreateXbPage.php
+++ /dev/null
@@ -1,46 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Drupal\experience_builder\Controller;
-
-use Drupal\Core\Entity\EntityTypeManagerInterface;
-use Drupal\Core\StringTranslation\StringTranslationTrait;
-use Symfony\Component\HttpFoundation\JsonResponse;
-use Symfony\Component\HttpFoundation\Response;
-
-/**
- * Creates new xb_page entity.
- *
- * New entity must be unpublished and have a hardcoded initial title.
- *
- * @internal This HTTP API is intended only for the XB UI. These controllers
- *   and associated routes may change at any time.
- *
- * @todo https://www.drupal.org/i/3498525 should generalize this to all eligible content entity types
- */
-final class ApiContentCreateXbPage {
-
-  use StringTranslationTrait;
-
-  public function __construct(
-    private readonly EntityTypeManagerInterface $entityTypeManager,
-  ) {}
-
-  public function __invoke(): JsonResponse {
-    // Note: this intentionally does not catch content entity type storage
-    // handler exceptions: the generic XB API exception subscriber handles them.
-    // @see \Drupal\experience_builder\EventSubscriber\ApiExceptionSubscriber
-    $page = $this->entityTypeManager->getStorage('xb_page')->create([
-      'title' => $this->t('Untitled page'),
-      'status' => FALSE,
-    ]);
-    $page->save();
-
-    return new JsonResponse([
-      'entity_type' => $page->getEntityTypeId(),
-      'entity_id' => $page->id(),
-    ], RESPONSE::HTTP_CREATED);
-  }
-
-}
diff --git a/tests/src/Functional/HttpApiTestBase.php b/tests/src/Functional/HttpApiTestBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..6cfaba4a54ac6e7b8fbfe7fa18075ec682c98ba3
--- /dev/null
+++ b/tests/src/Functional/HttpApiTestBase.php
@@ -0,0 +1,62 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\experience_builder\Functional;
+
+use Drupal\Core\Url;
+use Drupal\Tests\ApiRequestTrait;
+use Drupal\Tests\BrowserTestBase;
+
+abstract class HttpApiTestBase extends BrowserTestBase {
+
+  use ApiRequestTrait;
+
+  /**
+   * @return ?array
+   *   The decoded JSON response, or NULL if there is no body.
+   *
+   * @throws \JsonException
+   */
+  protected function assertExpectedResponse(string $method, Url $url, array $request_options, int $expected_status, ?array $expected_cache_contexts, ?array $expected_cache_tags, ?string $expected_page_cache, ?string $expected_dynamic_page_cache, array $additional_expected_response_headers = []): ?array {
+    $request_options['headers']['X-CSRF-Token'] = $this->drupalGet('session/token');
+    $response = $this->makeApiRequest($method, $url, $request_options);
+    $body = (string) $response->getBody();
+    $this->assertSame($expected_status, $response->getStatusCode(), $body);
+
+    // Cacheability headers.
+    $this->assertSame($expected_page_cache !== NULL, $response->hasHeader('X-Drupal-Cache'));
+    if ($expected_page_cache !== NULL) {
+      $this->assertSame($expected_page_cache, $response->getHeader('X-Drupal-Cache')[0], 'Page Cache response header');
+    }
+    $this->assertSame($expected_dynamic_page_cache !== NULL, $response->hasHeader('X-Drupal-Dynamic-Cache'));
+    if ($expected_dynamic_page_cache !== NULL) {
+      $this->assertSame($expected_dynamic_page_cache, $response->getHeader('X-Drupal-Dynamic-Cache')[0], 'Dynamic Page Cache response header');
+    }
+    $this->assertSame($expected_cache_tags !== NULL, $response->hasHeader('X-Drupal-Cache-Tags'));
+    if ($expected_cache_tags !== NULL) {
+      $this->assertEqualsCanonicalizing($expected_cache_tags, explode(' ', $response->getHeader('X-Drupal-Cache-Tags')[0]));
+    }
+    $this->assertSame($expected_cache_contexts !== NULL, $response->hasHeader('X-Drupal-Cache-Contexts'));
+    if ($expected_cache_contexts !== NULL) {
+      $this->assertEqualsCanonicalizing($expected_cache_contexts, explode(' ', $response->getHeader('X-Drupal-Cache-Contexts')[0]));
+    }
+
+    // Optionally, additional expected response headers can be validated.
+    if ($additional_expected_response_headers) {
+      foreach ($additional_expected_response_headers as $header_name => $expected_value) {
+        $this->assertSame($response->getHeader($header_name), $expected_value);
+      }
+    }
+
+    // Response must at least be decodable JSON, let this throw an exception
+    // otherwise. (Assertions of the contents happen outside this method.)
+    if ($body === '') {
+      return NULL;
+    }
+    $json = json_decode($body, associative: TRUE, flags: JSON_THROW_ON_ERROR);
+
+    return $json;
+  }
+
+}
diff --git a/tests/src/Functional/XbConfigEntityHttpApiTest.php b/tests/src/Functional/XbConfigEntityHttpApiTest.php
index 18baf91e7817d9aa00468eddd909a9f310e37b0d..26de0072c0d652bba46cbd7012150e03d8feed2c 100644
--- a/tests/src/Functional/XbConfigEntityHttpApiTest.php
+++ b/tests/src/Functional/XbConfigEntityHttpApiTest.php
@@ -2,10 +2,10 @@
 
 declare(strict_types=1);
 
+namespace Drupal\Tests\experience_builder\Functional;
+
 use Drupal\Core\Url;
 use Drupal\system\Entity\Menu;
-use Drupal\Tests\ApiRequestTrait;
-use Drupal\Tests\BrowserTestBase;
 use Drupal\Tests\experience_builder\Traits\ContribStrictConfigSchemaTestTrait;
 use Drupal\Tests\experience_builder\Traits\TestDataUtilitiesTrait;
 use Drupal\user\UserInterface;
@@ -16,9 +16,8 @@ use GuzzleHttp\RequestOptions;
  * @group experience_builder
  * @internal
  */
-class XbConfigEntityHttpApiTest extends BrowserTestBase {
+class XbConfigEntityHttpApiTest extends HttpApiTestBase {
 
-  use ApiRequestTrait;
   use TestDataUtilitiesTrait;
   use ContribStrictConfigSchemaTestTrait;
 
@@ -632,51 +631,4 @@ class XbConfigEntityHttpApiTest extends BrowserTestBase {
     $this->assertSame([], $individual_body);
   }
 
-  /**
-   * @return ?array
-   *   The decoded JSON response, or NULL if there is no body.
-   *
-   * @throws \JsonException
-   */
-  private function assertExpectedResponse(string $method, Url $url, array $request_options, int $expected_status, ?array $expected_cache_contexts, ?array $expected_cache_tags, ?string $expected_page_cache, ?string $expected_dynamic_page_cache, array $additional_expected_response_headers = []): ?array {
-    $request_options['headers']['X-CSRF-Token'] = $this->drupalGet('session/token');
-    $response = $this->makeApiRequest($method, $url, $request_options);
-    $body = (string) $response->getBody();
-    $this->assertSame($expected_status, $response->getStatusCode(), $body);
-
-    // Cacheability headers.
-    $this->assertSame($expected_page_cache !== NULL, $response->hasHeader('X-Drupal-Cache'));
-    if ($expected_page_cache !== NULL) {
-      $this->assertSame($expected_page_cache, $response->getHeader('X-Drupal-Cache')[0], 'Page Cache response header');
-    }
-    $this->assertSame($expected_dynamic_page_cache !== NULL, $response->hasHeader('X-Drupal-Dynamic-Cache'));
-    if ($expected_dynamic_page_cache !== NULL) {
-      $this->assertSame($expected_dynamic_page_cache, $response->getHeader('X-Drupal-Dynamic-Cache')[0], 'Dynamic Page Cache response header');
-    }
-    $this->assertSame($expected_cache_tags !== NULL, $response->hasHeader('X-Drupal-Cache-Tags'));
-    if ($expected_cache_tags !== NULL) {
-      $this->assertEqualsCanonicalizing($expected_cache_tags, explode(' ', $response->getHeader('X-Drupal-Cache-Tags')[0]));
-    }
-    $this->assertSame($expected_cache_contexts !== NULL, $response->hasHeader('X-Drupal-Cache-Contexts'));
-    if ($expected_cache_contexts !== NULL) {
-      $this->assertEqualsCanonicalizing($expected_cache_contexts, explode(' ', $response->getHeader('X-Drupal-Cache-Contexts')[0]));
-    }
-
-    // Optionally, additional expected response headers can be validated.
-    if ($additional_expected_response_headers) {
-      foreach ($additional_expected_response_headers as $header_name => $expected_value) {
-        $this->assertSame($response->getHeader($header_name), $expected_value);
-      }
-    }
-
-    // Response must at least be decodable JSON, let this throw an exception
-    // otherwise. (Assertions of the contents happen outside this method.)
-    if ($body === '') {
-      return NULL;
-    }
-    $json = json_decode($body, associative: TRUE, flags: JSON_THROW_ON_ERROR);
-
-    return $json;
-  }
-
 }
diff --git a/tests/src/Functional/XbContentEntityHttpApiTest.php b/tests/src/Functional/XbContentEntityHttpApiTest.php
index 197a5b335f8dcf6d851057c65d53daf4f8432a1e..2e71d19ed3b1bd0b85b8bc88a6511797962d6821 100644
--- a/tests/src/Functional/XbContentEntityHttpApiTest.php
+++ b/tests/src/Functional/XbContentEntityHttpApiTest.php
@@ -5,20 +5,17 @@ declare(strict_types=1);
 namespace Drupal\Tests\experience_builder\Functional;
 
 use Drupal\Core\Url;
-use Drupal\Tests\ApiRequestTrait;
-use Drupal\Tests\BrowserTestBase;
+use Drupal\experience_builder\Entity\Page;
 use Drupal\user\Entity\Role;
 use Drupal\user\UserInterface;
 use GuzzleHttp\RequestOptions;
 
 /**
- * @covers \Drupal\experience_builder\Controller\ApiContentCreateXbPage
+ * @covers \Drupal\experience_builder\Controller\ApiContentControllers
  * @group experience_builder
  * @internal
  */
-final class XbContentEntityHttpApiTest extends BrowserTestBase {
-
-  use ApiRequestTrait;
+final class XbContentEntityHttpApiTest extends HttpApiTestBase {
 
   /**
    * {@inheritdoc}
@@ -32,7 +29,29 @@ final class XbContentEntityHttpApiTest extends BrowserTestBase {
    */
   protected $defaultTheme = 'stark';
 
-  public function test(): void {
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    Page::create([
+      'title' => "Page 1",
+      'status' => TRUE,
+      'path' => ['alias' => "/page-1"],
+    ])->save();
+    Page::create([
+      'title' => "Page 2",
+      'status' => FALSE,
+      'path' => ['alias' => "/page-2"],
+    ])->save();
+    Page::create([
+      'title' => "Page 3",
+      'status' => TRUE,
+      'path' => ['alias' => "/page-3"],
+    ])->save();
+  }
+
+  public function testPost(): void {
     $url = Url::fromUri('base:/xb/api/content-create/xb_page');
     $request_options = [
       RequestOptions::HEADERS => [
@@ -66,9 +85,58 @@ final class XbContentEntityHttpApiTest extends BrowserTestBase {
     $response = $this->makeApiRequest('POST', $url, $request_options);
     $this->assertSame(201, $response->getStatusCode());
     $this->assertSame(
-      '{"entity_type":"xb_page","entity_id":"1"}',
+      '{"entity_type":"xb_page","entity_id":"4"}',
       (string) $response->getBody()
     );
   }
 
+  public function testList(): void {
+    $url = Url::fromUri('base:/xb/api/content/xb_page');
+
+    // Anonymously: 403.
+    $body = $this->assertExpectedResponse('GET', $url, [], 403, ['user.permissions'], ['4xx-response', 'config:user.role.anonymous', 'http_response'], 'MISS', NULL);
+    $this->assertSame([
+      'message' => "The 'administer xb_page' permission is required.",
+    ], $body);
+
+    // User without permission.
+    $user = $this->createUser(['access content'], 'access_content_user');
+    assert($user instanceof UserInterface);
+    $this->drupalLogin($user);
+    $body = $this->assertExpectedResponse('GET', $url, [], 403, ['user.permissions'], ['4xx-response', 'http_response'], 'UNCACHEABLE (request policy)', NULL);
+    $this->assertSame([
+      'message' => "The 'administer xb_page' permission is required.",
+    ], $body);
+
+    // Authenticated, authorized: 200.
+    $user = $this->createUser(['administer xb_page'], 'administer_xb_page_user');
+    assert($user instanceof UserInterface);
+    $this->drupalLogin($user);
+    $body = $this->assertExpectedResponse('GET', $url, [], 200, ['user.permissions'], ['http_response', 'xb_page_list'], 'UNCACHEABLE (request policy)', 'MISS');
+    $this->assertEquals(
+      [
+        '1' => [
+          'id' => 1,
+          'title' => 'Page 1',
+          'status' => TRUE,
+          'path' => base_path() . 'page-1',
+        ],
+        '2' => [
+          'id' => 2,
+          'title' => 'Page 2',
+          'status' => FALSE,
+          'path' => base_path() . 'page-2',
+        ],
+        '3' => [
+          'id' => 3,
+          'title' => 'Page 3',
+          'status' => TRUE,
+          'path' => base_path() . 'page-3',
+        ],
+      ],
+      $body
+    );
+    $this->assertExpectedResponse('GET', $url, [], 200, ['user.permissions'], ['http_response', 'xb_page_list'], 'UNCACHEABLE (request policy)', 'HIT');
+  }
+
 }