diff --git a/src/Plugin/UiPatterns/PropType/AttributesPropType.php b/src/Plugin/UiPatterns/PropType/AttributesPropType.php
index ee5fabb5d4eb038eb87ad68442bf9f40fa1416eb..e68252d38db83152a7b3575035ca9b43626f159e 100644
--- a/src/Plugin/UiPatterns/PropType/AttributesPropType.php
+++ b/src/Plugin/UiPatterns/PropType/AttributesPropType.php
@@ -58,7 +58,10 @@ class AttributesPropType extends PropTypePluginBase {
     It is possible because PropTypeInterface::normalize() is called by
     ComponentElementAlter::alter() after SDC is doing the validation.
      */
-    return new Attribute($value);
+    if (is_array($value)) {
+      return new Attribute($value);
+    }
+    return $value;
   }
 
 }
diff --git a/src/Plugin/UiPatterns/PropType/LinksPropType.php b/src/Plugin/UiPatterns/PropType/LinksPropType.php
index 4bbaa1c2e652619e33bc25eedf26a341e6bd0295..2d241098df927e703acfe2e54d0a61a8f80c1a96 100644
--- a/src/Plugin/UiPatterns/PropType/LinksPropType.php
+++ b/src/Plugin/UiPatterns/PropType/LinksPropType.php
@@ -9,6 +9,7 @@ use Drupal\Component\Serialization\Json;
 use Drupal\Core\Language\LanguageInterface;
 use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
 use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Drupal\Core\Template\Attribute;
 use Drupal\Core\Url;
 use Drupal\ui_patterns\Attribute\PropType;
 use Drupal\ui_patterns\PropTypePluginBase;
@@ -120,6 +121,7 @@ class LinksPropType extends PropTypePluginBase implements ContainerFactoryPlugin
         unset($item["link"]);
       }
       $item = self::normalizeUrl($item);
+      $item = static::normalizeAttributes($item, "link_attributes");
       if (array_key_exists("below", $item)) {
         $item["below"] = self::normalize($item["below"]);
       }
@@ -130,16 +132,20 @@ class LinksPropType extends PropTypePluginBase implements ContainerFactoryPlugin
   /**
    * Normalize attributes in an item.
    */
