diff --git a/.ddev/homeadditions/bin/generate-composer-json b/.ddev/homeadditions/bin/generate-composer-json
index 554d18dc45e5f96de1f7dcc0a5f19ceb646be2c8..0b9a99ce3de48a399d53a15f48b6ad4ab24e3a8b 100755
--- a/.ddev/homeadditions/bin/generate-composer-json
+++ b/.ddev/homeadditions/bin/generate-composer-json
@@ -10,10 +10,27 @@ $read_json = function (string $file): array {
   return json_decode($data, TRUE, flags: JSON_THROW_ON_ERROR);
 };
 
-$data = array_merge_recursive(
-  $read_json('project_template/composer.json'),
-  $read_json('dev.composer.json'),
-);
+// From \Drupal\Component\Utility\NestedArray::mergeDeep().
+$merge_deep = function (array ...$arrays) use (&$merge_deep): array {
+  $result = [];
+  foreach ($arrays as $array) {
+    foreach ($array as $key => $value) {
+      // Recurse when both values are arrays.
+      if (isset($result[$key]) && is_array($result[$key]) && is_array($value)) {
+        $result[$key] = $merge_deep($result[$key], $value);
+      }
+      // Otherwise, use the latter value, overriding any previous value.
+      else {
+        $result[$key] = $value;
+      }
+    }
+  }
+  return $result;
+};
+
+$base = $read_json('project_template/composer.json');
+$dev = $read_json('dev.composer.json');
+$data = $merge_deep($base, $dev);
 
 // If in a CI environment, make all path repository URLs absolute.
 if (getenv('CI') || getenv('TUGBOAT_PREVIEW')) {
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index f2fd78c9e35a86197d41113996c7c4b8f460607f..29a3a8282f227923a70b039316d4e9fb5ea91e6a 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -111,9 +111,6 @@ build test project:
     - *create-project
     # Generate `composer.json` by merging our dev requirements into the project template.
     - .ddev/homeadditions/bin/generate-composer-json > $BUILD_DIR/composer.json
-    # @todo Remove this line when XB is one of our production dependencies and
-    # the demo page has been moved into the `drupal_cms_starter` recipe.
-    - mv xb_page.yml $BUILD_DIR
     # Install dependencies.
     - composer install --working-dir=$BUILD_DIR
     # Remove all `.git` directories in the built project.
diff --git a/.tugboat/config.yml b/.tugboat/config.yml
index ddef083f35bc3cd01ef03b8d5327ed38ec868579..e91abf7b7234579927c2a5382bb2b77805f0bb90 100644
--- a/.tugboat/config.yml
+++ b/.tugboat/config.yml
@@ -17,9 +17,6 @@ services:
         - composer create-project drupal/cms $TUGBOAT_ROOT/project --stability=dev --no-install
         # Generate `composer.json` by merging our dev requirements into the project template.
         - .ddev/homeadditions/bin/generate-composer-json > $TUGBOAT_ROOT/project/composer.json
-        # TODO: remove this when https://www.drupal.org/project/drupal_cms/issues/3497781 lands
-        # Temporary workaround to bypass scaffolding the XB demo
-        - composer config --working-dir=$TUGBOAT_ROOT/project --unset extra.drupal-scaffold.file-mapping
         # Run composer install with the new composer.json
         - composer install --working-dir=$TUGBOAT_ROOT/project --optimize-autoloader
         # Symlink the Drupal root to the Apache web root.
diff --git a/dev.composer.json b/dev.composer.json
index 811994147f8f76422bba3afd1e40434aa51f598e..35513c1c44025137923912380107bdc6ecb2576f 100644
--- a/dev.composer.json
+++ b/dev.composer.json
@@ -99,12 +99,15 @@
         "starter": {
             "type": "path",
             "url": "recipes/drupal_cms_starter"
+        },
+        "xb_demo": {
+            "type": "path",
+            "url": "project_template/recipes/drupal_cms_xb_demo"
         }
     },
     "require-dev": {
         "drupal/core-dev": "^11.1.1",
-        "drupal/default_content": "^2",
-        "drupal/experience_builder": "0.x-dev"
+        "drupal/default_content": "^2"
     },
     "autoload-dev": {
         "files": [
@@ -123,11 +126,6 @@
         }
     },
     "extra": {
-        "drupal-scaffold": {
-            "file-mapping": {
-                "[web-root]/modules/contrib/experience_builder/content/xb_page/xb_page.yml": "xb_page.yml"
-            }
-        },
         "patches": {
             "drupal/core": {
                 "#3481164: Announcements Feed breaks `test-site.php install`": "https://www.drupal.org/files/issues/2024-10-16/3481164.patch"
diff --git a/project_template/composer.json b/project_template/composer.json
index 71b2d84f6d941856d5261a5094863c1516f36ae3..8509bcc47c6678c3f0ad1aea6b5b1892167d3745 100644
--- a/project_template/composer.json
+++ b/project_template/composer.json
@@ -12,6 +12,10 @@
         "drupal": {
             "type": "composer",
             "url": "https://packages.drupal.org/8"
+        },
+        "xb_demo": {
+            "type": "path",
+            "url": "recipes/drupal_cms_xb_demo"
         }
     },
     "require": {
@@ -31,6 +35,7 @@
         "drupal/drupal_cms_person": "*",
         "drupal/drupal_cms_project": "*",
         "drupal/drupal_cms_seo_tools": "*",
+        "drupal/drupal_cms_xb_demo": "@dev",
         "drush/drush": "^13"
     },
     "conflict": {
@@ -43,6 +48,7 @@
             "composer/installers": true,
             "drupal/core-composer-scaffold": true,
             "drupal/core-project-message": true,
+            "drupal/drupal_cms_xb_demo": true,
             "php-http/discovery": true
         },
         "sort-packages": true,
diff --git a/project_template/recipes/drupal_cms_xb_demo/composer.json b/project_template/recipes/drupal_cms_xb_demo/composer.json
new file mode 100644
index 0000000000000000000000000000000000000000..b0198a3649069f6228dae64b851b2f0db1210189
--- /dev/null
+++ b/project_template/recipes/drupal_cms_xb_demo/composer.json
@@ -0,0 +1,18 @@
+{
+  "name": "drupal/drupal_cms_xb_demo",
+  "type": "composer-plugin",
+  "description": "A read-only demonstration of the next-generation Experience Builder for Drupal.",
+  "require": {
+    "composer-plugin-api": "^2",
+    "drupal/experience_builder": "0.x-dev",
+    "drush/drush": "^13"
+  },
+  "autoload": {
+    "classmap": ["src/"]
+  },
+  "extra": {
+    "class": "Drupal\\XbDemo\\Plugin",
+    "plugin-optional": true
+  },
+  "version": "dev-main"
+}
diff --git a/xb_page.yml b/project_template/recipes/drupal_cms_xb_demo/content/xb_page.yml
similarity index 100%
rename from xb_page.yml
rename to project_template/recipes/drupal_cms_xb_demo/content/xb_page.yml
diff --git a/project_template/recipes/drupal_cms_xb_demo/recipe.yml b/project_template/recipes/drupal_cms_xb_demo/recipe.yml
new file mode 100644
index 0000000000000000000000000000000000000000..d920bc42a4905a5748ffa946633cf089106430c6
--- /dev/null
+++ b/project_template/recipes/drupal_cms_xb_demo/recipe.yml
@@ -0,0 +1,13 @@
+name: Experience Builder
+type: Drupal CMS
+description: A read-only demonstration of the next-generation Experience Builder for Drupal.
+install:
+  - experience_builder
+config:
+  import:
+    experience_builder:
+      - image.style.xb_avatar
+  actions:
+    experience_builder.settings:
+      simpleConfigUpdate:
+        demo_mode: true
diff --git a/project_template/recipes/drupal_cms_xb_demo/src/Plugin.php b/project_template/recipes/drupal_cms_xb_demo/src/Plugin.php
new file mode 100644
index 0000000000000000000000000000000000000000..72b9841d5f21e1aacd9c63e2c9d54e087f1c84bc
--- /dev/null
+++ b/project_template/recipes/drupal_cms_xb_demo/src/Plugin.php
@@ -0,0 +1,118 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\XbDemo;
+
+use Composer\Composer;
+use Composer\DependencyResolver\Operation\UpdateOperation;
+use Composer\EventDispatcher\EventSubscriberInterface;
+use Composer\InstalledVersions;
+use Composer\Installer\PackageEvent;
+use Composer\Installer\PackageEvents;
+use Composer\IO\IOInterface;
+use Composer\Plugin\PluginInterface;
+use Composer\Semver\VersionParser;
+use Symfony\Component\Process\Process;
+
+/**
+ * Uninstalls Experience Builder whenever Composer attempts to update it.
+ *
+ * Experience Builder currently has no update path, which means anyone who has
+ * it installed in demo mode could theoretically, suddenly break their site if
+ * Experience Builder ships a breaking change. To prevent that scenario, this
+ * plugin will uninstall Experience Builder and delete all of its data before
+ * the Composer package is updated.
+ */
+final class Plugin implements PluginInterface, EventSubscriberInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents(): array {
+    return [
+      PackageEvents::PRE_PACKAGE_UPDATE => 'onPackageUpdate',
+    ];
+  }
+
+  /**
+   * Uninstalls Experience Builder before it is updated.
+   *
+   * @param \Composer\Installer\PackageEvent $event
+   *   The event object.
+   */
+  public function onPackageUpdate(PackageEvent $event): void {
+    $operation = $event->getOperation();
+
+    // We only need to uninstall XB if we're updating it from any version less
+    // than 1.0.0-beta1.
+    if ($operation instanceof UpdateOperation) {
+      $from = $operation->getInitialPackage();
+
+      if ($from->getName() === 'drupal/experience_builder' && version_compare($from->getVersion(), '1.0.0-beta1', '<')) {
+        $drush = $event->getComposer()->getConfig()->get('bin-dir') . '/drush';
+        assert(is_executable($drush));
+
+        $io = $event->getIO();
+        $io->write('Uninstalling Experience Builder because it is being updated from an unstable version.');
+        $this->uninstallXb(function (array $command) use ($drush): Process {
+          $process = new Process([$drush, ...$command]);
+          return $process->mustRun();
+        });
+
+        $io->write('Successfully uninstalled Experience Builder. You can reinstall the demo by running the following command:');
+        $io->write("$drush recipe " . InstalledVersions::getInstallPath('drupal/drupal_cms_xb_demo'));
+      }
+    }
+  }
+
+  /**
+   * Deletes all of Experience Builder's data and uninstalls it.
+   *
+   * @param callable $drush
+   *   A callable that runs Drush. Accepts an array of command-line arguments
+   *   and options, and returns a Process object that has run successfully.
+   */
+  private function uninstallXb(callable $drush): void {
+    // Ensure that Drupal is installed and has a database connection; otherwise,
+    // there's nothing to do.
+    $output = $drush(['core:status', '--field=db-status'])->getOutput();
+    $output = trim($output);
+    if (empty($output)) {
+      return;
+    }
+
+    // Ask Drush if Experience Builder is installed.
+    $output = $drush([
+      'pm:list',
+      '--type=module',
+      '--field=status',
+      '--filter=experience_builder',
+    ])->getOutput();
+
+    if (trim($output) === 'Enabled') {
+      // Delete all xb_page entities and uninstall XB.
+      $drush(['entity:delete', 'xb_page']);
+      $drush(['pm:uninstall', 'experience_builder', '--yes']);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function activate(Composer $composer, IOInterface $io): void {
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function deactivate(Composer $composer, IOInterface $io): void {
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function uninstall(Composer $composer, IOInterface $io): void {
+  }
+
+}
diff --git a/project_template/web/profiles/drupal_cms_installer/drupal_cms_installer.profile b/project_template/web/profiles/drupal_cms_installer/drupal_cms_installer.profile
index d514205d985d9481b6418f6f3300f4293dc90a0f..4973967bdc4669a834d56b6e6c706aa03a7e8b6b 100644
--- a/project_template/web/profiles/drupal_cms_installer/drupal_cms_installer.profile
+++ b/project_template/web/profiles/drupal_cms_installer/drupal_cms_installer.profile
@@ -255,14 +255,11 @@ function drupal_cms_installer_apply_recipes(array &$install_state): array {
   $batch = install_profile_modules($install_state);
   $batch['title'] = t('Setting up your site');
 
-  ['install_path' => $cookbook_path] = InstalledVersions::getRootPackage();
-  $cookbook_path .= '/recipes';
-
   $recipe_operations = [];
 
-  foreach ($recipes_to_apply as $recipe) {
+  foreach ($recipes_to_apply as $name) {
     $recipe = RecipeLoader::load(
-      $cookbook_path . '/' . $recipe,
+      InstalledVersions::getInstallPath('drupal/' . $name),
       // Only save a cached copy of the recipe if this environment variable is
       // set. This allows us to ship a pre-primed cache of recipes to improve
       // installer performance for first-time users.