diff --git a/project_browser.libraries.yml b/project_browser.libraries.yml
index eb86d09ec2f62debf56126e79248c19886841e1f..1716ee1fdc00efacd3b257e75f4fb2def7498c55 100644
--- a/project_browser.libraries.yml
+++ b/project_browser.libraries.yml
@@ -8,6 +8,7 @@ svelte:
   dependencies:
     - core/drupalSettings
     - core/drupal
+    - core/drupal.ajax
     - core/drupal.debounce
     - core/drupal.dialog
     - core/drupal.announce
diff --git a/project_browser.services.yml b/project_browser.services.yml
index 4e2e42215233b7bccbd3109191c28ffd8e2ec63b..05c3bcd981ed75eb52ab062f91c3e1322e9866ec 100644
--- a/project_browser.services.yml
+++ b/project_browser.services.yml
@@ -41,3 +41,4 @@ services:
     public: false
     tags:
       - { name: paramconverter }
+  Drupal\project_browser\ProjectBrowser\Normalizer: ~
diff --git a/src/ActivationManager.php b/src/ActivationManager.php
index 91e78e68dd08fd2d939bf85134c68a7d5a6bec87..b2a89d7895a667894548b1f8dc838838122800e4 100644
--- a/src/ActivationManager.php
+++ b/src/ActivationManager.php
@@ -4,15 +4,9 @@ declare(strict_types=1);
 
 namespace Drupal\project_browser;
 
-use Drupal\Component\Utility\Xss;
-use Drupal\Core\Link;
-use Drupal\Core\Render\RendererInterface;
 use Drupal\project_browser\Activator\ActivationStatus;
 use Drupal\project_browser\Activator\ActivatorInterface;
-use Drupal\project_browser\Activator\InstructionsInterface;
-use Drupal\project_browser\Activator\TasksInterface;
 use Drupal\project_browser\ProjectBrowser\Project;
-use Symfony\Component\HttpFoundation\Response;
 
 /**
  * A generalized activator that can handle any type of project.
@@ -29,10 +23,6 @@ final class ActivationManager {
    */
   private array $activators = [];
 
-  public function __construct(
-    private readonly RendererInterface $renderer,
-  ) {}
-
   /**
    * Registers an activator.
    *
@@ -71,7 +61,7 @@ final class ActivationManager {
    * @throws \InvalidArgumentException
    *   Thrown if none of the registered activators can handle the given project.
    */