-  private static function normalizeAttributes(array $item): array {
-    if (array_key_exists("attributes", $item) && is_a($item["attributes"], '\Drupal\Core\Template\Attribute')) {
-      // In UI Patterns Settings, we kept the Attribute object here. It is not
-      // possible anymore because SDC will not validate it against the prop
-      // type schema.
-      $item["attributes"] = $item["attributes"]->toArray();
-      foreach ($item["attributes"] as $attr => $value) {
-        if ($value instanceof Url) {
-          $item["attributes"][$attr] = $value->toString();
-        }
+  protected static function normalizeAttributes(array $item, string $property = "attributes"): array {
+    if (!array_key_exists($property, $item)) {
+      return $item;
+    }
+    // Empty PHP arrays are converted in JSON arrays instead of JSON objects
+    // by json_encode(), so it is better to remove them.
+    if (is_array($item[$property]) && empty($item[$property])) {
+      unset($item[$property]);
+      return $item;
+    }
+    // Convert URL objects to strings.
+    foreach ($item[$property] as $attr => $value) {
+      if ($value instanceof Url) {
+        $item[$property][$attr] = $value->toString();
       }
     }
     return $item;
@@ -179,15 +185,32 @@ class LinksPropType extends PropTypePluginBase implements ContainerFactoryPlugin
     self::setHrefLang($options);
     self::setActiveClass($options, $url);
     if (isset($options["attributes"])) {
-      // In UI Patterns Settings, we built an Attribute object here. It is not
-      // possible anymore because SDC will not validate it against the prop
-      // type schema.
-      $item["link_attributes"] = $options["attributes"];
-      foreach ($item["link_attributes"] as $attr => $value) {
-        if ($value instanceof Url) {
-          $item["link_attributes"][$attr] = $value->toString();
-        }
-      }
+      $item = self:: mergeUrlAttributes($options["attributes"], $item);
+    }
+    return $item;
+  }
+
+  /**
+   * Merge Url attributes.
+   *
+   * $options["attributes"] is always an associative array of HTML attributes.
+   * But $item["link_attributes"] can vary.
+   */
+  private static function mergeUrlAttributes(array $url_attributes, array $item): array {
+    if (!isset($item["link_attributes"])) {
+      $item["link_attributes"] = $url_attributes;
+      return $item;
+    }
+    if (is_array($item["link_attributes"])) {
+      $item["link_attributes"] = array_merge(
+        $item["link_attributes"],
+        $url_attributes,
+      );
+      return $item;
+    }
+    if (is_a($item["link_attributes"], '\Drupal\Core\Template\Attribute')) {
+      $item["link_attributes"]->merge(new Attribute($url_attributes));
+      return $item;
     }
     return $item;
   }
diff --git a/tests/src/Unit/PropTypeNormalization/LinksPropTypeNormalizationTest.php b/tests/src/Unit/PropTypeNormalization/LinksPropTypeNormalizationTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..1d2e3f3f26588352cf996efe00ed26655e2ceb44
--- /dev/null
+++ b/tests/src/Unit/PropTypeNormalization/LinksPropTypeNormalizationTest.php
@@ -0,0 +1,392 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\ui_patterns\Unit;
+
+use Drupal\Core\Template\Attribute;
+use Drupal\Core\Url;
+use Drupal\Tests\UnitTestCase;
+use Drupal\ui_patterns\Plugin\UiPatterns\PropType\LinksPropType;
+
+/**
+ * @coversDefaultClass \Drupal\ui_patterns\Plugin\UiPatterns\PropType\LinksPropType
+ *
+ * @group ui_patterns
+ */
+final class LinksPropTypeNormalizationTest extends UnitTestCase {
+
+  /**
+   * @covers ::canonicalize
+   *
+   * @dataProvider provideNormalizationData
+   */
+  public function testNormalize(array $value, array $expected): void {
+    $normalized = LinksPropType::normalize($value);
+    self::assertEquals($normalized, $expected);
+  }
+
+  /**
+   * Provide data for testNormalize.
+   */
+  public static function provideNormalizationData(): \Generator {
+    $data = [
+      "Empty value" => [
+        "value" => [],
+        "expected" => [],
+      ],
+      "Standardized structure, flat, only primitives" => self::standardizedFlatPrimitives(),
+      // "Standardized structure, flat, with objects" =>
+      // self::standardizedFlatObjects(),
+      "Breadcrumb structure, as generated by the core service" => self::breadcrumb(),
+      "Mini pager, as generated by the Views module" => self::viewsMiniPager(),
+      "Pager's pages, as generated by the Views module" => self::pagerPages(),
+      "Pager's navigation links, as generated by the Views module" => self::pagesNavigationLinks(),
+      // "Menu, as generated by the Menu module" => self::menu(),
+      "Where link_attributes is already manually set" => self::linkAttributes(),
+    ];
+    foreach ($data as $label => $test) {
+      yield $label => [
+        $test['value'],
+        $test['expected'],
+      ];
+    };
+  }
+
+  /**
+   * Standardized structure, flat, only primitives.
+   */
+  protected static function standardizedFlatPrimitives() {
+    $value = [
+      [
+        "title" => "With an absolute URL",
+        "url" => "http://wwww.example.org/foo/bar",
+      ],
+      [
+        "title" => "With a relative URL",
+        "url" => "/foo/bar",
+        "attributes" => [
+          "foo" => "bar",
+        ],
+        "link_attributes" => [
+          "foo" => "baz",
+        ],
+      ],
+    ];
+    $expected = $value;
+    return [
+      "value" => $value,
+      "expected" => $expected,
+    ];
+  }
+
+  /**
+   * Standardized structure, flat, with objects.
+   */
+  protected static function standardizedFlatObjects() {
+    $value = [
+      [
+        "title" => "With an absolute URL",
+        "url" => Url::fromUri("http://wwww.example.org//foo/bar"),
+      ],
+      [
+        "title" => "With a relative URL",
+        "url" => Url::fromUserInput("/foo/bar", ["attributes" => ["foo" => "baz"]]),
+        "attributes" => new Attribute([
+          "foo" => "bar",
+        ]),
+      ],
+      [
+        "title" => "With empty attributes",
+        "url" => Url::fromUserInput("/foo/bar", ["attributes" => []]),
+        "attributes" => new Attribute([]),
+      ],
+    ];
+    $expected = [
+      [
+        "title" => "With an absolute URL",
+        "url" => "http://wwww.example.org/foo/bar",
+      ],
+      [
+        "title" => "With a relative URL",
+        "url" => "/foo/bar",
+        "attributes" => new Attribute([
+          "foo" => "bar",
+        ]),
+      ],
+      [
+        "title" => "With empty attributes",
+        "url" => "/foo/bar",
+      ],
+    ];
+    return [
+      "value" => $value,
+      "expected" => $expected,
+    ];
+  }
+
+  /**
+   * Breadcrumb structure, as generated by the core service.
+   *
+   * However, we replaced TranslatableMarkup("Home") by a simple string to
+   * avoid initializing a service container in the test.
+   */
+  protected static function breadcrumb() {
+    $value = [
+      [
+        "text" => "Home",
+        "url" => "/",
+      ],
+      [
+        "text" => "Foo",
+        "url" => "/foo",
+      ],
+    ];
+    $expected = [
+      [
+        "title" => "Home",
+        "url" => "/",
+      ],
+      [
+        "title" => "Foo",
+        "url" => "/foo",
+      ],
+    ];
+    return [
+      "value" => $value,
+      "expected" => $expected,
+    ];
+  }
+
+  /**
+   * Mini pager, as generated by the Views module.
+   *
+   * Usually, we don't expect the theme owner to use the normalization
+   * directly, but to add some custom logic around 'current' in a preprocess.
+   */
+  protected static function viewsMiniPager() {
+    $value = [
+      "current" => 2,
+      "previous" => [
+        "href" =>
+        "/articles?page=0",
+        "text" => "‹‹",
+        "attributes" => new Attribute(),
+      ],
+      "next" => [
+        "href" => "/articles?page=2",
+        "text" => "››",
+        "attributes" => new Attribute(),
+      ],
+    ];
+    $expected = [
+      [
+        "title" => "‹‹",
+        "url" => "/articles?page=0",
+        "attributes" => new Attribute(),
+      ],
+      [
+        "title" => "››",
+        "url" => "/articles?page=2",
+        "attributes" => new Attribute(),
+      ],
+    ];
+    return [
+      "value" => $value,
+      "expected" => $expected,
+    ];
+  }
+
+  /**
+   * Pager's pages, as generated by the Views module.
+   */
+  protected static function pagerPages() {
+    $value = [
+      [
+        "href" => "?page=0",
+        "attributes" => new Attribute(),
+      ],
+      [
+        "href" => "?page=1",
+        "attributes" => new Attribute([
+          "aria-current" => "page",
+        ]),
+      ],
+      [
+        "href" => "?page=2",
+        "attributes" => new Attribute(),
+      ],
+      [
+        "href" => "?page=3",
+        "attributes" => new Attribute(),
+      ],
+    ];
+    $expected = [
+      [
+        "title" => 0,
+        "url" => "?page=0",
+        "attributes" => new Attribute(),
+      ],
+      [
+        "title" => 1,
+        "url" => "?page=1",
+        "attributes" => new Attribute([
+          "aria-current" => "page",
+        ]),
+      ],
+      [
+        "title" => 2,
+        "url" => "?page=2",
+        "attributes" => new Attribute(),
+      ],
+      [
+        "title" => 3,
+        "url" => "?page=3",
+        "attributes" => new Attribute(),
+      ],
+    ];
+    return [
+      "value" => $value,
+      "expected" => $expected,
+    ];
+  }
+
+  /**
+   * Pager's navigation links, as generated by the Views module.
+   */
+  protected static function pagesNavigationLinks() {
+    $value = [
+      "first" => [
+        "attributes" => new Attribute(),
+        "href" => "?page=0",
+        "text" => "« First",
+      ],
+      "previous" => [
+        "attributes" => new Attribute(),
+        "href" => "?page=0",
+        "text" => "‹‹",
+      ],
+      "next" => [
+        "attributes" => new Attribute(),
+        "href" => "?page=2",
+        "text" => "››",
+      ],
+      "last" => [
+        "attributes" => new Attribute(),
+        "href" => "?page=3",
+        "text" => "Last »",
+      ],
+    ];
+    $expected = [
+      [
+        "attributes" => new Attribute(),
+        "title" => "« First",
+        "url" => "?page=0",
+      ],
+      [
+        "attributes" => new Attribute(),
+        "title" => "‹‹",
+        "url" => "?page=0",
+      ],
+      [
+        "attributes" => new Attribute(),
+        "title" => "››",
+        "url" => "?page=2",
+      ],
+      [
+        "attributes" => new Attribute(),
+        "title" => "Last »",
+        "url" => "?page=3",
+      ],
+    ];
+    return [
+      "value" => $value,
+      "expected" => $expected,
+    ];
+  }
+
+  /**
+   * Menu, as generated by the Menu module.
+   *
+   * However, we replaced TranslatableMarkup() by a simple string to avoid
+   * initializing a service container in the test.
+   * And we replaced Url::fromRoute() by Url::fromUserInput() to avoid
+   * initializing a service container in the test.
+   */
+  protected static function menu() {
+    $value = [
+      "user.page" => [
+        "is_expanded" => FALSE,
+        "is_collapsed" => FALSE,
+        "in_active_trail" => FALSE,
+        "attributes" => new Attribute(),
+        "title" => "My account",
+        "url" => Url::fromUserInput("/user", ["set_active_class" => TRUE]),
+        "below" => [],
+      ],
+      "user.logout" => [
+        "is_expanded" => FALSE,
+        "is_collapsed" => FALSE,
+        "in_active_trail" => FALSE,
+        "attributes" => new Attribute(),
+        "title" => "Log out",
+        "url" => Url::fromUserInput("/user/logout", ["set_active_class" => TRUE]),
+        "below" => [],
+      ],
+    ];
+    $expected = [
+      [
+        "title" => "My account",
+        "url" => "/user",
+      ],
+      [
+        "title" => "Log out",
+        "url" => "/user/logout",
+      ],
+    ];
+    return [
+      "value" => $value,
+      "expected" => $expected,
+    ];
+  }
+
+  /**
+   * Where link_attributes is already manually set.
+   *
+   * Instead of being generated from an Url object.
+   */
+  protected static function linkAttributes() {
+    $value = [
+      [
+        "href" => "?page=0",
+        "attributes" => new Attribute(),
+        "link_attributes" => new Attribute([
+          'class' => [
+            'display-flex',
+            'flex-align-center',
+            'flex-no-wrap',
+          ],
+        ]),
+      ],
+    ];
+    $expected = [
+      [
+        "title" => 0,
+        "url" => "?page=0",
+        "attributes" => new Attribute(),
+        "link_attributes" => new Attribute([
+          'class' => [
+            'display-flex',
+            'flex-align-center',
+            'flex-no-wrap',
+          ],
+        ]),
+      ],
+    ];
+    return [
+      "value" => $value,
+      "expected" => $expected,
+    ];
+  }
+
+}