diff --git a/openapi.yml b/openapi.yml
index 7ad22f7aa276558edf5a96f8325e2110a5583fae..536959d5def5c543ade3c1a1faaf8aa8288141df 100644
--- a/openapi.yml
+++ b/openapi.yml
@@ -1,3 +1,6 @@
+#file: noinspection YAMLSchemaValidation
+# ^ To suppress false positive validation errors in PhpStorm.
+
 # For readability and to prevent needless merge conflicts, follow all possible
 # conventions in the official OpenAPI specification and documentation, including
 # the order of fixed fields like paths and components, spacing, and quoting of
@@ -18,14 +21,19 @@
 #   any.
 
 openapi: 3.1.0
+
 info:
   title: Experience Builder
   description: API Spec for Experience Builder
   version: 0.x
 
+# @see https://spec.openapis.org/oas/v3.1.0.html#paths-object
+# @see https://spec.openapis.org/oas/v3.1.0.html#path-item-object
 paths:
   '/api/layout/{entityTypeId}/{entityId}':
+    description: TODO
     get:
+      description: TODO
       parameters:
         - $ref: '#/components/parameters/entityTypeId'
         - $ref: '#/components/parameters/entityId'
@@ -68,7 +76,7 @@ paths:
                       'created[0][value][date]': '2024-12-18'
                       'created[0][value][time]': '00:18:25'
                       'field_hero[0][target_id]': '1'
-                      'field_hero[0][alt]': 'A man walks a border collie along a beach.'
+                      'field_hero[0][alt]': A man walks a border collie along a beach.
                       'field_hero[0][title]': ''
                       'field_hero[0][width]': '207'
                       'field_hero[0][height]': '475'
@@ -87,8 +95,8 @@ paths:
                       'promote[value]': '1'
                       'status[value]': '1'
                       'sticky[value]': '0'
-                      'title[0][value]': 'XB Needs This For The Time Being'
-                      'uid[0][target_id]': 'Anonymous (0)'
+                      'title[0][value]': XB Needs This For The Time Being
+                      'uid[0][target_id]': Anonymous (0)
                       'langcode[0][value]': en
                       'revision_log[0][value]': ''
               schema:
@@ -111,7 +119,9 @@ paths:
                     type: object
                     description: The full entity data.
   '/api/preview/{entityTypeId}/{entityId}':
+    description: TODO
     post:
+      description: TODO
       parameters:
         - $ref: '#/components/parameters/entityTypeId'
         - $ref: '#/components/parameters/entityId'
@@ -131,7 +141,9 @@ paths:
                     type: string
                     description: The HTML preview.
   '/xb/api/content-update/{entityTypeId}/{entityId}':
+    description: TODO
     patch:
+      description: TODO
       parameters:
         - $ref: '#/components/parameters/entityTypeId'
         - $ref: '#/components/parameters/entityId'
@@ -153,7 +165,9 @@ paths:
                     items:
                       type: string
   /xb-components:
+    description: TODO
     get:
+      description: TODO
       responses:
         '200':
           description: All available components
@@ -222,10 +236,12 @@ paths:
                   '^\\[a-zA-Z0-9_-]:[a-zA-Z0-9_-]$':
                     $ref: '#/components/schemas/Component'
   '/xb-field-form/{entityTypeId}/{entityId}':
+    description: TODO
     parameters:
       - $ref: '#/components/parameters/entityTypeId'
       - $ref: '#/components/parameters/entityId'
     get:
+      description: TODO
       responses:
         '200':
           description: Component props form
@@ -258,6 +274,7 @@ paths:
                       Stringified settings info structured for use by `settings`
                       AJAX command
     post:
+      description: TODO
       parameters:
         # @see \Drupal\Core\Form\FormBuilderInterface::AJAX_FORM_REQUEST
         # @see \Drupal\Core\Form\EventSubscriber\FormAjaxSubscriber
@@ -303,8 +320,9 @@ paths:
         '200':
           $ref: '#/components/responses/FormResponse'
   '/xb/api/entity-form/{entityTypeId}/{entityId}':
-    description: Fetches the entity form with the "default" form mode
+    description: TODO
     get:
+      description: Fetches the entity form with the "default" form mode
       parameters:
         - $ref: '#/components/parameters/entityTypeId'
         - $ref: '#/components/parameters/entityId'
@@ -312,7 +330,9 @@ paths:
         '200':
           $ref: '#/components/responses/FormResponse'
   /xb/api/log-error:
+    description: TODO
     post:
+      description: TODO
       requestBody:
         description: Log an error message with a specified log level.
         content:
@@ -348,7 +368,8 @@ paths:
                 properties:
                   status:
                     type: string
-                    example: Error logged successfully
+                    examples:
+                      - Error logged successfully
                     description: The success message returned after logging the error.
         '400':
           description: Invalid request payload
@@ -361,7 +382,9 @@ paths:
                     type: string
                     description: Error description
   /xb/api/publish-all:
+    description: TODO
     post:
+      description: TODO
       requestBody:
         content:
           application/json:
@@ -378,7 +401,8 @@ paths:
                   message:
                     type: string
                     title: Success message
-                    example: Successfully published 3 items
+                    examples:
+                      - Successfully published 3 items
         '204':
           description: No items to publish
           content:
@@ -389,7 +413,8 @@ paths:
                   message:
                     type: string
                     title: Status message
-                    example: No items to publish
+                    examples:
+                      - No items to publish
         '422':
           description: Validation errors exist
           content:
@@ -405,7 +430,9 @@ paths:
                       schema:
                         $ref: '#/components/schemas/Error'
   /xb/api/autosave:
+    description: TODO
     get:
+      description: TODO
       responses:
         '200':
           description: All current auto-save entries
@@ -456,8 +483,120 @@ paths:
                       owner: 1
               schema:
                 $ref: '#/components/schemas/AutoSaveCollection'
+  /xb/api/config/pattern:
+    description: TODO
+    get:
+      description: TODO
+      responses:
+        '200':
+          description: Collection of Sections/Patterns
+          content:
+            application/json:
+              schema:
+                type: object
+                minProperties: 0
+                patternProperties:
+                  '^\\[a-zA-Z0-9_-]$':
+                    $ref: '#/components/schemas/PatternPreview'
+    post:
+      description: TODO
+      requestBody:
+        description: Create new Pattern/Section.
+        content:
+          application/json:
+            schema:
+              type: object
+              properties:
+                name:
+                  type: string
+                  description: Human-readable Pattern/Section label
+                layout:
+                  type: array
+                  items:
+                    $ref: '#/components/schemas/LayoutComponent'
+                model:
+                  $ref: '#/components/schemas/Model'
+      responses:
+        '201':
+          description: Successful save of Section/Pattern
+          content:
+            application/json:
+              examples:
+                mySectionPattern:
+                  value:
+                    layoutModel:
+                      layout:
+                        - uuid: 0b755464-281c-4487-82a0-ece34a9b1121
+                          nodeType: component
+                          type: sdc.experience_builder.my-section
+                          slots: []
+                      model:
+                        0b755464-281c-4487-82a0-ece34a9b1121:
+                          text: >-
+                            Our mission is to deliver the best products and
+                            services to our customers. We strive to exceed
+                            expectations and continuously improve our offerings.
+                    name: Section
+                    id: section
+                    default_markup: >-
+                      <!-- xb-start-0b755464-281c-4487-82a0-ece34a9b1121 --><div
+                      class="my-section__wrapper">
+                        <div class="my-section__content-wrapper">
+                          <h2 class="my-section__h2">Our Mission</h2>
+                          <p class="my-section__paragraph">
+                            <!-- xb-prop-start-text -->Our mission is to deliver the best products and services to our customers. We strive to exceed expectations and continuously improve our offerings.<!-- xb-prop-end-text -->
+                          </p>
+                          <p class="my-section__paragraph">
+                            Join us on our journey to innovation and excellence. Your satisfaction is our priority.
+                          </p>
+                        </div>
+                        <div class="my-section__image-wrapper">
+                          <img alt="Placeholder Image" class="my-section__img" width="500" height="500">
+                        </div>
+                      </div> <!-- xb-end-0b755464-281c-4487-82a0-ece34a9b1121
+                      -->
+                    css: >
+                      <link rel="stylesheet" media="all"
+                      href="/sites/default/files/css/css.css" />
+                    js_header: ''
+                    js_footer: ''
+              schema:
+                $ref: '#/components/schemas/PatternPreview'
+  '/xb/api/config/pattern/{configEntityId}':
+    description: TODO
+    get:
+      description: TODO
+      parameters:
+        - $ref: '#/components/parameters/configEntityId'
+      responses:
+        '200':
+          description: Individual Section/Pattern
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/PatternPreview'
+    patch:
+      description: TODO
+      requestBody:
+        description: Update specific Pattern/Section.
+        required: true
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/Pattern'
+      parameters:
+        - $ref: '#/components/parameters/configEntityId'
+      responses:
+        '200':
+          description: Individual Section/Pattern
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/PatternPreview'
 
+# @see https://spec.openapis.org/oas/v3.1.0.html#components-object
 components:
+  # @see https://spec.openapis.org/oas/v3.1.0.html#schema-object
   schemas:
     Component:
       title: Component
@@ -522,7 +661,8 @@ components:
       properties:
         nodeType:
           type: string
-          enum: [component]
+          enum:
+            - component
           description: The layout node type
         uuid:
           type: string
@@ -548,7 +688,9 @@ components:
       properties:
         nodeType:
           type: string
-          enum: [region, slot]
+          enum:
+            - region
+            - slot
           description: The layout node type
         id:
           type: string
@@ -589,20 +731,24 @@ components:
           title: Owner details
           description: Details of the last user to save the entry
           examples:
-            Logged in user:
-              name: 'Garrett Bobby Ferguson, Jr'
-              avatar: /sites/default/files/user-pictures/garrett.jpg
-              uri: /user/12
-              id: 12
+            - Logged in user:
+                name: 'Garrett Bobby Ferguson, Jr'
+                avatar: /sites/default/files/user-pictures/garrett.jpg
+                uri: /user/12
+                id: 12
           properties:
             name:
               type: string
               title: User's name
             avatar:
               type: string
