diff --git a/app/Exports/Controllers/BookExportController.php b/app/Exports/Controllers/BookExportController.php
index 36906b6ad..f726175a0 100644
--- a/app/Exports/Controllers/BookExportController.php
+++ b/app/Exports/Controllers/BookExportController.php
@@ -3,7 +3,9 @@
 namespace BookStack\Exports\Controllers;
 
 use BookStack\Entities\Queries\BookQueries;
+use BookStack\Exceptions\NotFoundException;
 use BookStack\Exports\ExportFormatter;
+use BookStack\Exports\ZipExports\ZipExportBuilder;
 use BookStack\Http\Controller;
 use Throwable;
 
@@ -63,4 +65,16 @@ class BookExportController extends Controller
 
         return $this->download()->directly($textContent, $bookSlug . '.md');
     }
+
+    /**
+     * Export a book to a contained ZIP export file.
+     * @throws NotFoundException
+     */
+    public function zip(string $bookSlug, ZipExportBuilder $builder)
+    {
+        $book = $this->queries->findVisibleBySlugOrFail($bookSlug);
+        $zip = $builder->buildForBook($book);
+
+        return $this->download()->streamedDirectly(fopen($zip, 'r'), $bookSlug . '.zip', filesize($zip));
+    }
 }
diff --git a/app/Exports/Controllers/ChapterExportController.php b/app/Exports/Controllers/ChapterExportController.php
index d85b90dcb..0d7a5c0d1 100644
--- a/app/Exports/Controllers/ChapterExportController.php
+++ b/app/Exports/Controllers/ChapterExportController.php
@@ -5,6 +5,7 @@ namespace BookStack\Exports\Controllers;
 use BookStack\Entities\Queries\ChapterQueries;
 use BookStack\Exceptions\NotFoundException;
 use BookStack\Exports\ExportFormatter;
+use BookStack\Exports\ZipExports\ZipExportBuilder;
 use BookStack\Http\Controller;
 use Throwable;
 
@@ -70,4 +71,16 @@ class ChapterExportController extends Controller
 
         return $this->download()->directly($chapterText, $chapterSlug . '.md');
     }
+
+    /**
+     * Export a book to a contained ZIP export file.
+     * @throws NotFoundException
+     */
+    public function zip(string $bookSlug, string $chapterSlug, ZipExportBuilder $builder)
+    {
+        $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
+        $zip = $builder->buildForChapter($chapter);
+
+        return $this->download()->streamedDirectly(fopen($zip, 'r'), $chapterSlug . '.zip', filesize($zip));
+    }
 }
diff --git a/app/Exports/ZipExports/ZipExportReferences.php b/app/Exports/ZipExports/ZipExportReferences.php
index 1fce0fc97..8b3a4b612 100644
--- a/app/Exports/ZipExports/ZipExportReferences.php
+++ b/app/Exports/ZipExports/ZipExportReferences.php
@@ -3,6 +3,9 @@
 namespace BookStack\Exports\ZipExports;
 
 use BookStack\App\Model;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
 use BookStack\Exports\ZipExports\Models\ZipExportAttachment;
 use BookStack\Exports\ZipExports\Models\ZipExportBook;
 use BookStack\Exports\ZipExports\Models\ZipExportChapter;
@@ -107,8 +110,6 @@ class ZipExportReferences
 
     protected function handleModelReference(Model $model, ZipExportModel $exportModel, ZipExportFiles $files): ?string
     {
-        // TODO - References to other entities
-
         // Handle attachment references
         // No permission check needed here since they would only already exist in this
         // reference context if already allowed via their entity access.
@@ -143,6 +144,15 @@ class ZipExportReferences
             return null;
         }
 
+        // Handle entity references
+        if ($model instanceof Book && isset($this->books[$model->id])) {
+            return "[[bsexport:book:{$model->id}]]";
+        } else if ($model instanceof Chapter && isset($this->chapters[$model->id])) {
+            return "[[bsexport:chapter:{$model->id}]]";
+        } else if ($model instanceof Page && isset($this->pages[$model->id])) {
+            return "[[bsexport:page:{$model->id}]]";
+        }
+
         return null;
     }
 }