-  private function getActivatorForProject(Project $project): ActivatorInterface {
+  public function getActivatorForProject(Project $project): ActivatorInterface {
     foreach ($this->activators as $activator) {
       if ($activator->supports($project)) {
         return $activator;
@@ -80,71 +70,17 @@ final class ActivationManager {
     throw new \InvalidArgumentException("The project '$project->machineName' is not supported by any registered activators.");
   }
 
-  /**
-   * Gets activation information for a project, for delivery to the front-end.
-   *
-   * @param \Drupal\project_browser\ProjectBrowser\Project $project
-   *   A project object.
-   *
-   * @return array
-   *   An array of activation information. Will consist of:
-   *   - `status`: The activation status of the project on the current site.
-   *     Will be the lowercase name of the one of the cases of
-   *     \Drupal\project_browser\Activator\ActivationStatus.
-   *   - `commands`: The instructions a human can take to activate the project
-   *     manually, or a URL where they can do so. Will be NULL if the registered
-   *     activator which supports the given project is not capable of generating
-   *     instructions.
-   *   - `tasks`: An array of \Drupal\Core\Link objects for specific follow-up
-   *      tasks that a user can take after activating this project. For example,
-   *      could include a link to a module's configuration form, or a dashboard
-   *      provided by a recipe.
-   *
-   * @see \Drupal\project_browser\ProjectBrowser\Project::toArray()
-   */
-  public function getActivationInfo(Project $project): array {
-    $activator = $this->getActivatorForProject($project);
-    $data = [
-      'status' => strtolower($activator->getStatus($project)->name),
-      'commands' => NULL,
-      'tasks' => [],
-    ];
-
-    if ($activator instanceof InstructionsInterface) {
-      $data['commands'] = Xss::filter(
-        $activator->getInstructions($project),
-        [...Xss::getAdminTagList(), 'textarea', 'button'],
-      );
-    }
-
-    if ($activator instanceof TasksInterface) {
-      $map = function (Link $link): array {
-        $text = $link->getText();
-        if (is_array($text)) {
-          $text = $this->renderer->renderInIsolation($text);
-        }
-        return [
-          'text' => (string) $text,
-          'url' => $link->getUrl()->setAbsolute()->toString(),
-        ];
-      };
-      $data['tasks'] = array_values(array_map($map, $activator->getTasks($project)));
-    }
-
-    return $data;
-  }
-
   /**
    * Activates a project on the current site.
    *
    * @param \Drupal\project_browser\ProjectBrowser\Project $project
    *   The project to activate.
    *
-   * @return \Symfony\Component\HttpFoundation\Response|null
-   *   The response, or lack thereof, returned by the first registered activator
-   *   that supports the given project.
+   * @return \Drupal\Core\Ajax\CommandInterface[]|null
+   *   The AJAX commands, or lack thereof, returned by the first registered
+   *   activator that supports the given project.
    */
-  public function activate(Project $project): ?Response {
+  public function activate(Project $project): ?array {
     return $this->getActivatorForProject($project)->activate($project);
   }
 
diff --git a/src/Activator/ActivatorInterface.php b/src/Activator/ActivatorInterface.php
index 5fc5a86395611680cb7f7450612972a359d71346..ea28361b70bee70e4b31d46e8240491516fb0331 100644
--- a/src/Activator/ActivatorInterface.php
+++ b/src/Activator/ActivatorInterface.php
@@ -5,7 +5,6 @@ declare(strict_types=1);
 namespace Drupal\project_browser\Activator;
 
 use Drupal\project_browser\ProjectBrowser\Project;
-use Symfony\Component\HttpFoundation\Response;
 
 /**
  * Defines an interface for services which can activate projects.
@@ -48,11 +47,9 @@ interface ActivatorInterface {
    * @param \Drupal\project_browser\ProjectBrowser\Project $project
    *   The project to activate.
    *
-   * @return \Symfony\Component\HttpFoundation\Response|null
-   *   Optionally, a response that should be presented to the user in Project
-   *   Browser. This could be a set of additional instructions to display in a
-   *   modal, for example, or a redirect to a configuration form.
+   * @return \Drupal\Core\Ajax\CommandInterface[]|null
+   *   Optionally, an array of AJAX commands to run on the front end.
    */
-  public function activate(Project $project): ?Response;
+  public function activate(Project $project): ?array;
 
 }
diff --git a/src/Activator/ModuleActivator.php b/src/Activator/ModuleActivator.php
index 9389d71732680fe3e5fb45411b5685fcc8fcefd0..6edee6617b58d394e07810e2b5837a76335a011d 100644
--- a/src/Activator/ModuleActivator.php
+++ b/src/Activator/ModuleActivator.php
@@ -14,7 +14,6 @@ use Drupal\Core\Session\AccountInterface;
 use Drupal\Core\Url;
 use Drupal\project_browser\ProjectBrowser\Project;
 use Drupal\project_browser\ProjectType;
-use Symfony\Component\HttpFoundation\JsonResponse;
 use Symfony\Component\HttpFoundation\RequestStack;
 
 /**
@@ -56,12 +55,9 @@ final class ModuleActivator implements InstructionsInterface, TasksInterface {
   /**
    * {@inheritdoc}
    */
-  public function activate(Project $project): JsonResponse {
+  public function activate(Project $project): null {
     $this->moduleInstaller->install([$project->machineName]);
-
-    return new JsonResponse([
-      'tasks' => $this->getTasks($project),
-    ]);
+    return NULL;
   }
 
   /**
diff --git a/src/Activator/RecipeActivator.php b/src/Activator/RecipeActivator.php
index 5af9c6e778c65e03cd3e558626241c26036118e3..2cff5fc002cbf64b84585f84d386d91cf8a5f2c4 100644
--- a/src/Activator/RecipeActivator.php
+++ b/src/Activator/RecipeActivator.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace Drupal\project_browser\Activator;
 
 use Composer\InstalledVersions;
+use Drupal\Core\Ajax\RedirectCommand;
 use Drupal\Core\Extension\ModuleExtensionList;
 use Drupal\Core\File\FileSystemInterface;
 use Drupal\Core\File\FileUrlGeneratorInterface;
@@ -18,8 +19,6 @@ use Drupal\Core\Url;
 use Drupal\project_browser\ProjectBrowser\Project;
 use Drupal\project_browser\ProjectType;
 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
-use Symfony\Component\HttpFoundation\JsonResponse;
-use Symfony\Component\HttpFoundation\Response;
 
 /**
  * Applies locally installed recipes.
@@ -93,7 +92,7 @@ final class RecipeActivator implements InstructionsInterface, EventSubscriberInt
   /**
    * {@inheritdoc}
    */
-  public function activate(Project $project): ?Response {
+  public function activate(Project $project): ?array {
     $path = $this->getPath($project);
     if (!$path) {
       return NULL;
@@ -114,19 +113,13 @@ final class RecipeActivator implements InstructionsInterface, EventSubscriberInt
           'recipe' => $path,
         ],
       ]);
-
-      // The `redirect` key is not meaningful to JsonResponse; this is handled
-      // specially by the Svelte app.
-      // @see sveltejs/src/ProcessInstallListButton.svelte
-      return new JsonResponse([
-        'redirect' => $url->setAbsolute()->toString(),
-      ]);
+      return [
+        new RedirectCommand($url->setAbsolute()->toString()),
+      ];
     }
 
     RecipeRunner::processRecipe($recipe);
-    return new JsonResponse([
-      'tasks' => $this->getTasks($project),
-    ]);
+    return NULL;
   }
 
   /**
diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php
index 29269ce895c8d07f76eca4e01d22f5e83042508e..0bd3f6b92f1d3dd8b8b1b8bb735ab2155bdadef3 100644
--- a/src/Controller/InstallerController.php
+++ b/src/Controller/InstallerController.php
@@ -4,21 +4,27 @@ namespace Drupal\project_browser\Controller;
 
 use Drupal\Component\Datetime\TimeInterface;
 use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Ajax\AjaxResponse;
+use Drupal\Core\Ajax\MessageCommand;
 use Drupal\Core\Controller\ControllerBase;
+use Drupal\Core\Messenger\MessengerInterface;
 use Drupal\Core\Url;
+use Drupal\Core\Utility\Error;
 use Drupal\package_manager\Exception\StageException;
 use Drupal\package_manager\StatusCheckTrait;
 use Drupal\project_browser\ActivationManager;
 use Drupal\project_browser\ComposerInstaller\Installer;
 use Drupal\project_browser\EnabledSourceHandler;
 use Drupal\project_browser\InstallState;
+use Drupal\project_browser\ProjectBrowser\Normalizer;
+use Drupal\project_browser\RefreshProjectCommand;
 use Drupal\system\SystemManager;
 use Psr\Log\LoggerInterface;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 use Symfony\Component\HttpFoundation\JsonResponse;
 use Symfony\Component\HttpFoundation\RedirectResponse;
 use Symfony\Component\HttpFoundation\Request;
-use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
 use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
 
 /**
@@ -43,6 +49,7 @@ final class InstallerController extends ControllerBase {
     private readonly ActivationManager $activationManager,
     private readonly InstallState $installState,
     private readonly EventDispatcherInterface $eventDispatcher,
+    private readonly NormalizerInterface $normalizer,
   ) {}
 
   /**
@@ -61,6 +68,7 @@ final class InstallerController extends ControllerBase {
       $container->get(ActivationManager::class),
       $container->get(InstallState::class),
       $container->get(EventDispatcherInterface::class),
+      $container->get(Normalizer::class),
     );
   }
 
@@ -418,30 +426,53 @@ final class InstallerController extends ControllerBase {
   }
 
   /**
-   * Installs an already downloaded module.
+   * Installs an already downloaded project.
    *
    * @param \Symfony\Component\HttpFoundation\Request $request
    *   The request.
    *
-   * @return \Symfony\Component\HttpFoundation\Response
-   *   Status message.
+   * @return \Drupal\Core\Ajax\AjaxResponse
+   *   A response that can be used by the client-side AJAX system.
    */
-  public function activate(Request $request): Response {
-    foreach ($request->toArray() as $project_id) {
+  public function activate(Request $request): AjaxResponse {
+    $response = new AjaxResponse();
+
+    $projects = $request->getPayload()->get('projects') ?? [];
+    if ($projects) {
+      assert(is_string($projects));
+      $projects = explode(',', $projects);
+    }
+    assert(is_array($projects));
+    foreach ($projects as $project_id) {
       $this->installState->setState($project_id, 'activating');
+      [$source_id] = explode('/', $project_id, 2);
+
       try {
         $project = $this->enabledSourceHandler->getStoredProject($project_id);
-        $response = $this->activationManager->activate($project);
+
+        $commands = $this->activationManager->activate($project);
+        foreach ($commands ?? [] as $command) {
+          $response->addCommand($command);
+        }
         $this->installState->setState($project_id, 'installed');
+
+        $project = $this->normalizer->normalize($project, context: ['source' => $source_id]);
+        assert(is_array($project));
+        $response->addCommand(new RefreshProjectCommand($project));
       }
       catch (\Throwable $e) {
-        return $this->errorResponse($e, 'project install');
-      }
-      finally {
-        $this->installState->deleteAll();
+        $message = $e->getMessage();
+        $response->addCommand(new MessageCommand(
+          $message,
+          options: [
+            'type' => MessengerInterface::TYPE_ERROR,
+          ],
+        ));
+        Error::logException($this->logger, $e);
       }
     }
-    return $response ?? new JsonResponse(['status' => 0]);
+    $this->installState->deleteAll();
+    return $response;
   }
 
   /**
diff --git a/src/Controller/ProjectBrowserEndpointController.php b/src/Controller/ProjectBrowserEndpointController.php
index b75d3c938958e027076866d9c6efc369126e6b4f..05c23e0b66272ab4120ecf41b30fd4c294434c0b 100644
--- a/src/Controller/ProjectBrowserEndpointController.php
+++ b/src/Controller/ProjectBrowserEndpointController.php
@@ -4,16 +4,15 @@ namespace Drupal\project_browser\Controller;
 
 use Drupal\Core\Controller\ControllerBase;
 use Drupal\Core\Form\FormState;
-use Drupal\project_browser\ActivationManager;
 use Drupal\project_browser\EnabledSourceHandler;
-use Drupal\project_browser\ProjectBrowser\Project;
-use Drupal\project_browser\ProjectBrowser\ProjectsResultsPage;
+use Drupal\project_browser\ProjectBrowser\Normalizer;
 use Drupal\system\Form\ModulesUninstallForm;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 use Symfony\Component\HttpFoundation\JsonResponse;
 use Symfony\Component\HttpFoundation\RedirectResponse;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
 
 /**
  * Controller for the proxy layer.
@@ -22,7 +21,7 @@ final class ProjectBrowserEndpointController extends ControllerBase {
 
   public function __construct(
     private readonly EnabledSourceHandler $enabledSource,
-    private readonly ActivationManager $activationManager,
+    private readonly NormalizerInterface $normalizer,
   ) {}
 
   /**
@@ -31,7 +30,7 @@ final class ProjectBrowserEndpointController extends ControllerBase {
   public static function create(ContainerInterface $container): static {
     return new static(
       $container->get(EnabledSourceHandler::class),
-      $container->get(ActivationManager::class),
+      $container->get(Normalizer::class),
     );
   }
 
@@ -54,33 +53,7 @@ final class ProjectBrowserEndpointController extends ControllerBase {
     }
 
     $results = $this->enabledSource->getProjects($query['source'], $query);
-    return new JsonResponse($this->prepareResults($results));
-  }
-
-  /**
-   * Prepares a set of results to be delivered to the front end.
-   *
-   * @param \Drupal\project_browser\ProjectBrowser\ProjectsResultsPage $results
-   *   A page of query results.
-   *
-   * @return array
-   *   The query results, with activation info and fully qualified IDs added to
-   *   all projects.
-   */
-  private function prepareResults(ProjectsResultsPage $results): array {
-    $data = $results->toArray();
-
-    // Add activation info to all the projects in the result set, and fully
-    // qualify the project IDs by prefixing them with the source plugin ID.
-    $mapper = function (Project $project) use ($results): array {
-      $data = $this->activationManager->getActivationInfo($project) + $project->toArray();
-      // Always send a fully qualified project ID to the front end.
-      $data['id'] = $results->pluginId . '/' . $project->id;
-      return $data;
-    };
-    $data['list'] = array_map($mapper, $data['list']);
-
-    return $data;
+    return new JsonResponse($this->normalizer->normalize($results));
   }
 
   /**
diff --git a/src/ProjectBrowser/Normalizer.php b/src/ProjectBrowser/Normalizer.php
new file mode 100644
index 0000000000000000000000000000000000000000..eafa7431ca514f038eb5073c823acaaf33442878
--- /dev/null
+++ b/src/ProjectBrowser/Normalizer.php
@@ -0,0 +1,117 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\project_browser\ProjectBrowser;
+
+use Drupal\Component\Utility\Xss;
+use Drupal\Core\Link;
+use Drupal\Core\Render\RendererInterface;
+use Drupal\project_browser\ActivationManager;
+use Drupal\project_browser\Activator\InstructionsInterface;
+use Drupal\project_browser\Activator\TasksInterface;
+use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
+
+/**
+ * Normalizes Project and ProjectsResultsPage objects into an array format.
+ */
+final class Normalizer implements NormalizerInterface {
+
+  public function __construct(
+    private readonly ActivationManager $activationManager,
+    private readonly RendererInterface $renderer,
+  ) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function normalize(mixed $data, ?string $format = NULL, array $context = []): array {
+    if ($data instanceof Project) {
+      assert(array_key_exists('source', $context));
+      $data = $this->getActivationInfo($data) + $data->toArray();
+      $data['id'] = $context['source'] . '/' . $data['id'];
+    }
+    elseif ($data instanceof ProjectsResultsPage) {
+      $context['source'] = $data->pluginId;
+
+      $data = $data->toArray();
+      $data['list'] = array_map(
+        fn (Project $project): array => $this->normalize($project, $format, $context),
+        $data['list'],
+      );
+    }
+    return $data;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function supportsNormalization(mixed $data, ?string $format = NULL, array $context = []): bool {
+    return $data instanceof Project || $data instanceof ProjectsResultsPage;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getSupportedTypes(?string $format): array {
+    return [
+      Project::class => TRUE,
+      ProjectsResultsPage::class => TRUE,
+    ];
+  }
+
+  /**
+   * Gets activation information for a project, for delivery to the front-end.
+   *
+   * @param \Drupal\project_browser\ProjectBrowser\Project $project
+   *   A project object.
+   *
+   * @return array
+   *   An array of activation information. Will consist of:
+   *   - `status`: The activation status of the project on the current site.
+   *     Will be the lowercase name of the one of the cases of
+   *     \Drupal\project_browser\Activator\ActivationStatus.
+   *   - `commands`: The instructions a human can take to activate the project
+   *     manually, or a URL where they can do so. Will be NULL if the registered
+   *     activator which supports the given project is not capable of generating
+   *     instructions.
+   *   - `tasks`: An array of \Drupal\Core\Link objects for specific follow-up
+   *      tasks that a user can take after activating this project. For example,
+   *      could include a link to a module's configuration form, or a dashboard
+   *      provided by a recipe.
+   *
+   * @see \Drupal\project_browser\ProjectBrowser\Project::toArray()
+   */
+  private function getActivationInfo(Project $project): array {
+    $activator = $this->activationManager->getActivatorForProject($project);
+    $data = [
+      'status' => strtolower($activator->getStatus($project)->name),
+      'commands' => NULL,
+      'tasks' => [],
+    ];
+
+    if ($activator instanceof InstructionsInterface) {
+      $data['commands'] = Xss::filter(
+        $activator->getInstructions($project),
+        [...Xss::getAdminTagList(), 'textarea', 'button'],
+      );
+    }
+
+    if ($activator instanceof TasksInterface) {
+      $map = function (Link $link): array {
+        $text = $link->getText();
+        if (is_array($text)) {
+          $text = $this->renderer->renderInIsolation($text);
+        }
+        return [
+          'text' => (string) $text,
+          'url' => $link->getUrl()->setAbsolute()->toString(),
+        ];
+      };
+      $data['tasks'] = array_values(array_map($map, $activator->getTasks($project)));
+    }
+
+    return $data;
+  }
+
+}
diff --git a/src/RefreshProjectCommand.php b/src/RefreshProjectCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..c6fab42a027e98558d6776fab416d2840b257364
--- /dev/null
+++ b/src/RefreshProjectCommand.php
@@ -0,0 +1,28 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\project_browser;
+
+use Drupal\Core\Ajax\CommandInterface;
+
+/**
+ * An AJAX command to refresh a particular project in the Svelte app.
+ */
+final class RefreshProjectCommand implements CommandInterface {
+
+  public function __construct(
+    private readonly array $project,
+  ) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function render(): array {
+    return [
+      'command' => 'refresh_project',
+      'project' => $this->project,
+    ];
+  }
+
+}
diff --git a/sveltejs/public/build/bundle.js b/sveltejs/public/build/bundle.js
index 6ab303721ff78d20ecbe605d0f4438ff3090dafb..2dc635852ab161656e8e26b7c0bd72abc61f7084 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 bc7ad9184da97ca23d5fe3bd230bd982a766b9bb..b43efeff6ef60e0e41ec035178f8fc9cb8272d0c 100644
Binary files a/sveltejs/public/build/bundle.js.map and b/sveltejs/public/build/bundle.js.map differ
diff --git a/sveltejs/src/InstallListProcessor.js b/sveltejs/src/InstallListProcessor.js
index 2a89442dbf5db573c1f97db6ebb2415103d7319a..67025be165cf151ca9e1ccab804c04f2a5947e17 100644
--- a/sveltejs/src/InstallListProcessor.js
+++ b/sveltejs/src/InstallListProcessor.js
@@ -2,6 +2,8 @@ import { get, writable } from 'svelte/store';
 import { openPopup } from './popup';
 import { BASE_URL, CURRENT_PATH } from './constants';
 
+const { Drupal } = window;
+
 export const updated = writable(0);
 
 // Store for the install list.
@@ -95,30 +97,16 @@ export const handleError = async (errorResponse) => {
  *   A promise that resolves when the project is activated.
  */
 export const activateProject = async (projectIds) => {
-  const url = `${BASE_URL}admin/modules/project_browser/activate`;
-
-  const installResponse = await fetch(url, {
-    method: 'POST',
-    headers: {
-      'Content-Type': 'application/json',
+  await new Drupal.Ajax(
+    null,
+    document.createElement('div'),
+    {
+      url: `${BASE_URL}admin/modules/project_browser/activate`,
+      submit: {
+        projects: projectIds.join(','),
+      },
     },
-    body: JSON.stringify(projectIds),
-  });
-
-  if (!installResponse.ok) {
-    await handleError(installResponse);
-    return;
-  }
-
-  try {
-    const responseContent = JSON.parse(await installResponse.text());
-
-    if (responseContent.hasOwnProperty('redirect')) {
-      window.location.href = responseContent.redirect;
-    }
-  } catch (err) {
-    await handleError(installResponse);
-  }
+  ).execute();
 };
 
 /**
diff --git a/tests/src/Functional/InstallerControllerTest.php b/tests/src/Functional/InstallerControllerTest.php
index 617fee9838f7713792c6e04d29d0bb498cecc5a7..68480eb72e0a9cabad73cd2e9411187292a585be 100644
--- a/tests/src/Functional/InstallerControllerTest.php
+++ b/tests/src/Functional/InstallerControllerTest.php
@@ -179,9 +179,10 @@ class InstallerControllerTest extends BrowserTestBase {
     // via composer.
     $contents = $this->drupalGet('admin/modules/project_browser/install-begin');
     $this->stageId = Json::decode($contents)['stage_id'];
-    $response = $this->getPostResponse('project_browser.stage.require', 'project_browser_test_mock/core', [
-      'stage_id' => $this->stageId,
-    ]);
+    $response = $this->getPostResponse(
+      Url::fromRoute('project_browser.stage.require', ['stage_id' => $this->stageId]),
+      ['project_browser_test_mock/core'],
+    );
     $this->assertSame(500, (int) $response->getStatusCode());
     $this->assertSame('{"message":"StageEventException: The following package is already installed: drupal\/core\n","phase":"require"}', (string) $response->getBody());
   }
@@ -206,9 +207,10 @@ class InstallerControllerTest extends BrowserTestBase {
    * @covers ::require
    */
   private function doRequire(): void {
-    $response = $this->getPostResponse('project_browser.stage.require', 'project_browser_test_mock/awesome_module', [
-      'stage_id' => $this->stageId,
-    ]);
+    $response = $this->getPostResponse(
+      Url::fromRoute('project_browser.stage.require', ['stage_id' => $this->stageId]),
+      ['project_browser_test_mock/awesome_module'],
+    );
     $expected_output = sprintf('{"phase":"require","status":0,"stage_id":"%s"}', $this->stageId);
     $this->assertSame($expected_output, (string) $response->getBody());
     $this->assertInstallInProgress('project_browser_test_mock/awesome_module', 'requiring');
@@ -311,9 +313,10 @@ class InstallerControllerTest extends BrowserTestBase {
     $result = ValidationResult::createError([$message]);
     $this->doStart();
     TestSubscriber::setTestResult([$result], PreRequireEvent::class);
-    $response = $this->getPostResponse('project_browser.stage.require', 'project_browser_test_mock/awesome_module', [
-      'stage_id' => $this->stageId,
-    ]);
+    $response = $this->getPostResponse(
+      Url::fromRoute('project_browser.stage.require', ['stage_id' => $this->stageId]),
+      ['project_browser_test_mock/awesome_module'],
+    );
     $this->assertSame(500, (int) $response->getStatusCode());
     $this->assertSame('{"message":"StageEventException: This is a PreRequire error.\n","phase":"require"}', (string) $response->getBody());
   }
@@ -327,9 +330,10 @@ class InstallerControllerTest extends BrowserTestBase {
     $error = new \Exception('PreRequire did not go well.');
     TestSubscriber::setException($error, PreRequireEvent::class);
     $this->doStart();
-    $response = $this->getPostResponse('project_browser.stage.require', 'project_browser_test_mock/awesome_module', [
-      'stage_id' => $this->stageId,
-    ]);
+    $response = $this->getPostResponse(
+      Url::fromRoute('project_browser.stage.require', ['stage_id' => $this->stageId]),
+      ['project_browser_test_mock/awesome_module'],
+    );
     $this->assertSame(500, (int) $response->getStatusCode());
     $this->assertSame('{"message":"StageEventException: PreRequire did not go well.","phase":"require"}', (string) $response->getBody());
   }
@@ -343,9 +347,10 @@ class InstallerControllerTest extends BrowserTestBase {
     $error = new \Exception('PostRequire did not go well.');
     TestSubscriber::setException($error, PostRequireEvent::class);
     $this->doStart();
-    $response = $this->getPostResponse('project_browser.stage.require', 'project_browser_test_mock/awesome_module', [
-      'stage_id' => $this->stageId,
-    ]);
+    $response = $this->getPostResponse(
+      Url::fromRoute('project_browser.stage.require', ['stage_id' => $this->stageId]),
+      ['project_browser_test_mock/awesome_module'],
+    );
     $this->assertSame(500, (int) $response->getStatusCode());
     $this->assertSame('{"message":"StageEventException: PostRequire did not go well.","phase":"require"}', (string) $response->getBody());
   }
@@ -561,11 +566,13 @@ class InstallerControllerTest extends BrowserTestBase {
     $assert_session->checkboxNotChecked('edit-modules-views-enable');
     $assert_session->checkboxNotChecked('edit-modules-views-ui-enable');
 
-    $response = $this->getPostResponse('project_browser.activate', 'drupal_core/views_ui');
+    $response = $this->getPostResponse(
+      Url::fromRoute('project_browser.activate'),
+      ['projects' => 'drupal_core/views_ui'],
+    );
     $this->assertSame(200, $response->getStatusCode());
     $this->assertTrue(json_validate((string) $response->getBody()));
 
-    $this->rebuildContainer();
     $this->drupalGet('admin/modules');
     $assert_session->checkboxChecked('edit-modules-views-enable');
     $assert_session->checkboxChecked('edit-modules-views-ui-enable');
@@ -589,28 +596,21 @@ class InstallerControllerTest extends BrowserTestBase {
   /**
    * Sends a POST request to the specified route with the provided project ID.
    *
-   * @param string $route_name
-   *   The route to which the POST request is sent.
-   * @param string|string[] $project_id
-   *   The project ID(s) to include in the POST request body.
-   * @param array $route_parameters
-   *   (optional) An associative array of route parameters, such as 'stage_id',
-   *   that will be included in the URL.
+   * @param \Drupal\Core\Url $url
+   *   The URL to which the POST request is sent.
+   * @param array $payload
+   *   The POST request body. Will be encoded to JSON.
    *
    * @return \Psr\Http\Message\ResponseInterface
    *   The response.
    */
-  private function getPostResponse(string $route_name, string|array $project_id, array $route_parameters = []): ResponseInterface {
-    $post_url = Url::fromRoute($route_name, $route_parameters);
-
-    $request_options = [
+  private function getPostResponse(Url $url, array $payload): ResponseInterface {
+    return $this->makeApiRequest('POST', $url, [
       RequestOptions::HEADERS => [
         'Content-Type' => 'application/json',
       ],
-    ];
-    $request_options[RequestOptions::BODY] = Json::encode((array) $project_id);
-
-    return $this->makeApiRequest('POST', $post_url, $request_options);
+      RequestOptions::BODY => Json::encode($payload),
+    ]);
   }
 
   /**
diff --git a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
index f0618297083d700c99aa1939dc61f946e42b0dc1..c29e717673d2b023cd96712cbabd598706794a63 100644
--- a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
+++ b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
@@ -96,14 +96,15 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
     $this->drupalGet('admin/modules/browse/project_browser_test_mock');
     $this->waitForProject('Pinky and the Brain')
       ->pressButton('Install Pinky and the Brain');
-    $popup = $this->assertElementIsVisible('css', '.project-browser-popup');
     // The Pinky and the Brain module doesn't actually exist in the filesystem,
     // 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());
+    $message = 'Unable to install modules pinky_brain due to missing modules pinky_brain';
+    $this->assertPageHasText($message);
+    $this->assertSession()->statusMessageContains($message, 'error');
   }
 
   /**