diff --git a/.cspell-project-words.txt b/.cspell-project-words.txt
index 00af16fb88ab7eb826c4d616ea935a170270bab1..c9c2091cab2dbf0f191fabc6c1cace8fac557efa 100644
--- a/.cspell-project-words.txt
+++ b/.cspell-project-words.txt
@@ -5,3 +5,4 @@ sirv
 yarncheck
 colinodell
 testlogger
+kanopi
diff --git a/composer.json b/composer.json
index b66cdb38813ee910ee92ff0091b4d03ac75e0a1a..a824a0bb2a385ecb8c8376db0a4b4aa031d164d5 100644
--- a/composer.json
+++ b/composer.json
@@ -5,13 +5,16 @@
     "license": "GPL-2.0-or-later",
     "require": {
         "php": ">=8.1",
-        "guzzlehttp/guzzle": "^6 || ^7",
+        "ext-simplexml": "*",
+        "composer-runtime-api": "^2",
         "composer/semver": "^3.2",
-        "ext-simplexml": "*"
+        "guzzlehttp/guzzle": "^6 || ^7",
+        "symfony/finder": "^6.3 || ^7"
     },
     "require-dev": {
+        "colinodell/psr-testlogger": "^1.2",
         "drupal/automatic_updates": "^3.1.2",
-        "colinodell/psr-testlogger": "^1.2"
+        "kanopi/imagemagick-configuration": "@dev"
     },
     "conflict": {
         "drupal/automatic_updates": "<3.0"
diff --git a/images/recipe-logo.png b/images/recipe-logo.png
new file mode 100644
index 0000000000000000000000000000000000000000..db5b58b2a026ebcea47ee2b16151b8acc664d552
Binary files /dev/null and b/images/recipe-logo.png differ
diff --git a/modules/project_browser_devel/src/Plugin/ProjectBrowserSource/RandomDataPlugin.php b/modules/project_browser_devel/src/Plugin/ProjectBrowserSource/RandomDataPlugin.php
index 896e5c6774a23a57c5e74ec4c6775bd0c587181b..7abcf406ffb90fc45449bbab5f2511eba5a7cbe1 100644
--- a/modules/project_browser_devel/src/Plugin/ProjectBrowserSource/RandomDataPlugin.php
+++ b/modules/project_browser_devel/src/Plugin/ProjectBrowserSource/RandomDataPlugin.php
@@ -135,7 +135,7 @@ class RandomDataPlugin extends ProjectBrowserSourceBase {
       $projects = array_filter($projects, fn(Project $project) => stripos($project->title, $query['search']) !== FALSE);
     }
 
-    return $this->createResultsPage($projects, TRUE);
+    return $this->createResultsPage($projects);
   }
 
   /**
@@ -192,7 +192,6 @@ class RandomDataPlugin extends ProjectBrowserSourceBase {
           'value' => $this->randomGenerator->paragraphs(5),
         ],
         title: ucwords($machine_name),
-        status: rand(0, 1),
         changed: $this->getRandomDate(),
         created: $this->getRandomDate(),
         author: [
diff --git a/modules/project_browser_source_example/src/Plugin/ProjectBrowserSource/ProjectBrowserSourceExample.php b/modules/project_browser_source_example/src/Plugin/ProjectBrowserSource/ProjectBrowserSourceExample.php
index cc5d84a40df3065b16a36f8fd2b281ec4a80e3e3..959711f1f0c182d06ff0fc90632776b4456652a3 100644
--- a/modules/project_browser_source_example/src/Plugin/ProjectBrowserSource/ProjectBrowserSourceExample.php
+++ b/modules/project_browser_source_example/src/Plugin/ProjectBrowserSource/ProjectBrowserSourceExample.php
@@ -149,8 +149,6 @@ class ProjectBrowserSourceExample extends ProjectBrowserSourceBase {
           'value' => $project_from_source['long_description'],
         ],
         title: $project_from_source['label'],
-        // Status: 1 enabled / 0 disabled.
-        status: 1,
         changed: $project_from_source['updated_at'],
         created: $project_from_source['created_at'],
         author: $author,
@@ -176,8 +174,6 @@ class ProjectBrowserSourceExample extends ProjectBrowserSourceBase {
           'value' => $project_from_source['long_description'] . ' (different commands)',
         ],
         title: 'A project with different commands',
-        // Status: 1 enabled / 0 disabled.
-        status: 1,
         changed: $project_from_source['updated_at'],
         created: $project_from_source['created_at'],
         author: $author,
@@ -190,7 +186,7 @@ class ProjectBrowserSourceExample extends ProjectBrowserSourceBase {
 
     // Return one page of results. The first parameter is the total number of
     // results for the set, as filtered by $query.
-    return $this->createResultsPage($projects, TRUE);
+    return $this->createResultsPage($projects);
   }
 
   /**
diff --git a/phpstan.neon b/phpstan.neon
index f715d17e092ac016d8c78d8aa301d7e5f57c31ce..7e8cbc5c36ba345aac6dc91c9bbf2267a8724c0f 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -9,3 +9,24 @@ parameters:
     -
       # @see https://www.drupal.org/docs/develop/development-tools/phpstan/handling-unsafe-usage-of-new-static#s-ignoring-the-issue
       identifier: new.static
+
+    # @todo: Remove the following rules when support is dropped for Drupal 10.2, which does not have recipes.
+    -
+      message: "#^Access to constant COMPOSER_PROJECT_TYPE on an unknown class Drupal\\\\Core\\\\Recipe\\\\Recipe\\.$#"
+      paths:
+        - src/Plugin/ProjectBrowserSource/Recipes.php
+        - src/RecipeActivator.php
+        - tests/src/Kernel/RecipesSourceTest.php
+      reportUnmatched: false
+    -
+      message: "#^Call to static method [a-zA-Z]+\\(\\) on an unknown class Drupal\\\\Core\\\\Recipe\\\\Recipe[a-zA-Z]*\\.$#"
+      path: src/RecipeActivator.php
+      reportUnmatched: false
+    -
+      message: "#^Class Drupal\\\\Core\\\\Recipe\\\\RecipeAppliedEvent not found\\.$#"
+      path: src/RecipeActivator.php
+      reportUnmatched: false
+    -
+      message: "#^Parameter \\$event of method Drupal\\\\project_browser\\\\RecipeActivator\\:\\:onApply\\(\\) has invalid type Drupal\\\\Core\\\\Recipe\\\\RecipeAppliedEvent\\.$#"
+      path: src/RecipeActivator.php
+      reportUnmatched: false
diff --git a/project_browser.install b/project_browser.install
index 824cf993742c45f22467a493aff8ce1688b0ea2a..cb07f6e646078202ccb7612e8267cfcec537831f 100644
--- a/project_browser.install
+++ b/project_browser.install
@@ -6,6 +6,7 @@
  */
 
 use Drupal\Core\Database\Database;
+use Drupal\Core\Recipe\Recipe;
 
 /**
  * Implements hook_schema().
@@ -135,6 +136,14 @@ function project_browser_schema() {
  */
 function project_browser_install() {
   _project_browser_populate_from_fixture();
+
+  if (class_exists(Recipe::class)) {
+    $config = \Drupal::configFactory()
+      ->getEditable('project_browser.admin_settings');
+    $enabled_sources = $config->get('enabled_sources');
+    $enabled_sources[] = 'recipes';
+    $config->set('enabled_sources', $enabled_sources)->save();
+  }
 }
 
 /**
diff --git a/project_browser.module b/project_browser.module
index 24e5fba4e98bae445c6697547e8d2ff0e73181f2..f47f581b7cfea4d0bb85f4e34f7533a37544529e 100644
--- a/project_browser.module
+++ b/project_browser.module
@@ -5,8 +5,10 @@
  * Provides hook implementations for the Project Browser module.
  */
 