+              format: uri
+              nullable: true
               title: URL to user's avatar picture
             uri:
               type: string
+              format: uri
+              nullable: true
               title: URL to user's profile page
             id:
               type: integer
@@ -611,95 +757,165 @@ components:
           type: integer
           title: Updated timestamp
           description: Unix timestamp for auto-save entry last updated time
-          example: 1732763679
+          examples:
+            - 1732763679
         entity_type:
           type: string
           title: Entity Type ID
-          example: node
+          examples:
+            - node
         langcode:
           type: string
           title: Language code
-          example: en
+          nullable: true
+          examples:
+            - en
         data_hash:
           type: string
           title: Hash of the item's data
-          example: 4711efecf75d2d1a
+          examples:
+            - 4711efecf75d2d1a
         entity_id:
           type:
             - string
             - integer
           title: Entity ID
           description: ID of the entity
-          example: 3
+          examples:
+            - 3
         label:
           type: string
           title: Entity label
           description: Label of the entity
-          example: Home page
+          examples:
+            - Home page
     AutoSaveCollection:
-      schema:
-        type: object
-        # @todo Add key validation for the format in {entity_type}:{entity_id}:{langcode} https://drupal.org/i/3471064.
-        additionalProperties:
-          $ref: '#/components/schemas/AutoSaveEntry'
+      type: object
+      # @todo Add key validation for the format in {entity_type}:{entity_id}:{langcode} https://drupal.org/i/3471064.
+      additionalProperties:
+        $ref: '#/components/schemas/AutoSaveEntry'
     PublishAllRequest:
-      schema:
+      type: object
+      # @todo Add key validation for the format in {entity_type}:{entity_id}:{langcode} https://drupal.org/i/3471064.
+      additionalProperties:
         type: object
-        # @todo Add key validation for the format in {entity_type}:{entity_id}:{langcode} https://drupal.org/i/3471064.
-        additionalProperties:
+        title: Entries to publish
+        description: >
+          The body should be an object with keys that match unpublished
+          entries and a data-hash property for each item.
+        properties:
+          data_hash:
+            type: string
+            title: Expected hash for this item
+    Error:
+      type: object
+      required:
+        - detail
+        - source
+      properties:
+        detail:
+          type: string
+          title: Error description
+          examples:
+            - I can't let you do that Dave
+        source:
           type: object
-          title: Entries to publish
-          description: >
-            The body should be an object with keys that match unpublished
-            entries and a data-hash property for each item.
+          required:
+            - pointer
           properties:
-            data_hash:
+            pointer:
               type: string
-              title: Expected hash for this item
-    Error:
-      schema:
-        type: object
-        required:
-          - detail
-          - source
-        properties:
-          detail:
-            type: string
-            title: Error description
-            example: I can't let you do that Dave
-          source:
-            type: object
-            required:
-              - pointer
-            properties:
-              pointer:
-                type: string
-                title: Pointer/path to source of error
-                example: title.0.value
-          code:
-            type: int
-            title: Error code
-            example: 1
-          meta:
-            type: object
-            properties:
-              entity_type:
-                type: string
-                example: node
-                title: Entity Type ID
-              entity_id:
-                type:
-                  - string
-                  - integer
-                example: 3
-                title: Entity ID
-              label:
-                type: string
-                example: Spring sale now on
-                title: Item label
+              title: Pointer/path to source of error
+              examples:
+                - title.0.value
+        code:
+          type: integer
+          title: Error code
+          examples:
+            - 1
+        meta:
+          type: object
+          properties:
+            entity_type:
+              type: string
+              examples:
+                - node
+              title: Entity Type ID
+            entity_id:
+              type:
+                - string
+                - integer
+              examples:
+                - 3
+              title: Entity ID
+            label:
+              type: string
+              examples:
+                - Spring sale now on
+              title: Item label
     Model:
       title: model
       type: object
+    PatternPreview:
+      title: 'Pattern with generated ID and preview metadata (default markup + CSS + JS)'
+      description: '@see \Drupal\experience_builder\Controller\ApiConfigControllers::normalizePattern()'
+      type: object
+      required:
+        - layoutModel
+        - name
+        - id
+        - default_markup
+        - css
+        - js_header
+        - js_footer
+      additionalProperties: false
+      properties:
+        layoutModel:
+          type: object
+          properties:
+            layout:
+              type: array
+              items:
+                $ref: '#/components/schemas/LayoutComponent'
+              minItems: 1
+            model:
+              $ref: '#/components/schemas/Model'
+          additionalProperties: false
+        name:
+          type: string
+          description: Human-readable Pattern/Section label
+        id:
+          type: string
+          description: The ID that was auto-generated from the given name.
+        default_markup:
+          type: string
+        css:
+          type: string
+        js_header:
+          type: string
+        js_footer:
+          type: string
+    Pattern:
+      title: Pattern
+      description: '@see \Drupal\experience_builder\Controller\ApiConfigControllers::denormalizePattern()'
+      type: object
+      required:
+        - name
+        - layout
+        - model
+      properties:
+        name:
+          type: string
+          description: Human-readable Pattern/Section label
+        layout:
+          type: array
+          items:
+            $ref: '#/components/schemas/LayoutComponent'
+          minItems: 1
+        model:
+          $ref: '#/components/schemas/Model'
+      additionalProperties: false
+  # @see https://spec.openapis.org/oas/v3.1.0.html#response-object
   responses:
     FormResponse:
       description: The form
@@ -715,8 +931,8 @@ components:
               - html
             properties:
               html:
-                schema:
-                  type: string
+                type: string
+  # @see https://spec.openapis.org/oas/v3.1.0.html#parameter-object
   parameters:
     entityTypeId:
       name: entityTypeId
@@ -736,6 +952,17 @@ components:
       examples:
         arbitraryEntityId:
           value: 42
+    configEntityId:
+      name: configEntityId
+      in: path
+      required: true
+      description: Config entity ID
+      schema:
+        type: string
+      examples:
+        arbitraryEntityId:
+          value: section_fg4n3m0b
+  # @see https://spec.openapis.org/oas/v3.1.0.html#header-object
   requestBodies:
     Layout:
       content:
diff --git a/phpstan.neon b/phpstan.neon
index 45a938ba3b6758f86c57f9ca96065b4d581ff2c2..0e2e9ff7c02f2334d7cbb8f988ccbacc85d705ff 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -45,3 +45,7 @@ parameters:
       message: "#^Static property Drupal\\\\Tests\\\\experience_builder\\\\Kernel\\\\Config\\\\PatternValidationTest\\:\\:\\$propertiesWithRequiredKeys \\(array<string>\\) does not accept default value of type array<string, array<int, string>>\\.$#"
       count: 1
       path: tests/src/Kernel/Config/PatternValidationTest.php
+    -
+      message: "#^Method Drupal\\\\experience_builder\\\\Entity\\\\Pattern\\:\\:preCreate\\(\\) has no return type specified\\.$#"
+      count: 1
+      path: src/Entity/Pattern.php
diff --git a/src/Controller/ApiConfigControllers.php b/src/Controller/ApiConfigControllers.php
index 0f533dbb9d1aef2afe5dd6feea6c6e3845262801..eb7d35efd713737f8526030a4a17c477cfb1d9d4 100644
--- a/src/Controller/ApiConfigControllers.php
+++ b/src/Controller/ApiConfigControllers.php
@@ -4,16 +4,22 @@ declare(strict_types=1);
 
 namespace Drupal\experience_builder\Controller;
 
-use Drupal\Component\Assertion\Inspector;
+use Drupal\Core\Asset\AttachedAssets;
 use Drupal\Core\Cache\CacheableJsonResponse;
 use Drupal\Core\Cache\CacheableMetadata;
 use Drupal\Core\Config\Entity\ConfigEntityTypeInterface;
 use Drupal\Core\Entity\EntityTypeInterface;
 use Drupal\Core\Entity\EntityTypeManagerInterface;
-use Drupal\Core\GeneratedUrl;
+use Drupal\Core\Render\RendererInterface;
 use Drupal\Core\Url;
+use Drupal\experience_builder\AssetRenderer;
+use Drupal\experience_builder\Entity\Pattern;
 use Drupal\experience_builder\Entity\XbHttpApiEligibleConfigEntityInterface;
 use Drupal\experience_builder\Exception\ConstraintViolationException;
+use Drupal\experience_builder\Plugin\DataType\ComponentTreeHydrated;
+use Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure;
+use Drupal\experience_builder\Plugin\Field\FieldType\ComponentTreeItem;
+use Drupal\experience_builder\Plugin\Field\FieldType\ComponentTreeItemInstantiatorTrait;
 use Symfony\Component\HttpFoundation\JsonResponse;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
