diff --git a/app/Api/ApiEntityListFormatter.php b/app/Api/ApiEntityListFormatter.php
new file mode 100644
index 000000000..c170ecf0c
--- /dev/null
+++ b/app/Api/ApiEntityListFormatter.php
@@ -0,0 +1,107 @@
+<?php
+
+namespace BookStack\Api;
+
+use BookStack\Entities\Models\Entity;
+
+class ApiEntityListFormatter
+{
+    /**
+     * The list to be formatted.
+     * @var Entity[]
+     */
+    protected $list = [];
+
+    /**
+     * The fields to show in the formatted data.
+     * Can be a plain string array item for a direct model field (If existing on model).
+     * If the key is a string, with a callable value, the return value of the callable
+     * will be used for the resultant value. A null return value will omit the property.
+     * @var array<string|int, string|callable>
+     */
+    protected $fields = [
+        'id', 'name', 'slug', 'book_id', 'chapter_id',
+        'draft', 'template', 'created_at', 'updated_at',
+    ];
+
+    public function __construct(array $list)
+    {
+        $this->list = $list;
+
+        // Default dynamic fields
+        $this->withField('url', fn(Entity $entity) => $entity->getUrl());
+    }
+
+    /**
+     * Add a field to be used in the formatter, with the property using the given
+     * name and value being the return type of the given callback.
+     */
+    public function withField(string $property, callable $callback): self
+    {
+        $this->fields[$property] = $callback;
+        return $this;
+    }
+
+    /**
+     * Show the 'type' property in the response reflecting the entity type.
+     * EG: page, chapter, bookshelf, book
+     * To be included in results with non-pre-determined types.
+     */
+    public function withType(): self
+    {
+        $this->withField('type', fn(Entity $entity) => $entity->getType());
+        return $this;
+    }
+
+    /**
+     * Include tags in the formatted data.
+     */
+    public function withTags(): self
+    {
+        $this->withField('tags', fn(Entity $entity) => $entity->tags);
+        return $this;
+    }
+
+    /**
+     * Format the data and return an array of formatted content.
+     * @return array[]
+     */
+    public function format(): array
+    {
+        $results = [];
+
+        foreach ($this->list as $item) {
+            $results[] = $this->formatSingle($item);
+        }
+
+        return $results;
+    }
+
+    /**
+     * Format a single entity item to a plain array.
+     */
+    protected function formatSingle(Entity $entity): array
+    {
+        $result = [];
+        $values = (clone $entity)->toArray();
+
+        foreach ($this->fields as $field => $callback) {
+            if (is_string($callback)) {
+                $field = $callback;
+                if (!isset($values[$field])) {
+                    continue;
+                }
+                $value = $values[$field];
+            } else {
+                $value = $callback($entity);
+                if (is_null($value)) {
+                    continue;
+                }
+            }
+
+            $result[$field] = $value;
+        }
+
+        return $result;
+    }
+}
diff --git a/app/Entities/Tools/BookContents.php b/app/Entities/Tools/BookContents.php
index 6f11e8cbe..0ad424de2 100644
--- a/app/Entities/Tools/BookContents.php
+++ b/app/Entities/Tools/BookContents.php
@@ -11,22 +11,15 @@ use Illuminate\Support\Collection;
 
 class BookContents
 {
-    /**
-     * @var Book
-     */
-    protected $book;
+    protected Book $book;
 
-    /**
-     * BookContents constructor.
-     */
     public function __construct(Book $book)
     {
         $this->book = $book;
     }
 
     /**
-     * Get the current priority of the last item
-     * at the top-level of the book.
+     * Get the current priority of the last item at the top-level of the book.
      */
     public function getLastPriority(): int
     {
diff --git a/app/Http/Controllers/Api/BookApiController.php b/app/Http/Controllers/Api/BookApiController.php
index 15565c361..d57b48a43 100644
--- a/app/Http/Controllers/Api/BookApiController.php
+++ b/app/Http/Controllers/Api/BookApiController.php
@@ -2,14 +2,18 @@
 
 namespace BookStack\Http\Controllers\Api;
 
+use BookStack\Api\ApiEntityListFormatter;
 use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Entity;
 use BookStack\Entities\Repos\BookRepo;
+use BookStack\Entities\Tools\BookContents;
 use Illuminate\Http\Request;
 use Illuminate\Validation\ValidationException;
 
 class BookApiController extends ApiController
 {
-    protected $bookRepo;
+    protected BookRepo $bookRepo;
 
     public function __construct(BookRepo $bookRepo)
     {
@@ -47,11 +51,25 @@ class BookApiController extends ApiController
 
     /**
      * View the details of a single book.
+     * The response data will contain 'content' property listing the chapter and pages directly within, in
+     * the same structure as you'd see within the BookStack interface when viewing a book. Top-level
+     * contents will have a 'type' property to distinguish between pages & chapters.
      */
     public function read(string $id)
     {
         $book = Book::visible()->with(['tags', 'cover', 'createdBy', 'updatedBy', 'ownedBy'])->findOrFail($id);
 
+        $contents = (new BookContents($book))->getTree(true, false)->all();
+        $contentsApiData = (new ApiEntityListFormatter($contents))
+            ->withType()
+            ->withField('pages', function (Entity $entity) {
+                if ($entity instanceof Chapter) {
+                    return (new ApiEntityListFormatter($entity->pages->all()))->format();
+                }
+                return null;
+            })->format();
+        $book->setAttribute('contents', $contentsApiData);
+
         return response()->json($book);
     }
 
diff --git a/app/Http/Controllers/Api/BookshelfApiController.php b/app/Http/Controllers/Api/BookshelfApiController.php
index 620df1638..b6b78e80e 100644
--- a/app/Http/Controllers/Api/BookshelfApiController.php
+++ b/app/Http/Controllers/Api/BookshelfApiController.php
@@ -13,9 +13,6 @@ class BookshelfApiController extends ApiController
 {
     protected BookshelfRepo $bookshelfRepo;
 
-    /**
-     * BookshelfApiController constructor.
-     */
     public function __construct(BookshelfRepo $bookshelfRepo)
     {
         $this->bookshelfRepo = $bookshelfRepo;
diff --git a/app/Http/Controllers/Api/SearchApiController.php b/app/Http/Controllers/Api/SearchApiController.php
index 7ef714390..bf59ec671 100644
--- a/app/Http/Controllers/Api/SearchApiController.php
+++ b/app/Http/Controllers/Api/SearchApiController.php
@@ -2,6 +2,7 @@
 
 namespace BookStack\Http\Controllers\Api;
 
+use BookStack\Api\ApiEntityListFormatter;
 use BookStack\Entities\Models\Entity;
 use BookStack\Search\SearchOptions;
 use BookStack\Search\SearchResultsFormatter;
@@ -10,8 +11,8 @@ use Illuminate\Http\Request;
 
 class SearchApiController extends ApiController
 {
-    protected $searchRunner;
-    protected $resultsFormatter;
+    protected SearchRunner $searchRunner;
+    protected SearchResultsFormatter $resultsFormatter;
 
     protected $rules = [
         'all' => [
@@ -50,24 +51,17 @@ class SearchApiController extends ApiController
         $results = $this->searchRunner->searchEntities($options, 'all', $page, $count);
         $this->resultsFormatter->format($results['results']->all(), $options);
 
-        /** @var Entity $result */
-        foreach ($results['results'] as $result) {
-            $result->setVisible([
-                'id', 'name', 'slug', 'book_id',
-                'chapter_id', 'draft', 'template',
-                'created_at', 'updated_at',
-                'tags', 'type', 'preview_html', 'url',
-            ]);
-            $result->setAttribute('type', $result->getType());
-            $result->setAttribute('url', $result->getUrl());
-            $result->setAttribute('preview_html', [
-                'name'    => (string) $result->getAttribute('preview_name'),
-                'content' => (string) $result->getAttribute('preview_content'),
-            ]);
-        }
+        $data = (new ApiEntityListFormatter($results['results']->all()))
+            ->withType()->withTags()
+            ->withField('preview_html', function (Entity $entity) {
+                return [
+                    'name'    => (string) $entity->getAttribute('preview_name'),
+                    'content' => (string) $entity->getAttribute('preview_content'),
+                ];
+            })->format();
 
         return response()->json([
-            'data'  => $results['results'],
+            'data'  => $data,
             'total' => $results['total'],
         ]);
     }
diff --git a/dev/api/responses/books-read.json b/dev/api/responses/books-read.json
index 7de85addc..8d584f597 100644
--- a/dev/api/responses/books-read.json
+++ b/dev/api/responses/books-read.json
@@ -17,6 +17,44 @@
     "id": 1,
     "name": "Admin"
   },
+  "contents": [
+    {
+      "id": 50,
+      "name": "Bridge Structures",
+      "slug": "bridge-structures",
+      "book_id": 16,
+      "created_at": "2021-12-19T15:22:11.000000Z",
+      "updated_at": "2021-12-21T19:42:29.000000Z",
+      "url": "https://example.com/books/my-own-book/chapter/bridge-structures",
+      "type": "chapter",
+      "pages": [
+        {
+          "id": 42,
+          "name": "Building Bridges",
+          "slug": "building-bridges",
+          "book_id": 16,
+          "chapter_id": 50,
+          "draft": false,
+          "template": false,
+          "created_at": "2021-12-19T15:22:11.000000Z",
+          "updated_at": "2022-09-29T13:44:15.000000Z",
+          "url": "https://example.com/books/my-own-book/page/building-bridges"
+        }
+      ]
+    },
+    {
+      "id": 43,
+      "name": "Cool Animals",
+      "slug": "cool-animals",
+      "book_id": 16,
+      "chapter_id": 0,
+      "draft": false,
+      "template": false,
+      "created_at": "2021-12-19T18:22:11.000000Z",
+      "updated_at": "2022-07-29T13:44:15.000000Z",
+      "url": "https://example.com/books/my-own-book/page/cool-animals"
+    }
+  ],
   "tags": [
     {
       "id": 13,
@@ -28,12 +66,12 @@
   "cover": {
     "id": 452,
     "name": "sjovall_m117hUWMu40.jpg",
-    "url": "http:\/\/bookstack.local\/uploads\/images\/cover_book\/2020-01\/sjovall_m117hUWMu40.jpg",
+    "url": "https://example.com/uploads/images/cover_book/2020-01/sjovall_m117hUWMu40.jpg",
     "created_at": "2020-01-12T14:11:51.000000Z",
     "updated_at": "2020-01-12T14:11:51.000000Z",
     "created_by": 1,
     "updated_by": 1,
-    "path": "\/uploads\/images\/cover_book\/2020-01\/sjovall_m117hUWMu40.jpg",
+    "path": "/uploads/images/cover_book/2020-01/sjovall_m117hUWMu40.jpg",
     "type": "cover_book",
     "uploaded_to": 16
   }
diff --git a/tests/Api/BooksApiTest.php b/tests/Api/BooksApiTest.php
index f426cff73..017322193 100644
--- a/tests/Api/BooksApiTest.php
+++ b/tests/Api/BooksApiTest.php
@@ -88,6 +88,38 @@ class BooksApiTest extends TestCase
         ]);
     }
 
+    public function test_read_endpoint_includes_chapter_and_page_contents()
+    {
+        $this->actingAsApiEditor();
+        /** @var Book $book */
+        $book = Book::visible()->has('chapters')->has('pages')->first();
+        $chapter = $book->chapters()->first();
+        $chapterPage = $chapter->pages()->first();
+
+        $resp = $this->getJson($this->baseEndpoint . "/{$book->id}");
+
+        $directChildCount = $book->directPages()->count() + $book->chapters()->count();
+        $resp->assertStatus(200);
+        $resp->assertJsonCount($directChildCount, 'contents');
+        $resp->assertJson([
+            'contents' => [
+                [
+                    'type' => 'chapter',
+                    'id' => $chapter->id,
+                    'name' => $chapter->name,
+                    'slug' => $chapter->slug,
+                    'pages' => [
+                        [
+                            'id' => $chapterPage->id,
+                            'name' => $chapterPage->name,
+                            'slug' => $chapterPage->slug,
+                        ]
+                    ]
+                ]
+            ]
+        ]);
+    }
+
     public function test_update_endpoint()
     {
         $this->actingAsApiEditor();