+use Drupal\Core\Recipe\Recipe;
 use Drupal\Core\Routing\RouteMatchInterface;
 use Drupal\Core\Url;
+use Drupal\project_browser\Plugin\ProjectBrowserSource\Recipes;
 use Drupal\project_browser\ProjectBrowserFixtureHelper;
 
 /**
@@ -60,3 +62,17 @@ function project_browser_menu_links_discovered_alter(&$links) {
     unset($links['admin_toolbar_tools.extra_links:update.module_install']);
   }
 }
+
+/**
+ * Implements hook_project_browser_source_info_alter().
+ */
+function project_browser_project_browser_source_info_alter(array &$definitions): void {
+  if (class_exists(Recipe::class)) {
+    $definitions['recipes'] = [
+      'id' => 'recipes',
+      'label' => t('Recipes'),
+      'description' => t('Shows recipes available in the local code base.'),
+      'class' => Recipes::class,
+    ];
+  }
+}
diff --git a/src/ActivationInstructionsTrait.php b/src/ActivationInstructionsTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..ff2683bb17a88eb527eaec59e806984515d8d45a
--- /dev/null
+++ b/src/ActivationInstructionsTrait.php
@@ -0,0 +1,54 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\project_browser;
+
+use Drupal\Core\Extension\ModuleExtensionList;
+use Drupal\Core\File\FileUrlGeneratorInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+
+/**
+ * Provides helper methods for activators which generate instructions.
+ */
+trait ActivationInstructionsTrait {
+
+  use StringTranslationTrait;
+
+  public function __construct(
+    protected readonly ModuleExtensionList $moduleList,
+    protected readonly FileUrlGeneratorInterface $fileUrlGenerator,
+  ) {
+  }
+
+  /**
+   * Generates the markup for a copy-and-paste terminal command.
+   *
+   * @param string $command
+   *   A terminal command.
+   * @param string $action
+   *   An identifier of the action, like `download` or `run`.
+   * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $alt
+   *   (optional) The alt text of the "copy" button. Defaults to "Copy the
+   *   $action command".
+   *
+   * @return string
+   *   The given command, in a format that can be copied and pasted.
+   */
+  protected function commandBox(string $command, string $action, ?TranslatableMarkup $alt = NULL): string {
+    $alt ??= $this->t('Copy the @action command', ['@action' => $action]);
+
+    $icon_url = $this->moduleList->getPath('project_browser') . '/images/copy-icon.svg';
+    $icon_url = $this->fileUrlGenerator->generateString($icon_url);
+
+    $command_box = '<div class="command-box">';
+    $command_box .= '<input value="' . $command . '" readonly />';
+    $command_box .= '<button data-copy-command id="' . $action . '-btn">';
+    $command_box .= '<img src="' . $icon_url . '" alt="' . $alt . '" />';
+    $command_box .= '</button>';
+    $command_box .= '</div>';
+    return $command_box;
+  }
+
+}
diff --git a/src/ActivationStatus.php b/src/ActivationStatus.php
new file mode 100644
index 0000000000000000000000000000000000000000..12ba43a43a894bac66fe3a9ebec196b8aa5dd86c
--- /dev/null
+++ b/src/ActivationStatus.php
@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\project_browser;
+
+/**
+ * Defines the possible states of a project in the current site.
+ */
+enum ActivationStatus {
+
+  // Not physically present, but can be required and activated.
+  case Absent;
+  // Physically present, but not yet activated.
+  case Present;
+  // Physically present and activated.
+  case Active;
+
+}
diff --git a/src/Activator.php b/src/Activator.php
index ae687986797b00f8336159559a9679455efdb2dc..19245c186ca33fb80026afc2e28d7b9789adac28 100644
--- a/src/Activator.php
+++ b/src/Activator.php
@@ -60,8 +60,8 @@ final class Activator implements ActivatorInterface {
   /**
    * {@inheritdoc}
    */
-  public function isActive(Project $project): bool {
-    return $this->getActivatorForProject($project)->isActive($project);
+  public function getStatus(Project $project): ActivationStatus {
+    return $this->getActivatorForProject($project)->getStatus($project);
   }
 
   /**
diff --git a/src/ActivatorInterface.php b/src/ActivatorInterface.php
index faa635101fa9cc4fdb5d904bfa159b31b7b5e550..b174ae34b7a8950f4beb6019b3fa93feb5d5cfeb 100644
--- a/src/ActivatorInterface.php
+++ b/src/ActivatorInterface.php
@@ -23,10 +23,10 @@ interface ActivatorInterface {
    * @param \Drupal\project_browser\ProjectBrowser\Project $project
    *   A project to check.
    *
-   * @return bool
-   *   TRUE if the project is activated on the current site, FALSE otherwise.
+   * @return \Drupal\project_browser\ActivationStatus
+   *   The state of the project on the current site.
    */
-  public function isActive(Project $project): bool;
+  public function getStatus(Project $project): ActivationStatus;
 
   /**
    * Determines if this activator can handle a particular project.
diff --git a/src/Controller/BrowserController.php b/src/Controller/BrowserController.php
index eb4e3d2ce2af1a01d91c7f0d7c30ef3c251aa75f..5874890afee4a8bd0cc82460138e52ece473f31f 100644
--- a/src/Controller/BrowserController.php
+++ b/src/Controller/BrowserController.php
@@ -3,7 +3,6 @@
 namespace Drupal\project_browser\Controller;
 
 use Drupal\Core\Controller\ControllerBase;
-use Drupal\Core\Extension\InfoParserException;
 use Drupal\Core\Extension\ModuleExtensionList;
 use Drupal\project_browser\DevelopmentStatus;
 use Drupal\project_browser\EnabledSourceHandler;
@@ -71,7 +70,6 @@ class BrowserController extends ControllerBase {
    *   A render array.
    */
   public function browse($module_name) {
-    $modules_status = $this->getModuleStatuses();
     $request = $this->requestStack->getCurrentRequest();
     $current_sources = $this->enabledSource->getCurrentSources();
     $ui_install_enabled = (bool) $this->config('project_browser.admin_settings')->get('allow_ui_install') && (bool) $this->installReadiness;
@@ -101,7 +99,6 @@ class BrowserController extends ControllerBase {
         'drupalSettings' => [
           'project_browser' => [
             'active_plugins' => $active_plugins,
-            'modules' => $modules_status,
             'drupal_version' => \Drupal::VERSION,
             'drupal_core_compatibility' => \Drupal::CORE_COMPATIBILITY,
             'module_path' => $this->moduleHandler()->getModule('project_browser')->getPath(),
@@ -116,6 +113,7 @@ class BrowserController extends ControllerBase {
             'ui_install' => $ui_install_enabled,
             'stage_available' => $ui_install_enabled ? $this->installReadiness->installerAvailable() : FALSE,
             'pm_validation' => $ui_install_enabled ? $this->installReadiness->validatePackageManager() : TRUE,
+            'package_manager_available' => array_key_exists('package_manager', $this->moduleList->getAllInstalledInfo()),
           ],
         ],
       ],
@@ -148,28 +146,4 @@ class BrowserController extends ControllerBase {
     ];
   }
 
-  /**
-   * Gets all module statuses.
-   *
-   * @return array
-   *   An array of module statues, keyed by machine name.
-   */
-  protected function getModuleStatuses(): array {
-    // Sort all modules by their names.
-    try {
-      // The module list needs to be reset so that it can re-scan and include
-      // any new modules that may have been added directly into the filesystem.
-      $modules = $this->moduleList->reset()->getList();
-      uasort($modules, [ModuleExtensionList::class, 'sortByName']);
-    }
-    catch (InfoParserException $e) {
-      $this->messenger()->addError($this->t('Modules could not be listed due to an error: %error', ['%error' => $e->getMessage()]));
-      $modules = [];
-    }
-
-    return array_map(function ($value) {
-      return $value->status;
-    }, $modules);
-  }
-
 }
diff --git a/src/Controller/ProjectBrowserEndpointController.php b/src/Controller/ProjectBrowserEndpointController.php
index 6d706ba5b259dd9eaa9bf132e74d1b1901c1da11..4a7396ee3aa8b628fe62efd8c52a9a6a95889ae6 100644
--- a/src/Controller/ProjectBrowserEndpointController.php
+++ b/src/Controller/ProjectBrowserEndpointController.php
@@ -158,6 +158,9 @@ class ProjectBrowserEndpointController extends ControllerBase {
 
     foreach ($projects as $result_page) {
       foreach ($result_page->list as $project) {
+        // The project's activator is the source of truth about the status of
+        // the project with respect to the current site.
+        $project->status = $this->activator->getStatus($project);
         // The activator is responsible for generating the instructions.
         $project->commands = $this->activator->getInstructions($project);
       }
diff --git a/src/ModuleActivator.php b/src/ModuleActivator.php
index e96868010ea7518cc6281a05c8b3baeeeb6a9325..e001e9350d2fb4b4ab9f6360b5f05af9985bd727 100644
--- a/src/ModuleActivator.php
+++ b/src/ModuleActivator.php
@@ -7,8 +7,6 @@ namespace Drupal\project_browser;
 use Drupal\Core\Extension\ModuleExtensionList;
 use Drupal\Core\Extension\ModuleInstallerInterface;
 use Drupal\Core\File\FileUrlGeneratorInterface;
-use Drupal\Core\StringTranslation\StringTranslationTrait;
-use Drupal\Core\StringTranslation\TranslatableMarkup;
 use Drupal\Core\Url;
 use Drupal\project_browser\ProjectBrowser\Project;
 use Symfony\Component\HttpFoundation\Response;
@@ -18,19 +16,29 @@ use Symfony\Component\HttpFoundation\Response;
  */
 final class ModuleActivator implements ActivatorInterface {
 
-  use StringTranslationTrait;
+  use ActivationInstructionsTrait {
+    __construct as traitConstruct;
+  }
 
   public function __construct(
-    private readonly ModuleExtensionList $moduleList,
     private readonly ModuleInstallerInterface $moduleInstaller,
-    private readonly FileUrlGeneratorInterface $fileUrlGenerator,
-  ) {}
+    ModuleExtensionList $moduleList,
+    FileUrlGeneratorInterface $fileUrlGenerator,
+  ) {
+    $this->traitConstruct($moduleList, $fileUrlGenerator);
+  }
 
   /**
    * {@inheritdoc}
    */
-  public function isActive(Project $project): bool {
-    return array_key_exists($project->machineName, $this->moduleList->getAllInstalledInfo());
+  public function getStatus(Project $project): ActivationStatus {
+    if (array_key_exists($project->machineName, $this->moduleList->getAllInstalledInfo())) {
+      return ActivationStatus::Active;
+    }
+    elseif (array_key_exists($project->machineName, $this->moduleList->getAllAvailableInfo())) {
+      return ActivationStatus::Present;
+    }
+    return ActivationStatus::Absent;
   }
 
   /**
@@ -52,7 +60,7 @@ final class ModuleActivator implements ActivatorInterface {
    * {@inheritdoc}
    */
   public function getInstructions(Project $project): string|Url|null {
-    if (array_key_exists($project->machineName, $this->moduleList->getAllAvailableInfo())) {
+    if ($this->getStatus($project) === ActivationStatus::Present) {
       return Url::fromRoute('system.modules_list', options: [
         'fragment' => 'module-' . str_replace('_', '-', $project->machineName),
       ]);
@@ -96,33 +104,4 @@ final class ModuleActivator implements ActivatorInterface {
     return $commands;
   }
 
-  /**
-   * Generates the markup for a copy-and-paste terminal command.
-   *
-   * @param string $command
-   *   A terminal command.
-   * @param string $action
-   *   An identifier of the action, like `download` or `run`.
-   * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $alt
-   *   (optional) The alt text of the "copy" button. Defaults to "Copy the
-   *   $action command".
-   *
-   * @return string
-   *   The given command, in a format that can be copied and pasted.
-   */
-  private function commandBox(string $command, string $action, ?TranslatableMarkup $alt = NULL): string {
-    $alt ??= $this->t('Copy the @action command', ['@action' => $action]);
-
-    $icon_url = $this->moduleList->getPath('project_browser') . '/images/copy-icon.svg';
-    $icon_url = $this->fileUrlGenerator->generateString($icon_url);
-
-    $command_box = '<div class="command-box">';
-    $command_box .= '<input value="' . $command . '" readonly />';
-    $command_box .= '<button data-copy-command id="' . $action . '-btn">';
-    $command_box .= '<img src="' . $icon_url . '" alt="' . $alt . '" />';
-    $command_box .= '</button>';
-    $command_box .= '</div>';
-    return $command_box;
-  }
-
 }
diff --git a/src/Plugin/ProjectBrowserSource/DrupalCore.php b/src/Plugin/ProjectBrowserSource/DrupalCore.php
index 66bd1404bf8a6be94c6535eb854353dcd5a644cb..a839ac053777cf78da2536ca475050c34a295dbb 100644
--- a/src/Plugin/ProjectBrowserSource/DrupalCore.php
+++ b/src/Plugin/ProjectBrowserSource/DrupalCore.php
@@ -159,7 +159,7 @@ class DrupalCore extends ProjectBrowserSourceBase {
     if (!empty($query['page']) && !empty($query['limit'])) {
       $projects = array_chunk($projects, $query['limit'])[$query['page']] ?? [];
     }
-    return $this->createResultsPage($projects, FALSE, $project_count);
+    return $this->createResultsPage($projects, $project_count);
   }
 
   /**
@@ -200,7 +200,6 @@ class DrupalCore extends ProjectBrowserSourceBase {
           'value' => $module->info['description'],
         ],
         title: $module->info['name'],
-        status: $module->status,
         changed: 280299600,
         created: 280299600,
         author: [
diff --git a/src/Plugin/ProjectBrowserSource/MockDrupalDotOrg.php b/src/Plugin/ProjectBrowserSource/MockDrupalDotOrg.php
index 2f5eac49f3b09b3bd6cdd40e4383aefae5107c7f..6b26ac60d0e520f4b9c2a87ec6c286a186844a54 100644
--- a/src/Plugin/ProjectBrowserSource/MockDrupalDotOrg.php
+++ b/src/Plugin/ProjectBrowserSource/MockDrupalDotOrg.php
@@ -384,7 +384,6 @@ class MockDrupalDotOrg extends ProjectBrowserSourceBase {
           machineName: $project_data['field_project_machine_name'],
           body: $this->relativeToAbsoluteUrls($project_data['project_data']['body'], 'https://www.drupal.org'),
           title: $project_data['title'],
-          status: $project_data['status'],
           changed: $project_data['changed'],
           created: $project_data['created'],
           author: ['name' => $project_data['author']],
@@ -398,7 +397,7 @@ class MockDrupalDotOrg extends ProjectBrowserSourceBase {
       }
     }
 
-    return $this->createResultsPage($returned_list, TRUE, $api_response['total_results'] ?? 0);
+    return $this->createResultsPage($returned_list, $api_response['total_results'] ?? 0);
   }
 
   /**
diff --git a/src/Plugin/ProjectBrowserSource/Recipes.php b/src/Plugin/ProjectBrowserSource/Recipes.php
new file mode 100644
index 0000000000000000000000000000000000000000..54e0bad5c2e671e1329186e82ad5e874ff0e5a3a
--- /dev/null
+++ b/src/Plugin/ProjectBrowserSource/Recipes.php
@@ -0,0 +1,194 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\project_browser\Plugin\ProjectBrowserSource;
+
+use Composer\InstalledVersions;
+use Drupal\Component\Serialization\Json;
+use Drupal\Component\Serialization\Yaml;
+use Drupal\Core\Cache\CacheBackendInterface;
+use Drupal\Core\Extension\ModuleExtensionList;
+use Drupal\Core\File\FileSystemInterface;
+use Drupal\Core\File\FileUrlGeneratorInterface;
+use Drupal\Core\Recipe\Recipe;
+use Drupal\project_browser\Plugin\ProjectBrowserSourceBase;
+use Drupal\project_browser\ProjectBrowser\Project;
+use Drupal\project_browser\ProjectBrowser\ProjectsResultsPage;
+use Drupal\project_browser\ProjectType;
+use Drupal\project_browser\SecurityStatus;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\Finder\Finder;
+
+/**
+ * A source plugin that exposes recipes installed locally.
+ */
+class Recipes extends ProjectBrowserSourceBase {
+
+  public function __construct(
+    private readonly FileSystemInterface $fileSystem,
+    private readonly CacheBackendInterface $cacheBin,
+    private readonly ModuleExtensionList $moduleList,
+    private readonly FileUrlGeneratorInterface $fileUrlGenerator,
+    private readonly string $appRoot,
+    mixed ...$arguments,
+  ) {
+    parent::__construct(...$arguments);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
+    return new static(
+      $container->get(FileSystemInterface::class),
+      $container->get('cache.project_browser'),
+      $container->get(ModuleExtensionList::class),
+      $container->get(FileUrlGeneratorInterface::class),
+      $container->getParameter('app.root'),
+      ...array_slice(func_get_args(), 1),
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getProjects(array $query = []): ProjectsResultsPage {
+    $cached = $this->cacheBin->get($this->getPluginId());
+    if ($cached) {
+      $projects = $cached->data;
+    }
+    else {
+      $projects = [];
+
+      $logo_url = $this->moduleList->getPath('project_browser') . '/images/recipe-logo.png';
+      $logo_url = $this->fileUrlGenerator->generateString($logo_url);
+
+      /** @var \Symfony\Component\Finder\SplFileInfo $file */
+      foreach ($this->getFinder() as $file) {
+        $path = $file->getPath();
+
+        // If the recipe isn't part of Drupal core, get its package name from
+        // `composer.json`. This shouldn't be necessary once drupal.org has a
+        // proper API endpoint that provides project information for recipes.
+        if (str_starts_with($path, $this->appRoot . '/core/recipes/')) {
+          $package_name = 'drupal/core';
+        }
+        else {
+          $package = file_get_contents($path . '/composer.json');
+          $package = Json::decode($package);
+          $package_name = $package['name'];
+        }
+
+        $recipe = Yaml::decode($file->getContents());
+        $description = $recipe['description'] ?? NULL;
+
+        $projects[] = new Project(
+          logo: [
+            'file' => [
+              'uri' => $logo_url,
+              'resource' => 'image',
+            ],
+            'alt' => (string) $this->t('@name logo', [
+              '@name' => $recipe['name'],
+            ]),
+          ],
+          isCompatible: TRUE,
+          isMaintained: TRUE,
+          isCovered: TRUE,
+          isActive: TRUE,
+          starUserCount: 0,
+          projectUsageTotal: 0,
+          machineName: basename($path),
+          body: $description ? ['value' => $description] : [],
+          title: $recipe['name'],
+          changed: 0,
+          created: 0,
+          author: [],
+          packageName: $package_name,
+          type: ProjectType::Recipe,
+        );
+      }
+      $this->cacheBin->set($this->getPluginId(), $projects);
+    }
+
+    $total = count($projects);
+
+    // Filter by project machine name.
+    if (!empty($query['machine_name'])) {
+      $projects = array_filter($projects, fn(Project $project) => $project->machineName === $query['machine_name']);
+    }
+
+    // Filter by coverage.
+    if (!empty($query['security_advisory_coverage']) && $query['security_advisory_coverage'] === SecurityStatus::Covered->value) {
+      $projects = array_filter($projects, fn(Project $project) => $project->isCovered);
+    }
+
+    // Filter by categories.
+    if (!empty($query['categories'])) {
+      $projects = array_filter($projects, fn(Project $project) => array_intersect(array_column($project->categories, 'id'), explode(',', $query['categories'])));
+    }
+
+    // Filter by search text.
+    if (!empty($query['search'])) {
+      $projects = array_filter($projects, fn(Project $project) => stripos($project->title, $query['search']) !== FALSE);
+    }
+
+    // Filter by sorting criterion.
+    if (!empty($query['sort'])) {
+      $sort = $query['sort'];
+      switch ($sort) {
+        case 'a_z':
+          usort($projects, fn($x, $y) => $x->title <=> $y->title);
+          break;
+
+        case 'z_a':
+          usort($projects, fn($x, $y) => $y->title <=> $x->title);
+          break;
+      }
+    }
+
+    if (array_key_exists('page', $query) && !empty($query['limit'])) {
+      $projects = array_chunk($projects, $query['limit'])[$query['page']] ?? [];
+    }
+
+    return $this->createResultsPage($projects, $total);
+  }
+
+  /**
+   * Prepares a Symfony Finder to search for recipes in the file system.
+   *
+   * @return \Symfony\Component\Finder\Finder
+   *   A Symfony Finder object, configured to find locally installed recipes.
+   */
+  private function getFinder(): Finder {
+    $search_in = [$this->appRoot . '/core/recipes'];
+
+    // If any recipes have been installed by Composer, also search there. The
+    // recipe system requires that all non-core recipes be located next to each
+    // other, in the same directory.
+    $contrib_recipe_names = InstalledVersions::getInstalledPackagesByType(Recipe::COMPOSER_PROJECT_TYPE);
+    if ($contrib_recipe_names) {
+      $path = InstalledVersions::getInstallPath($contrib_recipe_names[0]);
+      $path = $this->fileSystem->realpath($path);
+
+      $search_in[] = dirname($path);
+    }
+
+    return Finder::create()
+      ->files()
+      ->in($search_in)
+      ->depth(1)
+      // The example recipe exists for documentation purposes only.
+      ->notPath('example/')
+      ->name('recipe.yml');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCategories(): array {
+    return [];
+  }
+
+}
diff --git a/src/Plugin/ProjectBrowserSourceBase.php b/src/Plugin/ProjectBrowserSourceBase.php
index a003cfacf9bbf3335bba750e5d413978f05b50e1..6d41c41b35650cefd165310b241aa7475c419c88 100644
--- a/src/Plugin/ProjectBrowserSourceBase.php
+++ b/src/Plugin/ProjectBrowserSourceBase.php
@@ -66,21 +66,18 @@ abstract class ProjectBrowserSourceBase extends PluginBase implements ProjectBro
    *
    * @param \Drupal\project_browser\ProjectBrowser\Project[] $results
    *   The projects to list on the page.
-   * @param bool $package_manager_required
-   *   Whether Package Manager is required for these projects.
    * @param int|null $total_results
    *   (optional) The total number of results. Defaults to the size of $results.
    *
    * @return \Drupal\project_browser\ProjectBrowser\ProjectsResultsPage
    *   A list of projects to send to the client.
    */
-  protected function createResultsPage(array $results, bool $package_manager_required, ?int $total_results = NULL): ProjectsResultsPage {
+  protected function createResultsPage(array $results, ?int $total_results = NULL): ProjectsResultsPage {
     return new ProjectsResultsPage(
       $total_results ?? count($results),
       array_values($results),
       (string) $this->getPluginDefinition()['label'],
       $this->getPluginId(),
-      $package_manager_required,
     );
   }
 
diff --git a/src/ProjectBrowser/Project.php b/src/ProjectBrowser/Project.php
index e571ca97ece2b20c3bf3669eddcc44a3eb55bd05..07951f7dccb16060a2b5dba6f3ed057dadba6c10 100644
--- a/src/ProjectBrowser/Project.php
+++ b/src/ProjectBrowser/Project.php
@@ -6,6 +6,7 @@ use Drupal\Component\Utility\Html;
 use Drupal\Component\Utility\Unicode;
 use Drupal\Component\Utility\Xss;
 use Drupal\Core\Url;
+use Drupal\project_browser\ActivationStatus;
 use Drupal\project_browser\ProjectType;
 
 /**
@@ -20,6 +21,13 @@ class Project implements \JsonSerializable {
    */
   public readonly string $id;
 
+  /**
+   * The status of this project in the current site.
+   *
+   * @var \Drupal\project_browser\ActivationStatus
+   */
+  public ActivationStatus $status;
+
   /**
    * The instructions, if any, to activate this project.
    *
@@ -59,8 +67,6 @@ class Project implements \JsonSerializable {
    *   Body field of the project in array format.
    * @param string $title
    *   Title of the project.
-   * @param int $status
-   *   Status of the project.
    * @param int $changed
    *   When was the project changed last timestamp.
    * @param int $created
@@ -95,7 +101,6 @@ class Project implements \JsonSerializable {
     public string $machineName,
     private array $body,
     public string $title,
-    public int $status,
     public int $changed,
     public int $created,
     public array $author,
@@ -182,7 +187,11 @@ class Project implements \JsonSerializable {
       'is_active' => $this->isActive,
       'flag_project_star_user_count' => $this->starUserCount,
       'url' => $this->url,
-      'status' => $this->status,
+      'status' => match ($this->status) {
+        ActivationStatus::Absent => 'absent',
+        ActivationStatus::Present => 'present',
+        ActivationStatus::Active => 'active',
+      },
       'changed' => $this->changed,
       'created' => $this->created,
       'selector_id' => $this->getSelectorId(),
diff --git a/src/ProjectBrowser/ProjectsResultsPage.php b/src/ProjectBrowser/ProjectsResultsPage.php
index 5f520abaa9dfc97a22e9e79f453cb62306e3d9a6..8a23685db107af0b9036afdbebb30f4e400cb4b2 100644
--- a/src/ProjectBrowser/ProjectsResultsPage.php
+++ b/src/ProjectBrowser/ProjectsResultsPage.php
@@ -18,15 +18,12 @@ class ProjectsResultsPage implements \JsonSerializable {
    *   The source plugin's label.
    * @param string $pluginId
    *   The source plugin's ID.
-   * @param bool $isPackageManagerRequired
-   *   True if Package Manager is required.
    */
   public function __construct(
     public readonly int $totalResults,
     public readonly array $list,
     public readonly string $pluginLabel,
     public readonly string $pluginId,
-    public readonly bool $isPackageManagerRequired,
   ) {
     assert(array_is_list($list));
   }
diff --git a/src/ProjectBrowserServiceProvider.php b/src/ProjectBrowserServiceProvider.php
index 9977fcee83d79d1391c8897cfc8037147368897f..e9b8ddd675bb0a432a26e6259e176e4cadb62688 100644
--- a/src/ProjectBrowserServiceProvider.php
+++ b/src/ProjectBrowserServiceProvider.php
@@ -12,11 +12,13 @@ use Drupal\Core\Extension\ThemeExtensionList;
 use Drupal\Core\PrivateKey;
 use Drupal\Core\Queue\QueueFactory;
 use Drupal\Core\Queue\QueueInterface;
+use Drupal\Core\Recipe\Recipe;
 use Drupal\Core\State\StateInterface;
 use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface;
 use Drupal\project_browser\ComposerInstaller\Installer;
 use Drupal\project_browser\ComposerInstaller\Validator\CoreNotUpdatedValidator;
 use Drupal\project_browser\ComposerInstaller\Validator\PackageNotInstalledValidator;
+use Symfony\Component\DependencyInjection\Parameter;
 
 /**
  * Base class acts as a helper for Project Browser services.
@@ -45,6 +47,15 @@ class ProjectBrowserServiceProvider extends ServiceProviderBase {
         ->setAutowired(TRUE);
     }
 
+    if (class_exists(Recipe::class)) {
+      $container->register(RecipeActivator::class, RecipeActivator::class)
+        ->setAutowired(TRUE)
+        ->setArgument('$appRoot', new Parameter('app.root'))
+        ->addTag('project_browser.activator')
+        // Because it's an event subscriber, the activator needs to be public.
+        ->addTag('event_subscriber');
+    }
+
     // @todo Remove the following Drupal 10.0 autowiring shim in
     //   https://www.drupal.org/i/3349193.
     $autowire_aliases = [
diff --git a/src/ProjectType.php b/src/ProjectType.php
index de1e22a7eb89b27886f8ea8f7bc48af112e3753e..85ebba500e9c9834abeb642fc6619e0fd0ae7d7b 100644
--- a/src/ProjectType.php
+++ b/src/ProjectType.php
@@ -7,13 +7,11 @@ namespace Drupal\project_browser;
 /**
  * The different project types known to Project Browser.
  *
- * @todo Add a case for recipes once support for Drupal 10.2 and earlier is
- *   dropped.
- *
  * @see \Drupal\project_browser\ProjectBrowser\Project
  */
 enum ProjectType: string {
 
   case Module = 'module';
+  case Recipe = 'recipe';
 
 }
diff --git a/src/RecipeActivator.php b/src/RecipeActivator.php
new file mode 100644
index 0000000000000000000000000000000000000000..b3315f9e60dedf0be844e60b3c6fcc522b936204
--- /dev/null
+++ b/src/RecipeActivator.php
@@ -0,0 +1,140 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\project_browser;
+
+use Composer\InstalledVersions;
+use Drupal\Core\Extension\ModuleExtensionList;
+use Drupal\Core\File\FileSystemInterface;
+use Drupal\Core\File\FileUrlGeneratorInterface;
+use Drupal\Core\Recipe\Recipe;
+use Drupal\Core\Recipe\RecipeAppliedEvent;
+use Drupal\Core\Recipe\RecipeRunner;
+use Drupal\Core\State\StateInterface;
+use Drupal\Core\Url;
+use Drupal\project_browser\ProjectBrowser\Project;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * Applies locally installed recipes.
+ */
+class RecipeActivator implements ActivatorInterface, EventSubscriberInterface {
+
+  use ActivationInstructionsTrait {
+    __construct as traitConstruct;
+  }
+
+  /**
+   * The state key that stores the record of all applied recipes.
+   *
+   * @var string
+   */
+  private const STATE_KEY = 'project_browser.applied_recipes';
+
+  public function __construct(
+    private readonly string $appRoot,
+    private readonly StateInterface $state,
+    private readonly FileSystemInterface $fileSystem,
+    ModuleExtensionList $moduleList,
+    FileUrlGeneratorInterface $fileUrlGenerator,
+  ) {
+    $this->traitConstruct($moduleList, $fileUrlGenerator);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents(): array {
+    return [
+      RecipeAppliedEvent::class => 'onApply',
+    ];
+  }
+
+  /**
+   * Reacts when a recipe is applied to the site.
+   *
+   * @param \Drupal\Core\Recipe\RecipeAppliedEvent $event
+   *   The event object.
+   */
+  public function onApply(RecipeAppliedEvent $event): void {
+    $list = $this->state->get(static::STATE_KEY, []);
+    $list[] = $event->recipe->path;
+    $this->state->set(static::STATE_KEY, $list);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getStatus(Project $project): ActivationStatus {
+    $path = $this->getPath($project);
+
+    if (in_array($path, $this->state->get(static::STATE_KEY, []), TRUE)) {
+      return ActivationStatus::Active;
+    }
+    elseif ($project->packageName === 'drupal/core') {
+      // Recipes that are part of core are always present.
+      return ActivationStatus::Present;
+    }
+    else {
+      return is_string($path) ? ActivationStatus::Present : ActivationStatus::Absent;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function supports(Project $project): bool {
+    // @see \Drupal\project_browser\Plugin\ProjectBrowserSource\Recipes
+    return $project->type === ProjectType::Recipe;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function activate(Project $project): ?Response {
+    $recipe = Recipe::createFromDirectory($this->getPath($project));
+    RecipeRunner::processRecipe($recipe);
+    return NULL;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getInstructions(Project $project): string|Url|null {
+    $instructions = '<p>' . $this->t('To apply this recipe, run the following command at the command line:') . '</p>';
+
+    $command = sprintf(
+      'cd %s && %s/php %s/core/scripts/drupal recipe %s',
+      $this->appRoot,
+      // cspell:ignore BINDIR
+      PHP_BINDIR,
+      $this->appRoot,
+      $this->getPath($project),
+    );
+    $instructions .= $this->commandBox($command, 'apply');
+
+    return $instructions;
+  }
+
+  /**
+   * Returns the absolute path of an installed recipe, if known.
+   *
+   * @param \Drupal\project_browser\ProjectBrowser\Project $project
+   *   A project object with info about the recipe.
+   *
+   * @return string|null
+   *   The absolute local path of the recipe, or NULL if it's not installed.
+   */
+  private function getPath(Project $project): ?string {
+    if ($project->packageName === 'drupal/core') {
+      // The machine name is the directory name.
+      // @see \Drupal\project_browser\Plugin\ProjectBrowserSource\Recipes::getProjects()
+      return $this->appRoot . '/core/recipes/' . $project->machineName;
+    }
+    $path = InstalledVersions::getInstallPath($project->packageName);
+    return $path ? $this->fileSystem->realpath($path) : NULL;
+  }
+
+}
diff --git a/sveltejs/public/build/bundle.js b/sveltejs/public/build/bundle.js
index 6558e541937bd38640dea02b3812b9ca23467174..d325379796e6afd1cf3e0143736c1e03c7299a40 100644
Binary files a/sveltejs/public/build/bundle.js and b/sveltejs/public/build/bundle.js differ
diff --git a/sveltejs/public/build/bundle.js.map b/sveltejs/public/build/bundle.js.map
index 862e17e11551bcb04c6adf61a50ad58cfdbad3e6..7ac0ced3ac4b48a3ef9ad8f8e7e0ae72bbb438ba 100644
Binary files a/sveltejs/public/build/bundle.js.map and b/sveltejs/public/build/bundle.js.map differ
diff --git a/sveltejs/src/Project/ActionButton.svelte b/sveltejs/src/Project/ActionButton.svelte
index e8387ddc3eef3bd852c457af1d9e6441b79bbfe5..07a05da918d1f903494f0881baf1d2f8fba93bc1 100644
--- a/sveltejs/src/Project/ActionButton.svelte
+++ b/sveltejs/src/Project/ActionButton.svelte
@@ -1,7 +1,6 @@
 <script>
   import { onMount } from 'svelte';
   import {
-    MODULE_STATUS,
     ORIGIN_URL,
     ALLOW_UI_INSTALL,
     PM_VALIDATION_ERROR,
@@ -18,40 +17,10 @@
   let loading = false;
   let loadingPhase = 'Adding';
 
-  const { drupalSettings, Drupal } = window;
+  const { Drupal } = window;
 
-  /**
-   * Determine is a project is present in the local Drupal codebase.
-   *
-   * @param {string} projectName
-   *    The project name.
-   * @return {boolean}
-   *   True if the project is present.
-   */
-  function projectIsDownloaded(projectName) {
-    return (
-      typeof drupalSettings !== 'undefined' && projectName in MODULE_STATUS
-    );
-  }
-
-  /**
-   * Determine if a project is installed in the local Drupal codebase.
-   *
-   * @param {string} projectName
-   *   The project name.
-   * @return {boolean}
-   *   True if the project is installed.
-   */
-  function projectIsInstalled(projectName) {
-    return (
-      typeof drupalSettings !== 'undefined' &&
-      projectName in MODULE_STATUS &&
-      MODULE_STATUS[projectName] === 1
-    );
-  }
-
-  let projectInstalled = projectIsInstalled(project.project_machine_name);
-  let projectDownloaded = projectIsDownloaded(project.project_machine_name);
+  let projectInstalled = project.status === 'active';
+  let projectDownloaded = project.status === 'present';
 
   /**
    * Checks the download/install status of a project and updates the UI.
diff --git a/sveltejs/src/Project/AddInstallButton.svelte b/sveltejs/src/Project/AddInstallButton.svelte
index 5e2d5a6f256c4f3c3462c128ba8fbc9658c9fdfd..add2b0af9b8c9cd041d9e6b913d3eeda0fecd4da 100644
--- a/sveltejs/src/Project/AddInstallButton.svelte
+++ b/sveltejs/src/Project/AddInstallButton.svelte
@@ -1,8 +1,11 @@
 <script>
   import { openPopup } from '../popup';
-  import { MODULE_STATUS, ORIGIN_URL, PM_VALIDATION_ERROR } from '../constants';
+  import {
+    ORIGIN_URL,
+    PM_VALIDATION_ERROR,
+    PACKAGE_MANAGER_AVAILABLE,
+  } from '../constants';
   import ProjectButtonBase from './ProjectButtonBase.svelte';
-  import { isPackageManagerRequired } from '../stores';
 
   export let project;
   export let loading;
@@ -58,7 +61,7 @@
   /**
    * Installs an already downloaded module.
    */
-  async function installModule() {
+  async function activateProject() {
     loading = true;
     const url = `${ORIGIN_URL}/admin/modules/project_browser/activate/${project.id}`;
     const installResponse = await fetch(url);
@@ -75,7 +78,7 @@
       handleError(installResponse);
     }
     if (responseContent.status === 0) {
-      MODULE_STATUS[project.project_machine_name] = 1;
+      project.status = 'active';
       projectInstalled = true;
       loading = false;
     }
@@ -87,7 +90,7 @@
    * @param {boolean} install
    *   If true, the module will be installed after it is downloaded.
    */
-  function downloadModule(install = false) {
+  function downloadProject(install = false) {
     showStatus(true);
 
     /**
@@ -136,14 +139,14 @@
         // If this line is reached, then every stage of the download process
         // was completed without error and we can consider the module
         // downloaded and the process complete.
-        MODULE_STATUS[project.project_machine_name] = 0;
+        project.status = 'present';
         projectDownloaded = true;
         loading = false;
 
         // If install is true, install the module before conveying the process
         // is complete to the UI.
         if (install === true) {
-          installModule();
+          activateProject();
         }
       }
     }
@@ -156,12 +159,12 @@
 <ProjectButtonBase
   click={() => {
     if (alreadyAdded) {
-      installModule();
+      activateProject();
     } else {
-      downloadModule(true);
+      downloadProject(true);
     }
   }}
-  disabled={PM_VALIDATION_ERROR && $isPackageManagerRequired}
+  disabled={PM_VALIDATION_ERROR && PACKAGE_MANAGER_AVAILABLE}
 >
   {alreadyAdded ? Drupal.t('Install') : Drupal.t('Add and Install')}<span
     class="visually-hidden">{project.title}</span
diff --git a/sveltejs/src/ProjectBrowser.svelte b/sveltejs/src/ProjectBrowser.svelte
index 5de0db2492acfb1f6eea8e9077124289c2a5efe6..4403c2e82742eaea922071e6346d86a233d636da 100644
--- a/sveltejs/src/ProjectBrowser.svelte
+++ b/sveltejs/src/ProjectBrowser.svelte
@@ -20,7 +20,6 @@
     sortCriteria,
     preferredView,
     pageSize,
-    isPackageManagerRequired,
   } from './stores';
   import MediaQuery from './MediaQuery.svelte';
   import {
@@ -32,10 +31,10 @@
     ORIGIN_URL,
     FULL_MODULE_PATH,
     SORT_OPTIONS,
-    MODULE_STATUS,
     ALLOW_UI_INSTALL,
     PM_VALIDATION_ERROR,
     ACTIVE_PLUGINS,
+    PACKAGE_MANAGER_AVAILABLE,
   } from './constants';
   // cspell:ignore tabwise
 
@@ -116,13 +115,11 @@
       dataArray = Object.values(data);
       rows = data[$activeTab].list;
       $rowsCount = data[$activeTab].totalResults;
-      $isPackageManagerRequired = data[$activeTab].isPackageManagerRequired;
 
       if (
-        $isPackageManagerRequired &&
+        PACKAGE_MANAGER_AVAILABLE &&
         PM_VALIDATION_ERROR &&
         typeof PM_VALIDATION_ERROR === 'string' &&
-        MODULE_STATUS.package_manager &&
         ALLOW_UI_INSTALL
       ) {
         const messenger = new Drupal.Message();
diff --git a/sveltejs/src/constants.js b/sveltejs/src/constants.js
index 7f204cdd6651450b3b01c7034f0682cf510ee2dc..efc6460c8726de322c56fb1f876a21cff7ace8ea 100644
--- a/sveltejs/src/constants.js
+++ b/sveltejs/src/constants.js
@@ -15,7 +15,6 @@ export const DEFAULT_SOURCE_ID =
 export const CURRENT_SOURCES_KEYS =
   drupalSettings.project_browser.current_sources_keys;
 export const ORIGIN_URL = drupalSettings.project_browser.origin_url;
-export const MODULE_STATUS = drupalSettings.project_browser.modules;
 export const FULL_MODULE_PATH = `${ORIGIN_URL}/${drupalSettings.project_browser.module_path}`;
 export const ALLOW_UI_INSTALL = drupalSettings.project_browser.ui_install;
 export const DARK_COLOR_SCHEME =
@@ -23,3 +22,4 @@ export const DARK_COLOR_SCHEME =
   matchMedia('(prefers-color-scheme: dark)').matches;
 export const PM_VALIDATION_ERROR = drupalSettings.project_browser.pm_validation;
 export const ACTIVE_PLUGINS = drupalSettings.project_browser.active_plugins;
+export const PACKAGE_MANAGER_AVAILABLE = drupalSettings.project_browser.package_manager_available;
diff --git a/sveltejs/src/stores.js b/sveltejs/src/stores.js
index fd76e78e6faaa6b6beb5842c236b237a36f65a42..46858cd0195f440367740bfd1ac9c27dc0aecf6f 100644
--- a/sveltejs/src/stores.js
+++ b/sveltejs/src/stores.js
@@ -82,8 +82,5 @@ const storedPageSize = JSON.parse(sessionStorage.getItem('pageSize')) || 12;
 export const pageSize = writable(storedPageSize);
 pageSize.subscribe((val) => sessionStorage.setItem('pageSize', JSON.stringify(val)));
 
-// Store the Package Manager requirement.
-export const isPackageManagerRequired = writable(false);
-
 // Store the value of media queries.
 export const mediaQueryValues = writable(new Map());
diff --git a/tests/modules/project_browser_test/src/Plugin/ProjectBrowserSource/DrupalDotOrgJsonApi.php b/tests/modules/project_browser_test/src/Plugin/ProjectBrowserSource/DrupalDotOrgJsonApi.php
index 465501f2f055c390a348997e10c56f31f3ed495d..a4801e17d7fa6a9b4005ff3b76d02f87521a80a6 100644
--- a/tests/modules/project_browser_test/src/Plugin/ProjectBrowserSource/DrupalDotOrgJsonApi.php
+++ b/tests/modules/project_browser_test/src/Plugin/ProjectBrowserSource/DrupalDotOrgJsonApi.php
@@ -390,7 +390,6 @@ class DrupalDotOrgJsonApi extends ProjectBrowserSourceBase {
           machineName: $machine_name,
           body: $body,
           title: $project['attributes']['title'],
-          status: $project['attributes']['status'],
           changed: strtotime($project['attributes']['changed']),
           created: strtotime($project['attributes']['created']),
           author: [
@@ -404,7 +403,7 @@ class DrupalDotOrgJsonApi extends ProjectBrowserSourceBase {
       }
     }
 
-    return $this->createResultsPage($returned_list, TRUE, $api_response['total_results'] ?? 0);
+    return $this->createResultsPage($returned_list, $api_response['total_results'] ?? 0);
   }
 
   /**
diff --git a/tests/modules/project_browser_test/src/TestActivator.php b/tests/modules/project_browser_test/src/TestActivator.php
index 31c112021f58dc86afc0eb506062f5cd86d9ec35..9dae7ded6c2b00348e9b746fc046ddbda045ef5f 100644
--- a/tests/modules/project_browser_test/src/TestActivator.php
+++ b/tests/modules/project_browser_test/src/TestActivator.php
@@ -6,6 +6,7 @@ namespace Drupal\project_browser_test;
 
 use Drupal\Core\State\StateInterface;
 use Drupal\Core\Url;
+use Drupal\project_browser\ActivationStatus;
 use Drupal\project_browser\ActivatorInterface;
 use Drupal\project_browser\ProjectBrowser\Project;
 use Symfony\Component\HttpFoundation\Response;
@@ -24,14 +25,17 @@ class TestActivator implements ActivatorInterface {
    * {@inheritdoc}
    */
   public function supports(Project $project): bool {
-    return TRUE;
+    return $this->decorated->supports($project);
   }
 
   /**
    * {@inheritdoc}
    */
-  public function isActive(Project $project): bool {
-    return FALSE;
+  public function getStatus(Project $project): ActivationStatus {
+    if ($project->machineName === 'pinky_brain') {
+      return ActivationStatus::Present;
+    }
+    return $this->decorated->getStatus($project);
   }
 
   /**
diff --git a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
index 466f888e4c2b0621f2d7fc171e5f921bf1d161ff..db60f34493cb3a55b4730cd13af69057e6d0b922 100644
--- a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
+++ b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
@@ -4,6 +4,8 @@ declare(strict_types=1);
 
 namespace Drupal\Tests\project_browser\FunctionalJavascript;
 
+use Behat\Mink\Element\NodeElement;
+use Drupal\Core\Recipe\Recipe;
 use Drupal\Core\State\StateInterface;
 use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
 use Drupal\Tests\project_browser\Traits\PackageManagerFixtureUtilityTrait;
@@ -99,10 +101,11 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
     $popup = $assert_session->waitForElementVisible('css', '.project-browser-popup');
     $this->assertNotEmpty($popup);
     // The Pinky and the Brain module doesn't actually exist in the filesystem,
-    // but it was registered with JavaScript as if it was to test the presence
+    // but the test activator pretends it does, in order to test the presence
     // of the "Install" button as opposed vs. the default "Add and Install"
     // button. This happens to be a good way to test mid-install exceptions as
     // well.
+    // @see \Drupal\project_browser_test\TestActivator::getStatus()
     $this->assertStringContainsString('MissingDependencyException: Unable to install modules pinky_brain due to missing modules pinky_brain', $popup->getText());
 
     // The action button should have momentarily changed to a progress message,
@@ -112,6 +115,39 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
     $this->assertSame('Install Pinky and the Brain', $action_button->getText());
   }
 
+  /**
+   * Tests applying a recipe from the project browser UI.
+   */
+  public function testApplyRecipe(): void {
+    if (!class_exists(Recipe::class)) {
+      $this->markTestSkipped('This test cannot run because this version of Drupal does not support recipes.');
+    }
+    $assert_session = $this->assertSession();
+
+    $this->config('project_browser.admin_settings')
+      ->set('enabled_sources', ['recipes'])
+      ->save();
+
+    $this->drupalGet('admin/modules/browse');
+    $this->svelteInitHelper('css', '.pb-projects-list');
+    $this->inputSearchField('image');
+
+    // Apply a recipe that ships with core.
+    $card = $assert_session->waitForElementVisible('css', '.pb-project:contains("Image media type")');
+    $this->assertNotEmpty($card);
+    $assert_session->buttonExists('Install', $card)->press();
+    $recipe_applied = $card->waitFor(30, function (NodeElement $card): bool {
+      return $card->has('css', '.project_status-indicator:contains("Installed")');
+    });
+    $this->assertTrue($recipe_applied);
+
+    // If we reload, the installation status should be remembered.
+    $this->getSession()->reload();
+    $card = $assert_session->waitForElementVisible('css', '.pb-project:contains("Image media type")');
+    $this->assertNotEmpty($card);
+    $assert_session->elementExists('css', '.project_status-indicator:contains("Installed")', $card);
+  }
+
   /**
    * Tests install UI not available if not enabled.
    */
diff --git a/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php
index 23e55c875c7974775d2a0290be6b82e0ad29d00c..4064035b2a7be0115cb5b208fc3e35598856bd7f 100644
--- a/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php
+++ b/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php
@@ -6,6 +6,7 @@ namespace Drupal\Tests\project_browser\FunctionalJavascript;
 
 use Behat\Mink\Element\NodeElement;
 use Drupal\Core\Extension\MissingDependencyException;
+use Drupal\Core\Recipe\Recipe;
 use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
 
 // cspell:ignore coverageall doomer eggman quiznos statusactive statusmaintained
@@ -1018,4 +1019,35 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
     $this->assertNotEquals('clear-text', $has_focus_id);
   }
 
+  /**
+   * Tests that recipes show instructions for applying them.
+   */
+  public function testRecipeInstructions(): void {
+    if (!class_exists(Recipe::class)) {
+      $this->markTestSkipped('This test cannot run because this version of Drupal does not support recipes.');
+    }
+    $assert_session = $this->assertSession();
+
+    $this->config('project_browser.admin_settings')
+      ->set('enabled_sources', ['recipes'])
+      ->save();
+
+    $this->drupalGet('admin/modules/browse');
+    $this->svelteInitHelper('css', '.pb-projects-list');
+    $this->inputSearchField('image');
+
+    // Look for a recipe that ships with core.
+    $card = $assert_session->waitForElementVisible('css', '.pb-project:contains("Image media type")');
+    $this->assertNotEmpty($card);
+    $assert_session->buttonExists('View Commands', $card)->press();
+    $input = $assert_session->waitForElementVisible('css', '.command-box input');
+    $this->assertNotEmpty($input);
+    $command = $input->getValue();
+    // A full path to the PHP executable should be in the command.
+    $this->assertMatchesRegularExpression('/[^\s]+\/php /', $command);
+    $drupal_root = $this->getDrupalRoot();
+    $this->assertStringStartsWith("cd $drupal_root && ", $command);
+    $this->assertStringEndsWith("php $drupal_root/core/scripts/drupal recipe $drupal_root/core/recipes/image_media_type", $command);
+  }
+
 }
diff --git a/tests/src/Kernel/RecipesSourceTest.php b/tests/src/Kernel/RecipesSourceTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..bc1c723e003669161be803de8f9365c25332b15a
--- /dev/null
+++ b/tests/src/Kernel/RecipesSourceTest.php
@@ -0,0 +1,108 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\project_browser\Kernel;
+
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\Recipe\Recipe;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\project_browser\EnabledSourceHandler;
+use Drupal\project_browser\Plugin\ProjectBrowserSourceManager;
+use Drupal\project_browser\ProjectType;
+use Symfony\Component\Finder\Finder;
+use Symfony\Component\Finder\SplFileInfo;
+
+/**
+ * Tests the source plugin that exposes locally installed recipes.
+ *
+ * @group project_browser
+ * @covers \Drupal\project_browser\Plugin\ProjectBrowserSource\Recipes
+ */
+class RecipesSourceTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['project_browser'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    if (!class_exists(Recipe::class)) {
+      $this->markTestSkipped('This test cannot be run because the recipe system is not available.');
+    }
+    $this->installSchema('project_browser', [
+      'project_browser_projects',
+      'project_browser_categories',
+    ]);
+    $this->installConfig('project_browser');
+  }
+
+  /**
+   * @covers \project_browser_install
+   * @covers \project_browser_project_browser_source_info_alter
+   */
+  public function testRecipeSourceIsEnabledAtInstallTime(): void {
+    $this->assertNotContains('recipes', $this->config('project_browser.admin_settings')->get('enabled_sources'));
+
+    $this->container->get(ModuleHandlerInterface::class)
+      ->loadInclude('project_browser', 'install');
+    project_browser_install();
+    $this->assertContains('recipes', $this->config('project_browser.admin_settings')->get('enabled_sources'));
+
+    $enabled_sources = $this->container->get(EnabledSourceHandler::class)
+      ->getCurrentSources();
+    $this->assertArrayHasKey('recipes', $enabled_sources);
+  }
+
+  /**
+   * Tests that recipes are discovered by the plugin.
+   */
+  public function testRecipesAreDiscovered(): void {
+    $finder = Finder::create()
+      ->in($this->getDrupalRoot() . '/core/recipes')
+      ->directories()
+      ->notName('example')
+      ->depth(0);
+    $expected_recipe_names = array_map(fn (SplFileInfo $dir) => $dir->getBasename(), iterator_to_array($finder));
+    // This contributed recipe is one of our dev dependencies.
+    $expected_recipe_names[] = 'imagemagick-configuration';
+
+    /** @var \Drupal\project_browser\ProjectBrowser\ProjectsResultsPage $projects */
+    $projects = $this->container->get(ProjectBrowserSourceManager::class)
+      ->createInstance('recipes')
+      ->getProjects();
+    $found_recipes = [];
+    foreach ($projects->list as $project) {
+      $this->assertNotEmpty($project->title);
+      $this->assertSame(ProjectType::Recipe, $project->type);
+      $found_recipes[$project->machineName] = $project;
+    }
+    $found_recipe_names = array_keys($found_recipes);
+
+    // The `example` recipe (from core) should always be hidden.
+    $this->assertNotContains('example', $expected_recipe_names);
+
+    sort($expected_recipe_names);
+    sort($found_recipe_names);
+    $this->assertSame($expected_recipe_names, $found_recipe_names);
+
+    // Ensure the package names are properly resolved.
+    $this->assertSame('drupal/core', $found_recipes['standard']?->packageName);
+    $this->assertSame('kanopi/imagemagick-configuration', $found_recipes['imagemagick-configuration']?->packageName);
+
+    // The core recipes should have descriptions, which should become the body
+    // text of the project.
+    $this->assertArrayHasKey('standard', $found_recipes);
+    // The need for reflection sucks, but there's no way to introspect the body
+    // on the backend.
+    $body = (new \ReflectionProperty($found_recipes['standard'], 'body'))
+      ->getValue($found_recipes['standard']);
+    $this->assertNotEmpty($body);
+  }
+
+}