@@ -29,8 +35,13 @@ use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
  */
 final class ApiConfigControllers extends ApiControllerBase {
 
+  use ClientServerConversionTrait;
+  use ComponentTreeItemInstantiatorTrait;
+
   public function __construct(
     private readonly EntityTypeManagerInterface $entityTypeManager,
+    private readonly RendererInterface $renderer,
+    private readonly AssetRenderer $assetRenderer,
   ) {}
 
   /**
@@ -53,38 +64,19 @@ final class ApiConfigControllers extends ApiControllerBase {
       ->addCacheTags($xb_config_entity_type->getListCacheTags());
     /** @var array<\Drupal\experience_builder\Entity\XbHttpApiEligibleConfigEntityInterface> $config_entities */
     $config_entities = $this->entityTypeManager->getStorage($xb_config_entity_type_id)->loadMultiple();
+    $normalizations = [];
+    foreach ($config_entities as $key => &$entity) {
+      $normalizations[$key] = $this->normalize($entity);
+    }
 
-    $normalizations = self::normalizeConfigEntities($xb_config_entity_type, $config_entities);
-
-    // Ensure each normalized config entity is identical to the response at the
-    // "individual" config entity XB HTTP API route, but point to it.
-    $individual_urls = array_map(
-      fn (string $entity) => Url::fromRoute('experience_builder.api.config.get', [
-        'xb_config_entity_type_id' => $xb_config_entity_type_id,
-        'xb_config_entity' => $entity,
-      ])
-        ->toString(TRUE),
-      array_keys($normalizations),
-    );
-    $urls_cacheability = new CacheableMetadata();
-    array_reduce(
-      $individual_urls,
-      fn (CacheableMetadata $cacheability, GeneratedUrl $url) => $cacheability->addCacheableDependency($url),
-      $urls_cacheability
-    );
-
-    return (new CacheableJsonResponse(array_combine(
-      array_map(fn (GeneratedUrl $url): string => $url->getGeneratedUrl(), $individual_urls),
-      $normalizations,
-    )))
-      ->addCacheableDependency($query_cacheability)
-      ->addCacheableDependency($urls_cacheability);
+    return (new CacheableJsonResponse($normalizations))
+      ->addCacheableDependency($query_cacheability);
   }
 
   public function get(Request $request, XbHttpApiEligibleConfigEntityInterface $xb_config_entity): CacheableJsonResponse {
     $xb_config_entity_type = $xb_config_entity->getEntityType();
     assert($xb_config_entity_type instanceof ConfigEntityTypeInterface);
-    $normalization = self::normalizeConfigEntities($xb_config_entity_type, [$xb_config_entity])[0];
+    $normalization = $this->normalize($xb_config_entity);
     return (new CacheableJsonResponse(status: 200, data: $normalization))
       ->addCacheableDependency($xb_config_entity);
   }