diff --git a/routes/web.php b/routes/web.php
index 6ae70983d..e6f3683c6 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -132,6 +132,7 @@ Route::middleware('auth')->group(function () {
     Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/html', [ExportControllers\ChapterExportController::class, 'html']);
     Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/markdown', [ExportControllers\ChapterExportController::class, 'markdown']);
     Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/plaintext', [ExportControllers\ChapterExportController::class, 'plainText']);
+    Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/zip', [ExportControllers\ChapterExportController::class, 'zip']);
     Route::put('/books/{bookSlug}/chapter/{chapterSlug}/permissions', [PermissionsController::class, 'updateForChapter']);
     Route::get('/books/{bookSlug}/chapter/{chapterSlug}/references', [ReferenceController::class, 'chapter']);
     Route::get('/books/{bookSlug}/chapter/{chapterSlug}/delete', [EntityControllers\ChapterController::class, 'showDelete']);
diff --git a/tests/Exports/ZipExportTest.php b/tests/Exports/ZipExportTest.php
index d8ce00be3..536e23806 100644
--- a/tests/Exports/ZipExportTest.php
+++ b/tests/Exports/ZipExportTest.php
@@ -2,14 +2,95 @@
 
 namespace Tests\Exports;
 
-use BookStack\Entities\Models\Book;
+use Illuminate\Support\Carbon;
+use Illuminate\Testing\TestResponse;
 use Tests\TestCase;
+use ZipArchive;
 
 class ZipExportTest extends TestCase
 {
-    public function test_page_export()
+    public function test_export_results_in_zip_format()
     {
         $page = $this->entities->page();
+        $response = $this->asEditor()->get($page->getUrl("/export/zip"));
+
+        $zipData = $response->streamedContent();
+        $zipFile = tempnam(sys_get_temp_dir(), 'bstesta-');
+        file_put_contents($zipFile, $zipData);
+        $zip = new ZipArchive();
+        $zip->open($zipFile, ZipArchive::RDONLY);
+
+        $this->assertNotFalse($zip->locateName('data.json'));
+        $this->assertNotFalse($zip->locateName('files/'));
+
+        $data = json_decode($zip->getFromName('data.json'), true);
+        $this->assertIsArray($data);
+        $this->assertGreaterThan(0, count($data));
+
+        $zip->close();
+        unlink($zipFile);
+    }
+
+    public function test_export_metadata()
+    {
+        $page = $this->entities->page();
+        $zipResp = $this->asEditor()->get($page->getUrl("/export/zip"));
+        $zip = $this->extractZipResponse($zipResp);
+
+        $this->assertEquals($page->id, $zip->data['page']['id'] ?? null);
+        $this->assertArrayNotHasKey('book', $zip->data);
+        $this->assertArrayNotHasKey('chapter', $zip->data);
+
+        $now = time();
+        $date = Carbon::parse($zip->data['exported_at'])->unix();
+        $this->assertLessThan($now + 2, $date);
+        $this->assertGreaterThan($now - 2, $date);
+
+        $version = trim(file_get_contents(base_path('version')));
+        $this->assertEquals($version, $zip->data['instance']['version']);
+
+        $instanceId = decrypt($zip->data['instance']['id_ciphertext']);
+        $this->assertEquals('bookstack', $instanceId);
+    }
+
+    public function test_page_export()
+    {
         // TODO
     }
+
+    public function test_book_export()
+    {
+        // TODO
+    }
+
+    public function test_chapter_export()
+    {
+        // TODO
+    }
+
+    protected function extractZipResponse(TestResponse $response): ZipResultData
+    {
+        $zipData = $response->streamedContent();
+        $zipFile = tempnam(sys_get_temp_dir(), 'bstest-');
+
+        file_put_contents($zipFile, $zipData);
+        $extractDir = tempnam(sys_get_temp_dir(), 'bstestextracted-');
+        if (file_exists($extractDir)) {
+            unlink($extractDir);
+        }
+        mkdir($extractDir);
+
+        $zip = new ZipArchive();
+        $zip->open($zipFile, ZipArchive::RDONLY);
+        $zip->extractTo($extractDir);
+
+        $dataJson = file_get_contents($extractDir . DIRECTORY_SEPARATOR . "data.json");
+        $data = json_decode($dataJson, true);
+
+        return new ZipResultData(
+            $zipFile,
+            $extractDir,
+            $data,
+        );
+    }
 }
diff --git a/tests/Exports/ZipResultData.php b/tests/Exports/ZipResultData.php
new file mode 100644
index 000000000..b5cc2b4ca
--- /dev/null
+++ b/tests/Exports/ZipResultData.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace Tests\Exports;
+
+class ZipResultData
+{
+    public function __construct(
+        public string $zipPath,
+        public string $extractedDirPath,
+        public array $data,
+    ) {
+    }
+}