@@ -96,19 +88,26 @@ final class ApiConfigControllers extends ApiControllerBase {
 
     // Decode, then denormalize.
     $decoded = self::decode($request);
-    // ⚠️ For now, there's no denormalization. This may change in the future.
-    $denormalized = $decoded;
+    $denormalized = $this->denormalize($xb_config_entity_type_id, $decoded);
 
     // Create an in-memory config entity and validate it.
     $xb_config_entity = $this->entityTypeManager
       ->getStorage($xb_config_entity_type_id)
       ->create($denormalized);
     assert($xb_config_entity instanceof XbHttpApiEligibleConfigEntityInterface);
-    $this->validate($xb_config_entity);
+    try {
+      $this->validate($xb_config_entity);
+    }
+    catch (ConstraintViolationException $e) {
+      throw $e->renamePropertyPaths([
+        'component_tree.props' => 'model',
+        'component_tree' => 'layout',
+      ]);
+    }
 
     // Save the XB config entity, respond with a 201.
     $xb_config_entity->save();
-    $normalization = self::normalizeConfigEntities($xb_config_entity_type, [$xb_config_entity])[0];
+    $normalization = $this->normalize($xb_config_entity);
     return new JsonResponse(status: 201, data: $normalization, headers: [
       'Location' => Url::fromRoute(
         'experience_builder.api.config.get',
@@ -131,20 +130,27 @@ final class ApiConfigControllers extends ApiControllerBase {
   public function patch(Request $request, XbHttpApiEligibleConfigEntityInterface $xb_config_entity): JsonResponse {
     // Decode, then denormalize.
     $decoded = self::decode($request);
-    // ⚠️ For now, there's no denormalization. This may change in the future.
-    $denormalized = $decoded;
+    $denormalized = $this->denormalize($xb_config_entity->getEntityTypeId(), $decoded);
 
     // Modify the loaded entity using the denormalized data and validate it.
     foreach ($denormalized as $property_name => $property_value) {
       $xb_config_entity->set($property_name, $property_value);
     }
-    $this->validate($xb_config_entity);
+    try {
+      $this->validate($xb_config_entity);
+    }
+    catch (ConstraintViolationException $e) {
+      throw $e->renamePropertyPaths([
+        'component_tree.props' => 'model',
+        'component_tree' => 'layout',
+      ]);
+    }
 
     // Save the XB config entity, respond with a 200.
     $xb_config_entity->save();
     $xb_config_entity_type = $xb_config_entity->getEntityType();
     assert($xb_config_entity_type instanceof ConfigEntityTypeInterface);
-    $normalization = self::normalizeConfigEntities($xb_config_entity_type, [$xb_config_entity])[0];
+    $normalization = $this->normalize($xb_config_entity);
     return new JsonResponse(status: 200, data: $normalization);
   }
 
@@ -155,51 +161,6 @@ final class ApiConfigControllers extends ApiControllerBase {
     }
   }
 
-  /**
-   * Normalizes all config entities of a given config entity type.
-   *
-   * Associates the config entity type's list cache contexts and tags, because
-   * the given list of config entities is assumed to be the complete list.
-   *
-   * @param \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $config_entity_type
-   *   The config entity type whose config entities are being normalized.
-   * @param \Drupal\experience_builder\Entity\XbHttpApiEligibleConfigEntityInterface[] $config_entities
-   *   All config entities stored for the given config entity type.
-   *
-   * @return array
-   *   An array containing the normalization of each config entity, in the same
-   *   order, with the same keys.
-   */
-  private static function normalizeConfigEntities(ConfigEntityTypeInterface $config_entity_type, array $config_entities): array {
-    assert(Inspector::assertAll(fn ($v) => get_class($v) === $config_entity_type->getClass(), $config_entities));
-    // All exportable config entity properties should be present in the
-    // normalization because they may be edited, with the exception of the
-    // immutable properties.
-    $editable_config_entity_properties = array_diff(
-      $config_entity_type->get('config_export'),
-      $config_entity_type->getConstraints()['ImmutableProperties'],
-    );
-
-    $cacheability = new CacheableMetadata();
-    $normalizations = array_map(
-    // Exclude not only `_core`, but really everything that is not part of the
-    // explicit export. For example: `dependencies` should not be listed here,
-    // because it is not a concern for the XB UI to create/edit/delete
-    // PageTemplate config entities.
-    // @see \Drupal\serialization\Normalizer\ConfigEntityNormalizer
-      fn (XbHttpApiEligibleConfigEntityInterface $c) => array_intersect_key(
-        $c->toArray(),
-        array_flip($editable_config_entity_properties)
-      ),
-      $config_entities
-    );
-    $cacheability
-      ->addCacheContexts($config_entity_type->getListCacheContexts())
-      ->addCacheTags($config_entity_type->getListCacheTags());
-
-    return $normalizations;
-  }
-
   /**
    * Decodes a request whose body contains JSON.
    *
@@ -229,4 +190,121 @@ final class ApiConfigControllers extends ApiControllerBase {
     return $data;
   }
 
+  private function denormalize(string $xb_config_entity_type_id, array $data): array {
+    return match ($xb_config_entity_type_id) {
+      'pattern' => $this->denormalizePattern($data),
+      default => $data,
+    };
+  }
+
+  /**
+   * @todo Move to \Symfony\Component\Serializer\Normalizer\DenormalizerInterface implementation.
+   */
+  private function denormalizePattern(array $data): array {
+    ['layout' => $layout, 'model' => $model, 'name' => $label] = $data;
+    ['tree' => $tree, 'props' => $props] = $this->convertClientToServer($layout, $model);
+
+    return [
+      'label' => $label,
+      'component_tree' => [
+        'tree' => $tree,
+        'props' => $props,
+      ],
+    ];
+  }
+
+  private function normalize(XbHttpApiEligibleConfigEntityInterface $entity): array {
+    return match(TRUE) {
+      $entity instanceof Pattern => $this->normalizePattern($entity),
+      TRUE => [],
+    };
+  }
+
+  /**
+   * @see docs/adr/0005-Keep-the-front-end-simple.md
+   */
+  private function normalizePattern(Pattern $pattern): array {
+    $item = $pattern->getComponentTree();
+    assert($item instanceof ComponentTreeItem);
+    ['layout' => $layout, 'model' => $model] = $this->convertComponentTreeItemToLayoutModel($item);
+    $build = $pattern->getComponentTree()->toRenderable();
+    $default_markup = $this->renderer->renderInIsolation($build);
+    $assets = AttachedAssets::createFromRenderArray($build);
+    return [
+      'layoutModel' => [
+        'layout' => $layout,
+        'model' => $model,
+      ],
+      'name' => $pattern->label(),
+      'id' => $pattern->id(),
+      // A pre-rendered version of the Pattern is provided so no requests
+      // are needed when adding it to the layout which includes a default
+      // markup, CSS files, JS files in the header and JS files in the
+      // footer.
+      // @see \Drupal\experience_builder\ComponentSource\ComponentSourceInterface::getClientSideInfo()
+      'default_markup' => $default_markup,
+      'css' => $this->assetRenderer->renderCssAssets($assets),
+      'js_header' => $this->assetRenderer->renderJsHeaderAssets($assets),
+      'js_footer' => $this->assetRenderer->renderJsFooterAssets($assets),
+    ];
+  }
+
+  /**
+   * Converts server side data shape into client side data shape.
+   *
+   * @param \Drupal\experience_builder\Plugin\Field\FieldType\ComponentTreeItem $item
+   *
+   * @return array{'layout': array{'uuid': string, 'nodeType': 'component', 'type': 'string', 'slots': array}, 'model': array<string, array>}
+   *
+   * @todo Follow up issue to extract this logic into a trait: https://www.drupal.org/project/experience_builder/issues/3499632
+   */
+  private function convertComponentTreeItemToLayoutModel(ComponentTreeItem $item): array {
+    assert($item instanceof ComponentTreeItem);
+    $tree = $item->get('tree');
+    assert($tree instanceof ComponentTreeStructure);
+    $hydrated = $item->get('hydrated');
+    assert($hydrated instanceof ComponentTreeHydrated);
+
+    $layout = [];
+    $model = [];
+    $decoded_tree = json_decode($tree->getValue(), TRUE);
+
+    $this->buildLayoutAndModel($layout, $model, $item, $decoded_tree[ComponentTreeStructure::ROOT_UUID], $hydrated->getValue()->getTree()[ComponentTreeStructure::ROOT_UUID]);
+
+    return [
+      'layout' => $layout,
+      'model' => $model,
+    ];
+  }
+
+  private function buildLayoutAndModel(array &$layout, array &$model, ComponentTreeItem $item, array $tree_tier, array $hydrated): void {
+    $tree = $item->get('tree');
+    assert($tree instanceof ComponentTreeStructure);
+    $full_tree = json_decode($tree->getValue(), TRUE);
+    foreach ($tree_tier as ['uuid' => $component_instance_uuid, 'component' => $component_type]) {
+      $component_instance = [
+        'uuid' => $component_instance_uuid,
+        'nodeType' => 'component',
+        'type' => $component_type,
+        'slots' => [],
+      ];
+      if (isset($hydrated[$component_instance_uuid])) {
+        $model[$component_instance_uuid] = $hydrated[$component_instance_uuid]['props'] ?? [];
+      }
+      if (isset($full_tree[$component_instance_uuid])) {
+        foreach ($full_tree[$component_instance_uuid] as $slot_name => $slot_children) {
+          $component_instance_slot = [
+            'id' => $component_instance_uuid . '/' . $slot_name,
+            'name' => $slot_name,
+            'nodeType' => 'slot',
+            'components' => [],
+          ];
+          $this->buildLayoutAndModel($component_instance_slot['components'], $model, $item, $slot_children, $hydrated[$component_instance_uuid]['slots'][$slot_name]);
+          $component_instance['slots'][] = $component_instance_slot;
+        }
+      }
+      $layout[] = $component_instance;
+    }
+  }
+
 }
diff --git a/src/Controller/ApiLayoutController.php b/src/Controller/ApiLayoutController.php
index eeba562a28bcf75c6266fc0624efdd1f1076fd9a..63598d0f7865e3617c976cde68a66c28b1e86b7a 100644
--- a/src/Controller/ApiLayoutController.php
+++ b/src/Controller/ApiLayoutController.php
@@ -76,6 +76,9 @@ final class ApiLayoutController {
     ]);
   }
 
+  /**
+   * @todo Follow up issue to extract this logic into a trait: https://www.drupal.org/project/experience_builder/issues/3499632
+   */
   private function buildRegion(string $id, ?ComponentTreeItem $item = NULL, ?array &$model = NULL): array {
     if ($item) {
       $tree = $item->get('tree');
@@ -97,6 +100,9 @@ final class ApiLayoutController {
     ];
   }
 
+  /**
+   * @todo Follow up issue to extract this logic into a trait: https://www.drupal.org/project/experience_builder/issues/3499632
+   */
   private function buildLayout(array &$model, ComponentTreeItem $item, array $tree_tier, array $hydrated): array {
     $layout = [];
     $tree = $item->get('tree');
diff --git a/src/Controller/ClientServerConversionTrait.php b/src/Controller/ClientServerConversionTrait.php
index f8e007db7b8dac153a09b99cbb7e00c9d781e6f0..c826613ca6408d1572b015170864ff4bf38f78b6 100644
--- a/src/Controller/ClientServerConversionTrait.php
+++ b/src/Controller/ClientServerConversionTrait.php
@@ -25,7 +25,19 @@ trait ClientServerConversionTrait {
   private static function clientLayoutToServerTree(array $layout, bool $validate = TRUE): array {
     // Transform client-side representation to server-side representation.
     // The entire component tree is nested under the reserved root UUID.
-    $tree = self::doClientSlotToServerTree($layout, [], ComponentTreeStructure::ROOT_UUID);
+    // Top level of elements in component tree are regions, except for Patterns/Sections.
+    if (isset($layout['nodeType']) && $layout['nodeType'] === 'region') {
+      $tree = self::doClientSlotToServerTree($layout, [], ComponentTreeStructure::ROOT_UUID);
+    }
+    // For patterns/sections, the top level is array of components.
+    else {
+      $tree = [];
+      foreach ($layout as $component) {
+        assert($component['nodeType'] === 'component');
+        $tree = self::doClientComponentToServerTree($component, $tree, ComponentTreeStructure::ROOT_UUID, NULL);
+      }
+    }
+
     if (!$validate) {
       return $tree;
     }
diff --git a/src/Entity/Pattern.php b/src/Entity/Pattern.php
index 673a3c2926837b656053c269e32aa7ae4386f35b..fbb5cda8cd4a97f61a25f226ed063399ff635cf9 100644
--- a/src/Entity/Pattern.php
+++ b/src/Entity/Pattern.php
@@ -4,7 +4,9 @@ declare(strict_types=1);
 
 namespace Drupal\experience_builder\Entity;
 
+use Drupal\Component\Utility\Random;
 use Drupal\Core\Config\Entity\ConfigEntityBase;
+use Drupal\Core\Entity\EntityStorageInterface;
 use Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure;
 use Drupal\experience_builder\Plugin\Field\FieldType\ComponentTreeItem;
 use Drupal\experience_builder\Plugin\Field\FieldType\ComponentTreeItemInstantiatorTrait;
@@ -80,4 +82,37 @@ final class Pattern extends ConfigEntityBase implements XbHttpApiEligibleConfigE
     return $this;
   }
 
+  public static function preCreate(EntityStorageInterface $storage, array &$values) {
+    $values['id'] = self::generateId($values['label']);
+    parent::preCreate($storage, $values);
+  }
+
+  /**
+   * Generates a valid ID from the given label.
+   */
+  private static function generateId(string $label): string {
+    $id = mb_strtolower($label);
+
+    $id = preg_replace('@[^a-z0-9_.]+@', '', $id);
+    assert(is_string($id));
+    // Furthermore remove any characters that are not alphanumerical from the
+    // beginning and end of the transliterated string.
+    $id = preg_replace('@^([^a-z0-9]+)|([^a-z0-9]+)$@', '', $id);
+    assert(is_string($id));
+    assert(is_string($id));
+    if (strlen($id) > 23) {
+      $id = substr($id, 0, 23);
+    }
+    assert(is_string($id));
+
+    $query = \Drupal::entityTypeManager()->getStorage('pattern')->getQuery()->accessCheck(FALSE);
+    $ids = $query->execute();
+    $id_exists = in_array($id, $ids, TRUE);
+    if ($id_exists) {
+      $id = $id . '_' . (new Random())->machineName(8);
+    }
+
+    return $id;
+  }
+
 }
diff --git a/tests/src/Functional/XbConfigEntityHttpApiTest.php b/tests/src/Functional/XbConfigEntityHttpApiTest.php
index 71aa04ccb14fa09dd0c50e12d58a99693bdae8b2..c2781d3cd13508df9459acd8ff08c4f5c6f8e2b1 100644
--- a/tests/src/Functional/XbConfigEntityHttpApiTest.php
+++ b/tests/src/Functional/XbConfigEntityHttpApiTest.php
@@ -3,7 +3,6 @@
 declare(strict_types=1);
 
 use Drupal\Core\Url;
-use Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure;
 use Drupal\system\Entity\Menu;
 use Drupal\Tests\ApiRequestTrait;
 use Drupal\Tests\BrowserTestBase;
@@ -71,6 +70,7 @@ class XbConfigEntityHttpApiTest extends BrowserTestBase {
    * @see \Drupal\experience_builder\Entity\Pattern
    */
   public function testPattern(): void {
+    // cspell:ignore testpatternpleaseignore
     $base = rtrim(base_path(), '/');
     $list_url = Url::fromUri('base:/xb/api/config/pattern');
 
@@ -85,76 +85,100 @@ class XbConfigEntityHttpApiTest extends BrowserTestBase {
     $body = $this->assertExpectedResponse('GET', $list_url, [], 200, ['user.permissions'], ['config:pattern_list', 'http_response'], 'UNCACHEABLE (request policy)', 'MISS');
     $this->assertSame([], $body);
 
-    // Create a Pattern via the XB HTTP API, but forget crucial data: 422.
+    // Create a Pattern via the XB HTTP API, but forget crucial data that causes
+    // the required shape to be violated: 500, courtesy of OpenAPI.
     $pattern_to_send = [
-      'id' => 'test',
-      'label' => 'Test pattern, please ignore',
-      'component_tree' => NULL,
+      'name' => 'Test pattern, please ignore',
+      'layout' => NULL,
+      'model' => NULL,
     ];
     $request_options = [
-      RequestOptions::JSON => $pattern_to_send,
+      RequestOptions::HEADERS => [
+        'Content-Type' => 'application/json',
+      ],
+      // TRICKY: this intentionally avoids using RequestOptions::JSON because
+      // that encodes `'props' => []` as `'props': []`, whereas the server side
+      // expects `'props': {}`.
+      // @see \Drupal\Tests\experience_builder\Traits\TestDataUtilitiesTrait::encodeXBData()
+      RequestOptions::BODY => self::encodeXBData($pattern_to_send),
     ];
-    $body = $this->assertExpectedResponse('POST', $list_url, $request_options, 422, NULL, NULL, NULL, NULL);
+    $body = $this->assertExpectedResponse('POST', $list_url, $request_options, 500, NULL, NULL, NULL, NULL);
     $this->assertSame([
-      'errors' => [
-        [
-          'detail' => 'This value should not be null.',
-          'source' => ['pointer' => 'component_tree'],
-        ],
-      ],
+      'message' => 'Body does not match schema for content-type "application/json" for Request [post /xb/api/config/pattern]',
     ], $body);
 
-    // Add missing crucial data, but still make a mistake: 422.
-    $pattern_to_send['component_tree']['tree'] = self::encodeXBData([
-      ComponentTreeStructure::ROOT_UUID => [
-        ['uuid' => 'uuid-in-root', 'component' => 'sdc.xb_test_sdc.props-no-slots'],
-        ['uuid' => 'uuid-in-root-another', 'component' => 'sdc.xb_test_sdc.props-no-slots'],
+    // Add missing crucial data, but leave a requires shape violation: 500,
+    // courtesy of OpenAPI.
+    $pattern_to_send['layout'] = [
+      [
+        'uuid' => 'uuid-in-root',
+        'nodeType' => 'component',
+        'type' => 'sdc.xb_test_sdc.props-no-slots',
+        'slots' => [],
+      ],
+      [
+        'uuid' => 'uuid-in-root-another',
+        'nodeType' => 'component',
+        'type' => 'sdc.xb_test_sdc.props-no-slots',
+        'slots' => [],
       ],
-    ]);
-    $request_options = [
-      RequestOptions::JSON => $pattern_to_send,
     ];
+    $request_options[RequestOptions::BODY] = self::encodeXBData($pattern_to_send);
+    $body = $this->assertExpectedResponse('POST', $list_url, $request_options, 500, NULL, NULL, NULL, NULL);
+    $this->assertSame([
+      'message' => 'Body does not match schema for content-type "application/json" for Request [post /xb/api/config/pattern]',
+    ], $body);
+
+    // Meet data shape requirements, but violate internal consistency for
+    // `model` (`props` on server side): 422 (i.e. validation constraint
+    // violation).
+    $pattern_to_send['model'] = [];
+    $request_options[RequestOptions::BODY] = self::encodeXBData($pattern_to_send);
     $body = $this->assertExpectedResponse('POST', $list_url, $request_options, 422, NULL, NULL, NULL, NULL);
     $this->assertSame([
       'errors' => [
         [
-          'detail' => '\'props\' is a required key.',
-          'source' => ['pointer' => 'component_tree'],
+          'detail' => 'The required properties are missing.',
+          'source' => ['pointer' => 'model.uuid-in-root'],
         ],
         [
-          'detail' => 'The array must contain a "props" key.',
-          'source' => ['pointer' => 'component_tree'],
+          'detail' => 'The required properties are missing.',
+          'source' => ['pointer' => 'model.uuid-in-root-another'],
         ],
       ],
     ], $body);
 
-    // Add missing crucial data, but use disallowed component blocks: 422.
-    $pattern_to_send['component_tree']['tree'] = self::encodeXBData([
-      ComponentTreeStructure::ROOT_UUID => [
-        ['uuid' => 'uuid-main', 'component' => 'block.system_main_block'],
-        ['uuid' => 'uuid-title', 'component' => 'block.page_title_block'],
-        ['uuid' => 'uuid-messages', 'component' => 'block.system_messages_block'],
+    // Meet data shape requirements, but violate internal consistency for
+    // `layout` (`tree` on server side): 422 (i.e. validation constraint
+    // violation).
+    $generate_static_prop_source = function (string $label): array {
+      return [
+        'sourceType' => 'static:field_item:string',
+        'value' => "Hello, $label!",
+        'expression' => 'ℹ︎string␟value',
+      ];
+    };
+    $pattern_to_send['model'] = [
+      'uuid-in-root' => [
+        'heading' => $generate_static_prop_source('world'),
       ],
-    ]);
-    $pattern_to_send['component_tree']['props'] = self::encodeXBData([]);
-
-    $request_options = [
-      RequestOptions::JSON => $pattern_to_send,
+      'uuid-in-root-another' => [
+        'heading' => $generate_static_prop_source('another world'),
+      ],
+    ];
+    $pattern_to_send['layout'][] = [
+      'uuid' => 'uuid-main',
+      'nodeType' => 'component',
+      'type' => 'block.system_main_block',
+      'slots' => [],
     ];
+    $request_options[RequestOptions::BODY] = self::encodeXBData($pattern_to_send);
     $body = $this->assertExpectedResponse('POST', $list_url, $request_options, 422, NULL, NULL, NULL, NULL);
     $this->assertSame([
       'errors' => [
         [
           'detail' => 'The \'Drupal\Core\Block\MainContentBlockPluginInterface\' component interface must be absent.',
-          'source' => ['pointer' => 'component_tree'],
-        ],
-        [
-          'detail' => 'The \'Drupal\Core\Block\MessagesBlockPluginInterface\' component interface must be absent.',
-          'source' => ['pointer' => 'component_tree'],
-        ],
-        [
-          'detail' => 'The \'Drupal\Core\Block\TitleBlockPluginInterface\' component interface must be absent.',
-          'source' => ['pointer' => 'component_tree'],
+          'source' => ['pointer' => 'layout'],
         ],
       ],
     ], $body);
@@ -164,106 +188,167 @@ class XbConfigEntityHttpApiTest extends BrowserTestBase {
     $this->assertSame([], $body);
 
     // Create a Pattern via the XB HTTP API, correctly: 201.
-    $pattern_to_send['component_tree']['tree'] = self::encodeXBData([
-      ComponentTreeStructure::ROOT_UUID => [
-        ['uuid' => 'uuid-in-root', 'component' => 'sdc.xb_test_sdc.props-no-slots'],
-        ['uuid' => 'uuid-in-root-another', 'component' => 'sdc.xb_test_sdc.props-no-slots'],
-      ],
-    ]);
-    $pattern_to_send['component_tree']['props'] = [];
-    $generate_static_prop_source = function (string $label): array {
-      return [
-        'sourceType' => 'static:field_item:string',
-        'value' => "Hello, $label!",
-        'expression' => 'ℹ︎string␟value',
-      ];
-    };
-    $pattern_to_send['component_tree']['props'] = self::encodeXBData([
-      'uuid-in-root' => [
-        'heading' => $generate_static_prop_source('world'),
-      ],
-      'uuid-in-root-another' => [
-        'heading' => $generate_static_prop_source('another world'),
+    array_pop($pattern_to_send['layout']);
+    $request_options[RequestOptions::BODY] = self::encodeXBData($pattern_to_send);
+    $body = $this->assertExpectedResponse('POST', $list_url, $request_options, 201, NULL, NULL, NULL, NULL, [
+      'Location' => [
+        "$base/xb/api/config/pattern/testpatternpleaseignore",
       ],
     ]);
-    // Note how the key-value pairs under `component_tree` are sorted by key.
     $expected_pattern_normalization = [
-      'label' => 'Test pattern, please ignore',
-      'component_tree' => [
-        'tree' => self::encodeXBData([
-          ComponentTreeStructure::ROOT_UUID => [
-            ['uuid' => 'uuid-in-root', 'component' => 'sdc.xb_test_sdc.props-no-slots'],
-            ['uuid' => 'uuid-in-root-another', 'component' => 'sdc.xb_test_sdc.props-no-slots'],
+      'layoutModel' => [
+        'layout' => [
+          [
+            'uuid' => 'uuid-in-root',
+            'nodeType' => 'component',
+            'type' => 'sdc.xb_test_sdc.props-no-slots',
+            'slots' => [],
           ],
-        ]),
-        'props' => self::encodeXBData([
+          [
+            'uuid' => 'uuid-in-root-another',
+            'nodeType' => 'component',
+            'type' => 'sdc.xb_test_sdc.props-no-slots',
+            'slots' => [],
+          ],
+        ],
+        'model' => [
           'uuid-in-root' => [
-            'heading' => $generate_static_prop_source('world'),
+            'heading' => 'Hello, world!',
           ],
           'uuid-in-root-another' => [
-            'heading' => $generate_static_prop_source('another world'),
+            'heading' => 'Hello, another world!',
           ],
-        ]),
+        ],
       ],
+      'name' => 'Test pattern, please ignore',
+      'id' => 'testpatternpleaseignore',
+      'default_markup' => '<!-- xb-start-uuid-in-root --><div  data-component-id="xb_test_sdc:props-no-slots" style="font-family: Helvetica, Arial, sans-serif; width: 100%; height: 100vh; background-color: #f5f5f5; display: flex; justify-content: center; align-items: center; flex-direction: column; text-align: center; padding: 20px; box-sizing: border-box;">
+  <h1 style="font-size: 3em; margin: 0.5em 0; color: #333;"><!-- xb-prop-start-uuid-in-root/heading -->Hello, world!<!-- xb-prop-end-uuid-in-root/heading --></h1>
+</div>
+<!-- xb-end-uuid-in-root --><!-- xb-start-uuid-in-root-another --><div  data-component-id="xb_test_sdc:props-no-slots" style="font-family: Helvetica, Arial, sans-serif; width: 100%; height: 100vh; background-color: #f5f5f5; display: flex; justify-content: center; align-items: center; flex-direction: column; text-align: center; padding: 20px; box-sizing: border-box;">
+  <h1 style="font-size: 3em; margin: 0.5em 0; color: #333;"><!-- xb-prop-start-uuid-in-root-another/heading -->Hello, another world!<!-- xb-prop-end-uuid-in-root-another/heading --></h1>
+</div>
+<!-- xb-end-uuid-in-root-another -->',
+      'css' => '',
+      'js_header' => '',
+      'js_footer' => '',
     ];
-    $request_options = [
-      RequestOptions::JSON => $pattern_to_send,
+    $this->assertSame($expected_pattern_normalization, $body);
+
+    // Create a (more realistic) Pattern with nested components, but missing
+    // prop: 422.
+    $nested_components_pattern = $pattern_to_send;
+    $nested_components_pattern['name'] = 'Nested';
+    $nested_components_pattern['layout'] = [
+      [
+        'nodeType' => 'component',
+        'slots' => [
+          [
+            'components' => [
+              [
+                'uuid' => 'uuid-in-root',
+                'nodeType' => 'component',
+                'type' => 'sdc.xb_test_sdc.props-no-slots',
+                'slots' => [],
+              ],
+              [
+                'uuid' => 'uuid-in-root-another',
+                'nodeType' => 'component',
+                'type' => 'sdc.xb_test_sdc.props-no-slots',
+                'slots' => [],
+              ],
+            ],
+            'id' => 'c4074d1f-149a-4662-aaf3-615151531cf6/content',
+            'name' => 'content',
+            'nodeType' => 'slot',
+          ],
+        ],
+        'type' => 'sdc.experience_builder.one_column',
+        'uuid' => 'c4074d1f-149a-4662-aaf3-615151531cf6',
+      ],
     ];
-    $body = $this->assertExpectedResponse('POST', $list_url, $request_options, 201, NULL, NULL, NULL, NULL, [
+    $request_options[RequestOptions::BODY] = self::encodeXBData($nested_components_pattern);
+    $body = $this->assertExpectedResponse('POST', $list_url, $request_options, 422, NULL, NULL, NULL, NULL);
+    $this->assertSame([
+      'errors' => [
+        [
+          'detail' => 'The required properties are missing.',
+          'source' => ['pointer' => 'model.c4074d1f-149a-4662-aaf3-615151531cf6'],
+        ],
+      ],
+    ], $body);
+
+    // Add missing missing prop: 201.
+    $nested_components_pattern['model']['c4074d1f-149a-4662-aaf3-615151531cf6'] = [
+      'width' => 'full',
+    ];
+    $request_options[RequestOptions::BODY] = self::encodeXBData($nested_components_pattern);
+    $this->assertExpectedResponse('POST', $list_url, $request_options, 201, NULL, NULL, NULL, NULL, [
       'Location' => [
-        "$base/xb/api/config/pattern/test",
+        "$base/xb/api/config/pattern/nested",
       ],
     ]);
-    $this->assertSame($expected_pattern_normalization, $body);
+
+    // Delete the nested Pattern via the XB HTTP API: 204.
+    $this->assertExpectedResponse('DELETE', Url::fromUri('base:/xb/api/config/pattern/nested'), [], 204, NULL, NULL, NULL, NULL);
 
     // Re-retrieve list: 200, non-empty list. Dynamic Page Cache miss.
     $body = $this->assertExpectedResponse('GET', $list_url, [], 200, ['user.permissions'], ['config:pattern_list', 'http_response'], 'UNCACHEABLE (request policy)', 'MISS');
     $this->assertSame([
-      "$base/xb/api/config/pattern/test" => $expected_pattern_normalization,
+      "testpatternpleaseignore" => $expected_pattern_normalization,
     ], $body);
     // Use the individual URL in the list response body.
-    $individual_body = $this->assertExpectedResponse('GET', Url::fromUri('base:' . substr(array_keys($body)[0], strlen($base))), [], 200, ['user.permissions'], ['config:experience_builder.pattern.test', 'http_response'], 'UNCACHEABLE (request policy)', 'MISS');
-    $this->assertSame($expected_pattern_normalization, $individual_body);
+    $individual_body = $this->assertExpectedResponse('GET', Url::fromUri('base:/xb/api/config/pattern/testpatternpleaseignore'), [], 200, ['user.permissions'], ['config:experience_builder.pattern.testpatternpleaseignore', 'http_response'], 'UNCACHEABLE (request policy)', 'MISS');
+    $expected_individual_body_normalization = $expected_pattern_normalization;
+    $expected_individual_body_normalization['js_footer'] = str_replace('xb\/api\/config\/pattern', 'xb\/api\/config\/pattern\/testpatternpleaseignore', $expected_pattern_normalization['js_footer']);
+    $this->assertSame($expected_individual_body_normalization, $individual_body);
+
+    // Modify a Pattern incorrectly (shape-wise): 500.
+    $request_options[RequestOptions::BODY] = self::encodeXBData([
+      'name' => $pattern_to_send['name'],
+      'layout' => $pattern_to_send['layout'],
+      'model' => NULL,
+    ]);
+    $body = $this->assertExpectedResponse('PATCH', Url::fromUri('base:/xb/api/config/pattern/testpatternpleaseignore'), $request_options, 500, NULL, NULL, NULL, NULL);
+    $this->assertSame([
+      'message' => 'Body does not match schema for content-type "application/json" for Request [patch /xb/api/config/pattern/{configEntityId}]',
+    ], $body);
 
-    // Modify a Pattern incorrectly: 422.
-    $temp_copy = $pattern_to_send['component_tree']['tree'];
-    $pattern_to_send['component_tree']['tree'] = '{}';
-    $request_options = [
-      RequestOptions::JSON => $pattern_to_send,
-    ];
-    $body = $this->assertExpectedResponse('PATCH', Url::fromUri('base:/xb/api/config/pattern/test'), $request_options, 422, NULL, NULL, NULL, NULL);
+    // Modify a Pattern incorrectly (consistency-wise): 422.
+    $request_options[RequestOptions::BODY] = self::encodeXBData([
+      'name' => $pattern_to_send['name'],
+      'layout' => $pattern_to_send['layout'],
+      'model' => array_slice($pattern_to_send['model'], 1),
+    ]);
+    $body = $this->assertExpectedResponse('PATCH', Url::fromUri('base:/xb/api/config/pattern/testpatternpleaseignore'), $request_options, 422, NULL, NULL, NULL, NULL);
     $this->assertSame([
       'errors' => [
         [
-          'detail' => 'The root UUID is missing.',
-          'source' => ['pointer' => 'component_tree.tree[a548b48d-58a8-4077-aa04-da9405a6f418]'],
+          'detail' => 'The required properties are missing.',
+          'source' => ['pointer' => 'model.uuid-in-root'],
         ],
       ],
     ], $body);
 
-    $pattern_to_send['component_tree']['tree'] = $temp_copy;
-
     // Modify a Pattern correctly: 200.
-    $request_options = [
-      RequestOptions::JSON => $pattern_to_send,
-    ];
-    $body = $this->assertExpectedResponse('PATCH', Url::fromUri('base:/xb/api/config/pattern/test'), $request_options, 200, NULL, NULL, NULL, NULL);
-    $this->assertSame($expected_pattern_normalization, $body);
+    $request_options[RequestOptions::BODY] = self::encodeXBData($pattern_to_send);
+    $body = $this->assertExpectedResponse('PATCH', Url::fromUri('base:/xb/api/config/pattern/testpatternpleaseignore'), $request_options, 200, NULL, NULL, NULL, NULL);
+    $this->assertSame($expected_individual_body_normalization, $body);
 
     // Re-retrieve list: 200, non-empty list. Dynamic Page Cache miss.
     $body = $this->assertExpectedResponse('GET', $list_url, [], 200, ['user.permissions'], ['config:pattern_list', 'http_response'], 'UNCACHEABLE (request policy)', 'MISS');
     $this->assertSame([
-      "$base/xb/api/config/pattern/test" => $expected_pattern_normalization,
+      "testpatternpleaseignore" => $expected_pattern_normalization,
     ], $body);
 
-    // Delete the sole Pattern via the XB HTTP API: 204.
-    $body = $this->assertExpectedResponse('DELETE', Url::fromUri('base:/xb/api/config/pattern/test'), [], 204, NULL, NULL, NULL, NULL);
+    // Delete the sole remaining Pattern via the XB HTTP API: 204.
+    $body = $this->assertExpectedResponse('DELETE', Url::fromUri('base:/xb/api/config/pattern/testpatternpleaseignore'), [], 204, NULL, NULL, NULL, NULL);
     $this->assertNull($body);
 
-    // Re-retrieve list: 200, non-empty list. Dynamic Page Cache miss.
+    // Re-retrieve list: 200, empty list. Dynamic Page Cache miss.
     $body = $this->assertExpectedResponse('GET', $list_url, [], 200, ['user.permissions'], ['config:pattern_list', 'http_response'], 'UNCACHEABLE (request policy)', 'MISS');
     $this->assertSame([], $body);
-    $individual_body = $this->assertExpectedResponse('GET', Url::fromUri('base:/xb/api/config/pattern/test'), [], 404, NULL, NULL, 'UNCACHEABLE (request policy)', 'UNCACHEABLE (no cacheability)');
+    $individual_body = $this->assertExpectedResponse('GET', Url::fromUri('base:/xb/api/config/pattern/testpatternpleaseignore'), [], 404, NULL, NULL, 'UNCACHEABLE (request policy)', 'UNCACHEABLE (no cacheability)');
     $this->assertSame([], $individual_body);
 
     // This was now tested full circle! ✅
diff --git a/tests/src/Kernel/ClientServerConversionTraitTest.php b/tests/src/Kernel/ClientServerConversionTraitTest.php
index 6ff6b7987718159176f02268784cafd9ada28f6a..8c28db44f8fa31da4be80787b2d74296e47de313 100644
--- a/tests/src/Kernel/ClientServerConversionTraitTest.php
+++ b/tests/src/Kernel/ClientServerConversionTraitTest.php
@@ -63,14 +63,6 @@ class ClientServerConversionTraitTest extends KernelTestBase {
       ],
     ], json_decode($converted_item['tree'], TRUE));
 
-    // Ensure convert 'tree' and 'props' can be used both to create both a
-    // config entity and a content entity field value.
-    Pattern::create([
-      'id' => 'test_pattern',
-      'label' => 'Test Pattern',
-      'component_tree' => $converted_item,
-    ])->save();
-
     $node1 = Node::create([
       'type' => 'article',
       'title' => '5 amazing uses for old toothbrushes',
@@ -88,6 +80,29 @@ class ClientServerConversionTraitTest extends KernelTestBase {
       $this->getValidConvertedProps(),
       '5 amazing uses for old toothbrushes'
     );
+
+    ['layout' => $layout, 'model' => $model] = $this->getValidPatternJson();
+    $converted_item = $this->convertClientToServer($layout, $model);
+    $this->assertSame($this->getValidConvertedProps(), json_decode($converted_item['props'], TRUE));
+    $this->assertSame([
+      ComponentTreeStructure::ROOT_UUID => [
+        [
+          'uuid' => self::TEST_HEADING_UUID,
+          'component' => 'sdc.experience_builder.heading',
+        ],
+        [
+          'uuid' => self::TEST_IMAGE_UUID,
+          'component' => 'sdc.experience_builder.image',
+        ],
+      ],
+    ], json_decode($converted_item['tree'], TRUE));
+
+    Pattern::create([
+      'id' => 'test_pattern',
+      'label' => 'Test Pattern',
+      'component_tree' => $converted_item,
+    ])->save();
+
   }
 
   public function testConvertClientToServerErrors(): void {
@@ -126,4 +141,38 @@ class ClientServerConversionTraitTest extends KernelTestBase {
     }
   }
 
+  protected function getValidPatternJson(): array {
+    return [
+      'layout' => [
+        [
+          'nodeType' => 'component',
+          'uuid' => self::TEST_HEADING_UUID,
+          'type' => 'sdc.experience_builder.heading',
+          'slots' => [],
+        ],
+        [
+          'nodeType' => 'component',
+          'uuid' => self::TEST_IMAGE_UUID,
+          'type' => 'sdc.experience_builder.image',
+          'slots' => [],
+        ],
+      ],
+      'model' => [
+        self::TEST_HEADING_UUID => [
+          'text' => 'This is a random heading.',
+          'style' => 'primary',
+          'element' => 'h1',
+        ],
+        self::TEST_IMAGE_UUID => [
+          'image' => [
+            'src' => $this->getSrcPropertyFromFile($this->referencedImage),
+            'alt' => 'This is a random image.',
+            'width' => 100,
+            'height' => 100,
+          ],
+        ],
+      ],
+    ];
+  }
+
 }
diff --git a/tests/src/Kernel/Config/PatternValidationTest.php b/tests/src/Kernel/Config/PatternValidationTest.php
index 05f9739d7fad6838c1aa1662f8d27a8f9ab1a5f9..b7bef0dcb194a6a98606c5a63888430ecc782786 100644
--- a/tests/src/Kernel/Config/PatternValidationTest.php
+++ b/tests/src/Kernel/Config/PatternValidationTest.php
@@ -4,6 +4,8 @@ declare(strict_types=1);
 
 namespace Drupal\Tests\experience_builder\Kernel\Config;
 
+// cspell:ignore thisisatestpattern
+
 use Drupal\experience_builder\Entity\Pattern;
 use Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure;
 use Drupal\KernelTests\Core\Config\ConfigEntityValidationTestBase;
@@ -122,6 +124,50 @@ class PatternValidationTest extends ConfigEntityValidationTestBase {
     ], $this->getAllDependencies($this->entity));
   }
 
+  /**
+   * Pattern config entities atypically do not need an ID to be specified.
+   *
+   * @see \Drupal\experience_builder\Entity\Pattern::preCreate()
+   */
+  public function testValidWithoutIdSpecified(): void {
+    $this->assertCount(1, Pattern::loadMultiple());
+
+    // Reuse most of the values of the test Pattern.
+    $values = $this->entity->toArray();
+    // Each config entity must have a unique UUID; it is generated by config
+    // storage.
+    unset($values['uuid']);
+
+    // Test creating Pattern entities with a specific label and no ID.
+    $values['label'] = 'This is a test pattern';
+    unset($values['id']);
+
+    for ($i = 0; $i < 3; $i++) {
+      $pattern = Pattern::create($values);
+      $pattern->save();
+      $this->assertSame($values['label'], $pattern->label());
+      if ($i === 0) {
+        // The first Pattern generated from this label does not have a suffix.
+        $this->assertSame('thisisatestpattern', $pattern->id());
+      }
+      else {
+        // All others do.
+        // @phpstan-ignore-next-line
+        $this->assertMatchesRegularExpression('/^thisisatestpattern_[a-z0-9]+$/', $pattern->id());
+      }
+    }
+
+    $this->assertSame([
+      'Test pattern',
+      'This is a test pattern',
+      'This is a test pattern',
+      'This is a test pattern',
+    ], array_map(
+      fn (Pattern $p): string => (string) $p->label(),
+      array_values(Pattern::loadMultiple())
+    ));
+  }
+
   /**
    * @dataProvider providerInvalidComponentTree
    * @covers \Drupal\experience_builder\Plugin\Validation\Constraint\ComponentTreeMeetsRequirementsConstraint
diff --git a/ui/src/components/list/DummySectionList.tsx b/ui/src/components/list/DummySectionList.tsx
deleted file mode 100644
index fd987293a0ca78c5bc50eaabb2bd663d0e5e0afa..0000000000000000000000000000000000000000
--- a/ui/src/components/list/DummySectionList.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import List from '@/components/list/List';
-import { useGetDummySectionsQuery } from '@/services/sections';
-import { Text } from '@radix-ui/themes';
-
-const DummySectionList = () => {
-  const { data: fakeSections } = useGetDummySectionsQuery();
-
-  return (
-    <>
-      <Text size="1">
-        The section template listed below is hard coded and is a proof of
-        concept. It should allow the user to add a hero with an image below it
-        in a single action.
-      </Text>
-      <List
-        items={fakeSections}
-        isLoading={false}
-        type="section"
-        label="Dummy Section templates"
-      />
-    </>
-  );
-};
-
-export default DummySectionList;
diff --git a/ui/src/components/sidebar/Library.tsx b/ui/src/components/sidebar/Library.tsx
index 73087281a3c592d39a294983821e89c26a5e1835..0f47057f9d3a047926f780adc9fde45e30e79dee 100644
--- a/ui/src/components/sidebar/Library.tsx
+++ b/ui/src/components/sidebar/Library.tsx
@@ -14,7 +14,6 @@ import {
 } from '@/features/ui/primaryPanelSlice';
 import { useAppDispatch, useAppSelector } from '@/app/hooks';
 import ErrorBoundary from '@/components/error/ErrorBoundary';
-import DummySectionList from '@/components/list/DummySectionList';
 
 const Library = () => {
   const openItems = useAppSelector(selectOpenLayoutItems);
@@ -62,7 +61,6 @@ const Library = () => {
             Sections
           </AccordionTrigger>
           <Accordion.Content>
-            <DummySectionList />
             <ErrorBoundary title="An unexpected error has occurred while fetching section templates.">
               <SectionList />
             </ErrorBoundary>
diff --git a/ui/src/features/saveSection/SaveSectionDialog.tsx b/ui/src/features/saveSection/SaveSectionDialog.tsx
index faaa6db789d7b8adfd0df5aa2ab2f27e95fbb401..3bbafdf384a5c2ca8ec9dbeb0b1df7e553a026c8 100644
--- a/ui/src/features/saveSection/SaveSectionDialog.tsx
+++ b/ui/src/features/saveSection/SaveSectionDialog.tsx
@@ -126,12 +126,6 @@ const SaveSectionDialog: React.FC = () => {
   return (
     <Dialog.Root open={saveAsSection} onOpenChange={handleOpenChange}>
       <Dialog.Content maxWidth="550px">
-        <Text size="1">
-          <strong>
-            This is currently only a proof of concept, saving will fail until
-            the endpoint is fully implemented.
-          </strong>
-        </Text>
         <Dialog.Title>Add new section</Dialog.Title>
         <Dialog.Description size="2" mb="4">
           Save "{selectedComponentName}" as a section. Unlike components,
diff --git a/ui/src/hooks/usePreviewSortable.ts b/ui/src/hooks/usePreviewSortable.ts
index 4f6fbb514201a8e8f045402ffa32fc84df0747c3..fc59799734bc1a462fcb9a7fe011ff98d1f3224f 100644
--- a/ui/src/hooks/usePreviewSortable.ts
+++ b/ui/src/hooks/usePreviewSortable.ts
@@ -12,7 +12,7 @@ import {
 import { setTargetSlot, unsetTargetSlot } from '@/features/ui/uiSlice';
 import { findNodePathByUuid } from '@/features/layout/layoutUtils';
 import { useGetComponentsQuery } from '@/services/components';
-import { useGetDummySectionsQuery } from '@/services/sections';
+import { useGetSectionsQuery } from '@/services/sections';
 import type { SlotsMap } from '@/types/AnnotationMaps';
 import { insertPlaceholderIfMatchingComments } from '@/utils/function-utils';
 
@@ -32,8 +32,7 @@ function usePreviewSortable(
   const model = useAppSelector(selectModel);
   const dispatch = useAppDispatch();
   const { data: components } = useGetComponentsQuery();
-  // TODO update to use the real section query once it works.
-  const { data: sections } = useGetDummySectionsQuery();
+  const { data: sections } = useGetSectionsQuery();
   const modelRef = useRef(model);
   const iframeDocumentRef = useRef<Document | null>(null);
   const componentsRef = useRef(components);
diff --git a/ui/src/services/sections.ts b/ui/src/services/sections.ts
index 87356c72546df760deb950c28474f87beb1973dd..cbd07afa3ec8005fa973f8c38af7c9996afc704b 100644
--- a/ui/src/services/sections.ts
+++ b/ui/src/services/sections.ts
@@ -1,5 +1,3 @@
-// cspell:ignore abcde, fghij, klmno
-
 // Need to use the React-specific entry point to import createApi
 import { createApi } from '@reduxjs/toolkit/query/react';
 import { baseQuery } from '@/services/baseQuery';
@@ -9,99 +7,15 @@ interface SaveSectionData extends LayoutModelPiece {
   name: string;
 }
 
-const mockSections = {
-  fakeSection2: {
-    id: 'fakeSection2',
-    name: 'Fake Section 2',
-    css: '',
-    js_footer: '',
-    js_header: '',
-    layoutModel: {
-      layout: [
-        {
-          uuid: 'abcde',
-          nodeType: 'component',
-          type: 'sdc.experience_builder.two_column',
-          slots: [
-            {
-              id: 'abcde/column_one',
-              name: 'column_one',
-              nodeType: 'slot',
-              components: [
-                {
-                  uuid: 'fghij',
-                  nodeType: 'component',
-                  type: 'sdc.experience_builder.my-hero',
-                  slots: [],
-                },
-              ],
-            },
-            {
-              id: 'abcde/column_two',
-              name: 'column_two',
-              nodeType: 'slot',
-              components: [
-                {
-                  uuid: 'klmno',
-                  nodeType: 'component',
-                  type: 'sdc.experience_builder.my-hero',
-                  slots: [],
-                },
-              ],
-            },
-          ],
-        },
-      ],
-      model: {
-        abcde: {
-          width: 50,
-          name: 'Two Column',
-        },
-        fghij: {
-          heading: 'A hero in slot 1!',
-          subheading: 'This text was defined in the section.',
-          cta1: 'Yes',
-          cta2: 'No',
-          cta1href: 'https://drupal.org',
-          cta2href: 'https://google.com',
-          name: 'Hero',
-        },
-        klmno: {
-          heading: 'A hero in slot 2!',
-          subheading: 'Text saved in the section',
-          cta1: 'Up',
-          cta2: 'Down',
-          cta1href: 'https://drupal.org',
-          cta2href: 'https://google.com',
-          name: 'Hero',
-        },
-      },
-    },
-    default_markup: '<h1 style="background: black; color: white;">TODO</h1>',
-  },
-};
-// Custom baseQuery function to return mock data during development
-// @ts-ignore
-const customBaseQuery = async (args, api, extraOptions) => {
-  if (args === 'xb-sections') {
-    return { data: mockSections };
-  }
-  return baseQuery(args, api, extraOptions);
-};
-
 // Define a service using a base URL and expected endpoints
 export const sectionApi = createApi({
   reducerPath: 'sectionsApi',
-  baseQuery: customBaseQuery,
+  baseQuery,
+  tagTypes: ['Sections'],
   endpoints: (builder) => ({
-    getSectionById: builder.query<any, string>({
-      query: (id) => `xb-section/${id}`,
-    }),
-    getDummySections: builder.query<any, void>({
-      query: () => `xb-sections`,
-    }),
     getSections: builder.query<any, void>({
       query: () => `/xb/api/config/pattern`,
+      providesTags: () => [{ type: 'Sections', id: 'LIST' }],
     }),
     saveSection: builder.mutation<{ html: string }, SaveSectionData>({
       query: (body) => ({
@@ -109,15 +23,11 @@ export const sectionApi = createApi({
         method: 'POST',
         body,
       }),
+      invalidatesTags: () => [{ type: 'Sections', id: 'LIST' }],
     }),
   }),
 });
 
 // Export hooks for usage in functional sections, which are
 // auto-generated based on the defined endpoints
-export const {
-  useGetSectionByIdQuery,
-  useGetSectionsQuery,
-  useGetDummySectionsQuery,
-  useSaveSectionMutation,
-} = sectionApi;
+export const { useGetSectionsQuery, useSaveSectionMutation } = sectionApi;
diff --git a/ui/tests/e2e/component-operations.cy.js b/ui/tests/e2e/component-operations.cy.js
index ef26fdd5264926e9f1c982b36f9d0bd5b1172a91..1c2f915ea551da4ce73486ccdadb365bcfa439bd 100644
--- a/ui/tests/e2e/component-operations.cy.js
+++ b/ui/tests/e2e/component-operations.cy.js
@@ -1,11 +1,6 @@
-import { onlyVisibleChars } from '../support/utils.js';
-
 describe('Perform CRUD operations on components', () => {
-  before(() => {
-    cy.drupalXbInstall();
-  });
-
   beforeEach(() => {
+    cy.drupalXbInstall();
     cy.drupalLogin('xbUser', 'xbUser');
   });
 
@@ -182,65 +177,108 @@ describe('Perform CRUD operations on components', () => {
   });
 
   it('Performs basic interaction with the Add section button', () => {
-    cy.loadURLandWaitForXBLoaded();
+    const clickDefault = {
+      force: true,
+      scrollBehavior: false,
+    };
 
-    // Check there are three heroes initially.
-    cy.testInIframe(
-      '[data-component-id="experience_builder:my-hero"]',
-      (myHeroComponent) => {
-        expect(myHeroComponent.length).to.equal(3);
-      },
-    );
-    // Check that the layers menu is initially open
-    cy.get('[data-testid="xb-primary-panel--layers"]').should(
-      'have.attr',
-      'data-state',
-      'active',
+    cy.viewport(2000, 1320);
+    cy.loadURLandWaitForXBLoaded();
+    cy.get(
+      '[data-xb-viewport-size="lg"] [aria-label="Two Column: Column One"]',
+    ).realClick({ position: 'bottom' });
+    cy.log(
+      'Save the entire node 1 layout as a section, so it can be added to a different node.',
     );
-    cy.get('[data-xb-uuid="content"]').findByText('Hero').should('not.exist');
-    cy.get('.primaryPanelContent').findByText('Two Column').click();
-    cy.findByLabelText('Column Width').should('exist');
-    cy.findAllByLabelText('Add section')
+
+    // First remove the two image components because they will otherwise crash
+    // due to the test not creating them in a way that allows the media entity
+    // to be found based on filename.
+    cy.get(
+      '[data-xb-viewport-size="lg"] [data-xb-component-id="sdc.experience_builder.image"]',
+    )
       .first()
-      .click({ scrollBehavior: 'center' });
+      .trigger('contextmenu', clickDefault);
+    cy.findByText('Delete').click({
+      force: true,
+      scrollBehavior: false,
+    });
+    cy.get(
+      '[data-xb-viewport-size="lg"] [data-xb-component-id="sdc.experience_builder.image"]',
+    )
+      .first()
+      .trigger('contextmenu', clickDefault);
+    cy.findByText('Delete').click({
+      force: true,
+      scrollBehavior: false,
+    });
+    cy.get(
+      '[data-xb-viewport-size="lg"] [aria-label="Two Column: Column One"]',
+    ).trigger('contextmenu', {
+      ...clickDefault,
+      position: 'bottom',
+    });
+    cy.findByText('Create section').click(clickDefault); // Section name
+    cy.findByLabelText('Section name').clear();
+    cy.findByLabelText('Section name').type('The Entire Node 1');
+    cy.findByText('Add to Library').click({ scrollBehavior: false });
 
-    // Check the active panel is the library panel.
-    cy.get('[data-testid="xb-primary-panel--library"]').should(
-      'have.attr',
-      'data-state',
-      'active',
-    );
-    cy.get('.primaryPanelContent').should('contain.text', 'Sections');
-    // Click on Fake Section 2 inside menu.
-    cy.get('.primaryPanelContent').findByText('Fake Section 2').click();
-    cy.waitForElementContentInIframe('div', 'A hero in slot 1!');
-    cy.testInIframe(
-      '[data-component-id="experience_builder:my-hero"]',
-      (components) => {
-        expect(components.length).to.equal(5);
-      },
-    );
-    cy.testInIframe(
-      '[data-component-id="experience_builder:my-hero"] h1',
-      (components) => {
-        const heroText1 = onlyVisibleChars(components[3].textContent);
-        const heroText2 = onlyVisibleChars(components[4].textContent);
-        expect(heroText1).to.equal('A hero in slot 1!');
-        expect(heroText2).to.equal('A hero in slot 2!');
-      },
-    );
+    cy.openLibraryPanel();
+    cy.get('.primaryPanelContent').within(() => {
+      cy.findByText('Sections').click(clickDefault);
+      cy.findByText('The Entire Node 1').should('exist');
+    });
+    cy.get('.primaryPanelContent')
+      .as('panel')
+      .should('contain.text', 'The Entire Node 1');
 
-    cy.log(
-      'The newly added Two Column component from the section should be selected',
+    cy.loadURLandWaitForXBLoaded({ url: 'xb/node/2' });
+    cy.get('#edit-title-0-value').should('exist');
+    cy.waitForElementContentNotInIframe('div', 'There goes my hero');
+    cy.openLibraryPanel();
+    // Add a single component so the 'Add section' button can appear when it is
+    // clicked.
+    cy.get('[data-xb-component-id="sdc.experience_builder.my-hero"]').should(
+      'exist',
     );
+
+    // Ensure the element that can receive component drops is present.
+    cy.waitForElementInIframe('.xb--sortable-slot-empty-placeholder');
+
+    cy.getIframeBody().then(($iframe) => {
+      cy.get($iframe.find('.xb--sortable-slot-empty-placeholder')).then(
+        ($destination) => {
+          cy.get(
+            '[data-xb-component-id="sdc.experience_builder.my-hero"]',
+          ).realDnd($destination);
+        },
+      );
+    });
+    cy.waitForElementContentInIframe('div', 'There goes my hero');
+
+    // There should be one Hero added.
+    cy.get(
+      '[data-xb-viewport-size="lg"] [data-xb-component-id="sdc.experience_builder.my-hero"]',
+    ).should('have.length', 1);
+    cy.findAllByLabelText('Add section').first().click(clickDefault);
+
+    // Add the section that was created earlier in this test.
+    cy.get('.primaryPanelContent').within(() => {
+      cy.findByText('The Entire Node 1').should('exist');
+      cy.findByText('The Entire Node 1').click(clickDefault);
+    });
+
+    // After adding the section, there should be four Hero components.
+    cy.get(
+      '[data-xb-viewport-size="lg"] [data-xb-component-id="sdc.experience_builder.my-hero"]',
+    ).should('have.length', 4);
+
+    // The Two Column component that is the top level element of the section
+    // should be the currently selected layer.
     cy.openLayersPanel();
     cy.findByTestId('xb-primary-panel').within(() => {
-      cy.findAllByText('Two Column').should('have.length', 2);
-      cy.log(
-        'The second (new) Two Column should be selected (check that it has a parent treeItem with the data-xb-selected attr)',
-      );
+      cy.findAllByText('Two Column').should('have.length', 1);
       cy.findAllByText('Two Column')
-        .eq(1)
         .parents('.treeItem')
         .should('have.attr', 'data-xb-selected', 'true');
     });
diff --git a/ui/tests/e2e/undo-redo.cy.js b/ui/tests/e2e/undo-redo.cy.js
index c64b4db78351139739659ab9d69481f10337975f..ed55e2d00e9220c6df8c757caa0897041af60d06 100644
--- a/ui/tests/e2e/undo-redo.cy.js
+++ b/ui/tests/e2e/undo-redo.cy.js
@@ -36,8 +36,6 @@ describe('Undo/Redo functionality', () => {
       'data-state',
       'active',
     );
-    cy.get('.primaryPanelContent').should('contain.text', 'Sections');
-    cy.get('.primaryPanelContent').findByText('Fake Section 2');
     cy.intercept('POST', '**/api/preview/node/1').as('getPreview');
 
     // Click on the menu item with data-xb-name="Hero" inside menu.