diff --git a/core/modules/jsonapi/jsonapi.services.yml b/core/modules/jsonapi/jsonapi.services.yml
index b5cf0ef1e90de5fa3546044143b8c339c550bed8..319b3c1927884efb704e419ef17726a287e5754f 100644
--- a/core/modules/jsonapi/jsonapi.services.yml
+++ b/core/modules/jsonapi/jsonapi.services.yml
@@ -72,11 +72,10 @@ services:
     class: Drupal\jsonapi\Normalizer\LinkCollectionNormalizer
     tags:
       - { name: jsonapi_normalizer }
-  serializer.normalizer.entity_reference_field.jsonapi:
-    class: Drupal\jsonapi\Normalizer\EntityReferenceFieldNormalizer
+  serializer.normalizer.relationship.jsonapi:
+    class: Drupal\jsonapi\Normalizer\RelationshipNormalizer
     tags:
-      # This must have a higher priority than the 'serializer.normalizer.field.jsonapi' to take effect.
-      - { name: jsonapi_normalizer, priority: 1 }
+      - { name: jsonapi_normalizer }
   serializer.encoder.jsonapi:
     class: Drupal\jsonapi\Encoder\JsonEncoder
     tags:
diff --git a/core/modules/jsonapi/src/Controller/EntityResource.php b/core/modules/jsonapi/src/Controller/EntityResource.php
index 24c0dadf7a4cda63b29153346b618a467be52c4c..b0febdc49b7c5d82bf3c7ad4470e32c3b0ca75da 100644
--- a/core/modules/jsonapi/src/Controller/EntityResource.php
+++ b/core/modules/jsonapi/src/Controller/EntityResource.php
@@ -34,11 +34,12 @@
 use Drupal\jsonapi\JsonApiResource\IncludedData;
 use Drupal\jsonapi\JsonApiResource\LinkCollection;
 use Drupal\jsonapi\JsonApiResource\NullIncludedData;
+use Drupal\jsonapi\JsonApiResource\Relationship;
 use Drupal\jsonapi\JsonApiResource\ResourceIdentifier;
 use Drupal\jsonapi\JsonApiResource\Link;
 use Drupal\jsonapi\JsonApiResource\ResourceObject;
 use Drupal\jsonapi\JsonApiResource\ResourceObjectData;
-use Drupal\jsonapi\Normalizer\EntityReferenceFieldNormalizer;
+use Drupal\jsonapi\JsonApiResource\TopLevelDataInterface;
 use Drupal\jsonapi\Query\Filter;
 use Drupal\jsonapi\Query\Sort;
 use Drupal\jsonapi\Query\OffsetPage;
@@ -563,10 +564,8 @@ public function getRelationship(ResourceType $resource_type, FieldableEntityInte
     // Access will have already been checked by the RelationshipFieldAccess
     // service, so we don't need to call ::getAccessCheckedResourceObject().
     $resource_object = ResourceObject::createFromEntity($resource_type, $entity);
-    $relationship_object_urls = EntityReferenceFieldNormalizer::getRelationshipLinks($resource_object, $related);
-    $response = $this->buildWrappedResponse($field_list, $request, $this->getIncludes($request, $resource_object), $response_code, [], array_reduce(array_keys($relationship_object_urls), function (LinkCollection $links, $key) use ($relationship_object_urls) {
-      return $links->withLink($key, new Link(new CacheableMetadata(), $relationship_object_urls[$key], [$key]));
-    }, new LinkCollection([])));
+    $relationship = Relationship::createFromEntityReferenceField($resource_object, $field_list);
+    $response = $this->buildWrappedResponse($relationship, $request, $this->getIncludes($request, $resource_object), $response_code);
     // Add the host entity as a cacheable dependency.
     $response->addCacheableDependency($entity);
     return $response;
@@ -969,7 +968,7 @@ protected static function relationshipResponseRequiresBody(array $received_resou
   /**
    * Builds a response with the appropriate wrapped document.
    *
-   * @param mixed $data
+   * @param \Drupal\jsonapi\JsonApiResource\TopLevelDataInterface $data
    *   The data to wrap.
    * @param \Symfony\Component\HttpFoundation\Request $request
    *   The request object.
@@ -988,8 +987,7 @@ protected static function relationshipResponseRequiresBody(array $received_resou
    * @return \Drupal\jsonapi\ResourceResponse
    *   The response.
    */
-  protected function buildWrappedResponse($data, Request $request, IncludedData $includes, $response_code = 200, array $headers = [], LinkCollection $links = NULL, array $meta = []) {
-    assert($data instanceof Data || $data instanceof FieldItemListInterface);
+  protected function buildWrappedResponse(TopLevelDataInterface $data, Request $request, IncludedData $includes, $response_code = 200, array $headers = [], LinkCollection $links = NULL, array $meta = []) {
     $links = ($links ?: new LinkCollection([]));
     if (!$links->hasLinkWithKey('self')) {
       $self_link = new Link(new CacheableMetadata(), self::getRequestLink($request), ['self']);
diff --git a/core/modules/jsonapi/src/JsonApiResource/JsonApiDocumentTopLevel.php b/core/modules/jsonapi/src/JsonApiResource/JsonApiDocumentTopLevel.php
index 124bc0723767e2087fc191dee294e7b21984aa32..5fc7291f9622958d0dba12a7396d6b905ea4a252 100644
--- a/core/modules/jsonapi/src/JsonApiResource/JsonApiDocumentTopLevel.php
+++ b/core/modules/jsonapi/src/JsonApiResource/JsonApiDocumentTopLevel.php
@@ -2,8 +2,6 @@
 
 namespace Drupal\jsonapi\JsonApiResource;
 
-use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
-
 /**
  * Represents a JSON:API document's "top level".
  *
@@ -57,7 +55,7 @@ class JsonApiDocumentTopLevel {
   /**
    * Instantiates a JsonApiDocumentTopLevel object.
    *
-   * @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface|\Drupal\jsonapi\JsonApiResource\Data|\Drupal\jsonapi\JsonApiResource\ErrorCollection|\Drupal\Core\Field\EntityReferenceFieldItemListInterface $data
+   * @param \Drupal\jsonapi\JsonApiResource\TopLevelDataInterface|\Drupal\jsonapi\JsonApiResource\ErrorCollection $data
    *   The data to normalize. It can be either a ResourceObject, or a stand-in
    *   for one, or a collection of the same.
    * @param \Drupal\jsonapi\JsonApiResource\IncludedData $includes
@@ -69,13 +67,13 @@ class JsonApiDocumentTopLevel {
    *   (optional) The metadata to normalize.
    */
   public function __construct($data, IncludedData $includes, LinkCollection $links, array $meta = []) {
-    assert($data instanceof ResourceIdentifierInterface || $data instanceof Data || $data instanceof ErrorCollection || $data instanceof EntityReferenceFieldItemListInterface);
+    assert($data instanceof TopLevelDataInterface || $data instanceof ErrorCollection);
     assert(!$data instanceof ErrorCollection || $includes instanceof NullIncludedData);
-    $this->data = $data instanceof ResourceObjectData ? $data->getAccessible() : $data;
-    $this->includes = $includes->getAccessible();
-    $this->links = $links->withContext($this);
-    $this->meta = $meta;
-    $this->omissions = $data instanceof ResourceObjectData
+    $this->data = $data instanceof TopLevelDataInterface ? $data->getData() : $data;
+    $this->includes = $includes->getData();
+    $this->links = $data instanceof TopLevelDataInterface ? $data->getMergedLinks($links->withContext($this)) : $links->withContext($this);
+    $this->meta = $data instanceof TopLevelDataInterface ? $data->getMergedMeta($meta) : $meta;
+    $this->omissions = $data instanceof TopLevelDataInterface
       ? OmittedData::merge($data->getOmissions(), $includes->getOmissions())
       : $includes->getOmissions();
   }
@@ -83,7 +81,7 @@ public function __construct($data, IncludedData $includes, LinkCollection $links
   /**
    * Gets the data.
    *
-   * @return \Drupal\jsonapi\JsonApiResource\ResourceObject|\Drupal\jsonapi\JsonApiResource\Data|\Drupal\jsonapi\JsonApiResource\LabelOnlyResourceObject|\Drupal\jsonapi\JsonApiResource\ErrorCollection
+   * @return \Drupal\jsonapi\JsonApiResource\Data|\Drupal\jsonapi\JsonApiResource\ErrorCollection
    *   The data.
    */
   public function getData() {
diff --git a/core/modules/jsonapi/src/JsonApiResource/LinkCollection.php b/core/modules/jsonapi/src/JsonApiResource/LinkCollection.php
index 4464f4e51f773d46a6591e32384b5f5782d13d51..29aac92830a01fe0e860bf406c647c6965e867ff 100644
--- a/core/modules/jsonapi/src/JsonApiResource/LinkCollection.php
+++ b/core/modules/jsonapi/src/JsonApiResource/LinkCollection.php
@@ -28,7 +28,7 @@ final class LinkCollection implements \IteratorAggregate {
    * All links objects exist within a context object. Links form a relationship
    * between a source IRI and target IRI. A context is the link's source.
    *
-   * @var \Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel|\Drupal\jsonapi\JsonApiResource\ResourceObject
+   * @var \Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel|\Drupal\jsonapi\JsonApiResource\ResourceObject|\Drupal\jsonapi\JsonApiResource\Relationship
    *
    * @see https://tools.ietf.org/html/rfc8288#section-3.2
    */
@@ -39,7 +39,7 @@ final class LinkCollection implements \IteratorAggregate {
    *
    * @param \Drupal\jsonapi\JsonApiResource\Link[] $links
    *   An associated array of key names and JSON:API Link objects.
-   * @param \Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel|\Drupal\jsonapi\JsonApiResource\ResourceObject $context
+   * @param \Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel|\Drupal\jsonapi\JsonApiResource\ResourceObject|\Drupal\jsonapi\JsonApiResource\Relationship $context
    *   (internal use only) The context object. Use the self::withContext()
    *   method to establish a context. This should be done automatically when
    *   a LinkCollection is passed into a context object.
@@ -51,7 +51,7 @@ public function __construct(array $links, $context = NULL) {
     assert(Inspector::assertAll(function ($link) {
       return $link instanceof Link || is_array($link) && Inspector::assertAllObjects($link, Link::class);
     }, $links));
-    assert(is_null($context) || Inspector::assertAllObjects([$context], JsonApiDocumentTopLevel::class, ResourceObject::class));
+    assert(is_null($context) || Inspector::assertAllObjects([$context], JsonApiDocumentTopLevel::class, ResourceObject::class, Relationship::class));
     ksort($links);
     $this->links = array_map(function ($link) {
       return is_array($link) ? $link : [$link];
@@ -111,7 +111,7 @@ public function hasLinkWithKey($key) {
   /**
    * Establishes a new context for a LinkCollection.
    *
-   * @param \Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel|\Drupal\jsonapi\JsonApiResource\ResourceObject $context
+   * @param \Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel|\Drupal\jsonapi\JsonApiResource\ResourceObject|\Drupal\jsonapi\JsonApiResource\Relationship $context
    *   The new context object.
    *
    * @return static
@@ -124,7 +124,7 @@ public function withContext($context) {
   /**
    * Gets the LinkCollection's context object.
    *
-   * @return \Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel|\Drupal\jsonapi\JsonApiResource\ResourceObject
+   * @return \Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel|\Drupal\jsonapi\JsonApiResource\ResourceObject|\Drupal\jsonapi\JsonApiResource\Relationship
    *   The LinkCollection's context.
    */
   public function getContext() {
diff --git a/core/modules/jsonapi/src/JsonApiResource/Relationship.php b/core/modules/jsonapi/src/JsonApiResource/Relationship.php
new file mode 100644
index 0000000000000000000000000000000000000000..0f6f9a6325ec3c5853dbb1e0b941ce9cbe0a1a0d
--- /dev/null
+++ b/core/modules/jsonapi/src/JsonApiResource/Relationship.php
@@ -0,0 +1,262 @@
+<?php
+
+namespace Drupal\jsonapi\JsonApiResource;
+
+use Drupal\Component\Utility\NestedArray;
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
+use Drupal\Core\Url;
+use Drupal\jsonapi\JsonApiSpec;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\Routing\Routes;
+
+/**
+ * Represents references from one resource object to other resource object(s).
+ *
+ * @internal JSON:API maintains no PHP API since its API is the HTTP API. This
+ *   class may change at any time and this will break any dependencies on it.
+ *
+ * @see https://www.drupal.org/project/jsonapi/issues/3032787
+ * @see jsonapi.api.php
+ */
+class Relationship implements TopLevelDataInterface {
+
+  /**
+   * The context resource object of the relationship.
+   *
+   * A relationship object represents references from a resource object in
+   * which it’s defined to other resource objects. Respectively, the "context"
+   * of the relationship and the "target(s)" of the relationship.
+   *
+   * A relationship object's context either comes from the resource object that
+   * contains it or, in the case that the relationship object is accessed
+   * directly via a relationship URL, from its `self` URL, which should identify
+   * the resource to which it belongs.
+   *
+   * @var \Drupal\jsonapi\JsonApiResource\ResourceObject
+   *
+   * @see https://jsonapi.org/format/#document-resource-object-relationships
+   * @see https://jsonapi.org/recommendations/#urls-relationships
+   */
+  protected $context;
+
+  /**
+   * The data of the relationship object.
+   *
+   * @var \Drupal\jsonapi\JsonApiResource\RelationshipData
+   */
+  protected $data;
+
+  /**
+   * The relationship's public field name.
+   *
+   * @var string
+   */
+  protected $fieldName;
+
+  /**
+   * The relationship object's links.
+   *
+   * @var \Drupal\jsonapi\JsonApiResource\LinkCollection
+   */
+  protected $links;
+
+  /**
+   * The relationship object's meta member.
+   *
+   * @var array
+   */
+  protected $meta;
+
+  /**
+   * Relationship constructor.
+   *
+   * This constructor is protected by design. To create a new relationship, use
+   * static::createFromEntityReferenceField().
+   *
+   * @param string $public_field_name
+   *   The public field name of the relationship field.
+   * @param \Drupal\jsonapi\JsonApiResource\RelationshipData $data
+   *   The relationship data.
+   * @param \Drupal\jsonapi\JsonApiResource\LinkCollection $links
+   *   Any links for the resource object, if a `self` link is not
+   *   provided, one will be automatically added if the resource is locatable
+   *   and is not internal.
+   * @param array $meta
+   *   Any relationship metadata.
+   * @param \Drupal\jsonapi\JsonApiResource\ResourceObject $context
+   *   The relationship's context resource object. Use the
+   *   self::withContext() method to establish a context.
+   *
+   * @see \Drupal\jsonapi\JsonApiResource\Relationship::createFromEntityReferenceField()
+   */
+  protected function __construct($public_field_name, RelationshipData $data, LinkCollection $links, array $meta, ResourceObject $context) {
+    $this->fieldName = $public_field_name;
+    $this->data = $data;
+    $this->links = $links->withContext($this);
+    $this->meta = $meta;
+    $this->context = $context;
+  }
+
+  /**
+   * Creates a new Relationship from an entity reference field.
+   *
+   * @param \Drupal\jsonapi\JsonApiResource\ResourceObject $context
+   *   The context resource object of the relationship to be created.
+   * @param \Drupal\Core\Field\EntityReferenceFieldItemListInterface $field
+   *   The entity reference field from which to create the relationship.
+   * @param \Drupal\jsonapi\JsonApiResource\LinkCollection $links
+   *   (optional) Any extra links for the Relationship, if a `self` link is not
+   *   provided, one will be automatically added if the context resource is
+   *   locatable and is not internal.
+   * @param array $meta
+   *   (optional) Any relationship metadata.
+   *
+   * @return static
+   *   An instantiated relationship object.
+   */
+  public static function createFromEntityReferenceField(ResourceObject $context, EntityReferenceFieldItemListInterface $field, LinkCollection $links = NULL, array $meta = []) {
+    $context_resource_type = $context->getResourceType();
+    $public_field_name = $context_resource_type->getPublicName($field->getName());
+    $field_cardinality = $field->getFieldDefinition()->getFieldStorageDefinition()->getCardinality();
+    return new static(
+      $public_field_name,
+      new RelationshipData(ResourceIdentifier::toResourceIdentifiers($field), $field_cardinality),
+      static::buildLinkCollectionFromEntityReferenceField($context, $field, $links ?: new LinkCollection([])),
+      $meta,
+      $context
+    );
+  }
+
+  /**
+   * Gets context resource object of the relationship.
+   *
+   * @return \Drupal\jsonapi\JsonApiResource\ResourceObject
+   *   The context ResourceObject.
+   *
+   * @see \Drupal\jsonapi\JsonApiResource\Relationship::$context
+   */
+  public function getContext() {
+    return $this->context;
+  }
+
+  /**
+   * Gets the relationship object's public field name.
+   *
+   * @return string
+   *   The relationship's field name.
+   */
+  public function getFieldName() {
+    return $this->fieldName;
+  }
+
+  /**
+   * Gets the relationship object's data.
+   *
+   * @return \Drupal\jsonapi\JsonApiResource\RelationshipData
+   *   The relationship's data.
+   */
+  public function getData() {
+    return $this->data;
+  }
+
+  /**
+   * Gets the relationship object's links.
+   *
+   * @return \Drupal\jsonapi\JsonApiResource\LinkCollection
+   *   The relationship object's links.
+   */
+  public function getLinks() {
+    return $this->links;
+  }
+
+  /**
+   * Gets the relationship object's metadata.
+   *
+   * @return array
+   *   The relationship object's metadata.
+   */
+  public function getMeta() {
+    return $this->meta;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getOmissions() {
+    return new OmittedData([]);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getMergedLinks(LinkCollection $top_level_links) {
+    // When directly fetching a relationship object, the relationship object's
+    // links become the top-level object's links unless they've been
+    // overridden. Overrides are especially important for the `self` link, which
+    // must match the link that generated the response. For example, the
+    // top-level `self` link might have an `include` query parameter that would
+    // be lost otherwise.
+    // See https://jsonapi.org/format/#fetching-relationships-responses-200 and
+    // https://jsonapi.org/format/#document-top-level.
+    return LinkCollection::merge($top_level_links, $this->getLinks()->filter(function ($key) use ($top_level_links) {
+      return !$top_level_links->hasLinkWithKey($key);
+    })->withContext($top_level_links->getContext()));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getMergedMeta(array $top_level_meta) {
+    return NestedArray::mergeDeep($top_level_meta, $this->getMeta());
+  }
+
+  /**
+   * Builds a LinkCollection for the given entity reference field.
+   *
+   * @param \Drupal\jsonapi\JsonApiResource\ResourceObject $context
+   *   The context resource object of the relationship object.
+   * @param \Drupal\Core\Field\EntityReferenceFieldItemListInterface $field
+   *   The entity reference field from which to create the links.
+   * @param \Drupal\jsonapi\JsonApiResource\LinkCollection $links
+   *   Any extra links for the Relationship, if a `self` link is not provided,
+   *   one will be automatically added if the context resource is locatable and
+   *   is not internal.
+   *
+   * @return \Drupal\jsonapi\JsonApiResource\LinkCollection
+   *   The built links.
+   */
+  protected static function buildLinkCollectionFromEntityReferenceField(ResourceObject $context, EntityReferenceFieldItemListInterface $field, LinkCollection $links) {
+    $context_resource_type = $context->getResourceType();
+    $public_field_name = $context_resource_type->getPublicName($context_resource_type->getPublicName($field->getName()));
+    if ($context_resource_type->isLocatable() && !$context_resource_type->isInternal()) {
+      $context_is_versionable = $context_resource_type->isVersionable();
+      if (!$links->hasLinkWithKey('self')) {
+        $route_name = Routes::getRouteName($context_resource_type, "$public_field_name.relationship.get");
+        $self_link = Url::fromRoute($route_name, ['entity' => $context->getId()]);
+        if ($context_is_versionable) {
+          $self_link->setOption('query', [JsonApiSpec::VERSION_QUERY_PARAMETER => $context->getVersionIdentifier()]);
+        }
+        $links = $links->withLink('self', new Link(new CacheableMetadata(), $self_link, ['self']));
+      }
+      $has_non_internal_resource_type = array_reduce($context_resource_type->getRelatableResourceTypesByField($public_field_name), function ($carry, ResourceType $target) {
+        return $carry ?: !$target->isInternal();
+      }, FALSE);
+      // If a `related` link was not provided, automatically generate one from
+      // the relationship object to the collection resource with all of the
+      // resources targeted by this relationship. However, that link should
+      // *not* be generated if all of the relatable resources are internal.
+      // That's because, in that case, a route will not exist for it.
+      if (!$links->hasLinkWithKey('related') && $has_non_internal_resource_type) {
+        $route_name = Routes::getRouteName($context_resource_type, "$public_field_name.related");
+        $related_link = Url::fromRoute($route_name, ['entity' => $context->getId()]);
+        if ($context_is_versionable) {
+          $related_link->setOption('query', [JsonApiSpec::VERSION_QUERY_PARAMETER => $context->getVersionIdentifier()]);
+        }
+        $links = $links->withLink('related', new Link(new CacheableMetadata(), $related_link, ['related']));
+      }
+    }
+    return $links;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/JsonApiResource/ResourceIdentifier.php b/core/modules/jsonapi/src/JsonApiResource/ResourceIdentifier.php
index f129281b15d84c4f2546becf07ed0c4e2a4269b2..eb7b1de0c4b0f52511d3a803118080c7eab9256e 100644
--- a/core/modules/jsonapi/src/JsonApiResource/ResourceIdentifier.php
+++ b/core/modules/jsonapi/src/JsonApiResource/ResourceIdentifier.php
@@ -311,12 +311,15 @@ public static function toResourceIdentifier(EntityReferenceItem $item, $arity =
    */
   public static function toResourceIdentifiers(EntityReferenceFieldItemListInterface $items) {
     $relationships = [];
-    foreach ($items as $item) {
+    foreach ($items->filterEmptyItems() as $item) {
       // Create a ResourceIdentifier from the field item. This will make it
       // comparable with all previous field items. Here, it is assumed that the
       // resource identifier is unique so it has no arity. If a parallel
       // relationship is encountered, it will be assigned later.
       $relationship = static::toResourceIdentifier($item);
+      if ($relationship->getResourceType()->isInternal()) {
+        continue;
+      }
       // Now, iterate over the previously seen resource identifiers in reverse
       // order. Reverse order is important so that when a parallel relationship
       // is encountered, it will have the highest arity value so the current
diff --git a/core/modules/jsonapi/src/JsonApiResource/ResourceObjectData.php b/core/modules/jsonapi/src/JsonApiResource/ResourceObjectData.php
index 41430f585884aca59a5e0f70d705797f57c356c8..5fadfb0356b35d2c5e3378f17d914aed4f0df429 100644
--- a/core/modules/jsonapi/src/JsonApiResource/ResourceObjectData.php
+++ b/core/modules/jsonapi/src/JsonApiResource/ResourceObjectData.php
@@ -14,7 +14,7 @@
  * @see https://www.drupal.org/project/jsonapi/issues/3032787
  * @see jsonapi.api.php
  */
-class ResourceObjectData extends Data {
+class ResourceObjectData extends Data implements TopLevelDataInterface {
 
   /**
    * ResourceObjectData constructor.
@@ -31,6 +31,13 @@ public function __construct($data, $cardinality = -1) {
     parent::__construct($data, $cardinality);
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function getData() {
+    return $this->getAccessible();
+  }
+
   /**
    * Gets only data to be exposed.
    *
@@ -61,4 +68,18 @@ public function getOmissions() {
     return new OmittedData($omitted_data);
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function getMergedLinks(LinkCollection $top_level_links) {
+    return $top_level_links;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getMergedMeta(array $top_level_meta) {
+    return $top_level_meta;
+  }
+
 }
diff --git a/core/modules/jsonapi/src/JsonApiResource/TopLevelDataInterface.php b/core/modules/jsonapi/src/JsonApiResource/TopLevelDataInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..a01da15682e36a6d0b5b6a2dc68a04d586db1c6d
--- /dev/null
+++ b/core/modules/jsonapi/src/JsonApiResource/TopLevelDataInterface.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace Drupal\jsonapi\JsonApiResource;
+
+/**
+ * Interface for objects that can appear as top-level object data.
+ *
+ * @internal JSON:API maintains no PHP API since its API is the HTTP API. This
+ *   class may change at any time and this will break any dependencies on it.
+ *
+ * @see https://www.drupal.org/project/jsonapi/issues/3032787
+ * @see jsonapi.api.php
+ */
+interface TopLevelDataInterface {
+
+  /**
+   * Returns the data for the top-level data member of a JSON:API document.
+   *
+   * @return \Drupal\jsonapi\JsonApiResource\Data
+   *   The top-level data.
+   */
+  public function getData();
+
+  /**
+   * Returns the data that was omitted from the JSON:API document.
+   *
+   * @return \Drupal\jsonapi\JsonApiResource\OmittedData
+   *   The omitted data.
+   */
+  public function getOmissions();
+
+  /**
+   * Merges the object's links with the top-level links.
+   *
+   * @param \Drupal\jsonapi\JsonApiResource\LinkCollection $top_level_links
+   *   The top-level links to merge.
+   *
+   * @return \Drupal\jsonapi\JsonApiResource\LinkCollection
+   *   The merged links.
+   */
+  public function getMergedLinks(LinkCollection $top_level_links);
+
+  /**
+   * Merges the object's meta member with the top-level meta member.
+   *
+   * @param array $top_level_meta
+   *   The top-level links to merge.
+   *
+   * @return array
+   *   The merged meta member.
+   */
+  public function getMergedMeta(array $top_level_meta);
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/EntityReferenceFieldNormalizer.php b/core/modules/jsonapi/src/Normalizer/EntityReferenceFieldNormalizer.php
deleted file mode 100644
index 1096de42d391ad8ef43e9294a6f06129b1138261..0000000000000000000000000000000000000000
--- a/core/modules/jsonapi/src/Normalizer/EntityReferenceFieldNormalizer.php
+++ /dev/null
@@ -1,120 +0,0 @@
-<?php
-
-namespace Drupal\jsonapi\Normalizer;
-
-use Drupal\Core\Cache\CacheableMetadata;
-use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
-use Drupal\Core\Url;
-use Drupal\jsonapi\JsonApiResource\ResourceIdentifier;
-use Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface;
-use Drupal\jsonapi\JsonApiResource\ResourceObject;
-use Drupal\jsonapi\JsonApiSpec;
-use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
-use Drupal\jsonapi\Routing\Routes;
-
-/**
- * Normalizer class specific for entity reference field objects.
- *
- * @internal JSON:API maintains no PHP API since its API is the HTTP API. This
- *   class may change at any time and this will break any dependencies on it.
- *
- * @see https://www.drupal.org/project/jsonapi/issues/3032787
- * @see jsonapi.api.php
- */
-class EntityReferenceFieldNormalizer extends FieldNormalizer {
-
-  /**
-   * {@inheritdoc}
-   */
-  protected $supportedInterfaceOrClass = EntityReferenceFieldItemListInterface::class;
-
-  /**
-   * {@inheritdoc}
-   */
-  public function normalize($field, $format = NULL, array $context = []) {
-    assert($field instanceof EntityReferenceFieldItemListInterface);
-    // Build the relationship object based on the Entity Reference and normalize
-    // that object instead.
-    $definition = $field->getFieldDefinition();
-    $cardinality = $definition
-      ->getFieldStorageDefinition()
-      ->getCardinality();
-    $resource_identifiers = array_filter(ResourceIdentifier::toResourceIdentifiers($field->filterEmptyItems()), function (ResourceIdentifierInterface $resource_identifier) {
-      return !$resource_identifier->getResourceType()->isInternal();
-    });
-    $context['field_name'] = $field->getName();
-    $normalized_items = CacheableNormalization::aggregate($this->serializer->normalize($resource_identifiers, $format, $context));
-    assert($context['resource_object'] instanceof ResourceObject);
-    $link_cacheability = new CacheableMetadata();
-    $links = array_map(function (Url $link) use ($link_cacheability) {
-      $href = $link->setAbsolute()->toString(TRUE);
-      $link_cacheability->addCacheableDependency($href);
-      return ['href' => $href->getGeneratedUrl()];
-    }, static::getRelationshipLinks($context['resource_object'], $field->getName()));
-    $data_normalization = $normalized_items->getNormalization();
-    $normalization = [
-      // Empty 'to-one' relationships must be NULL.
-      // Empty 'to-many' relationships must be an empty array.
-      // @link http://jsonapi.org/format/#document-resource-object-linkage
-      'data' => $cardinality === 1 ? array_shift($data_normalization) : $data_normalization,
-    ];
-    if (!empty($links)) {
-      $normalization['links'] = $links;
-    }
-    return (new CacheableNormalization($normalized_items, $normalization))->withCacheableDependency($link_cacheability);
-  }
-
-  /**
-   * Gets the links for the relationship.
-   *
-   * @param \Drupal\jsonapi\JsonApiResource\ResourceObject $relationship_context
-   *   The JSON:API resource object context of the relationship.
-   * @param string $relationship_field_name
-   *   The internal relationship field name.
-   *
-   * @return array
-   *   The relationship's links.
-   */
-  public static function getRelationshipLinks(ResourceObject $relationship_context, $relationship_field_name) {
-    $resource_type = $relationship_context->getResourceType();
-    if ($resource_type->isInternal() || !$resource_type->isLocatable()) {
-      return [];
-    }
-    $public_field_name = $resource_type->getPublicName($relationship_field_name);
-    $relationship_route_name = Routes::getRouteName($resource_type, "$public_field_name.relationship.get");
-    $links = [
-      'self' => Url::fromRoute($relationship_route_name, ['entity' => $relationship_context->getId()]),
-    ];
-    if (static::hasNonInternalResourceType($resource_type->getRelatableResourceTypesByField($public_field_name))) {
-      $related_route_name = Routes::getRouteName($resource_type, "$public_field_name.related");
-      $links['related'] = Url::fromRoute($related_route_name, ['entity' => $relationship_context->getId()]);
-    }
-    if ($resource_type->isVersionable()) {
-      $version_query_parameter = [JsonApiSpec::VERSION_QUERY_PARAMETER => $relationship_context->getVersionIdentifier()];
-      $links['self']->setOption('query', $version_query_parameter);
-      if (isset($links['related'])) {
-        $links['related']->setOption('query', $version_query_parameter);
-      }
-    }
-    return $links;
-  }
-
-  /**
-   * Determines if a given list of resource types contains a non-internal type.
-   *
-   * @param \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types
-   *   The JSON:API resource types to evaluate.
-   *
-   * @return bool
-   *   FALSE if every resource type is internal, TRUE otherwise.
-   */
-  protected static function hasNonInternalResourceType(array $resource_types) {
-    foreach ($resource_types as $resource_type) {
-      if (!$resource_type->isInternal()) {
-        return TRUE;
-      }
-    }
-    return FALSE;
-  }
-
-}
diff --git a/core/modules/jsonapi/src/Normalizer/JsonApiDocumentTopLevelNormalizer.php b/core/modules/jsonapi/src/Normalizer/JsonApiDocumentTopLevelNormalizer.php
index 7f0dccb8670fd8e263fccc592e515533474988df..b6abaf7eaa80f2d00ec791a097d6bb8699c716fe 100644
--- a/core/modules/jsonapi/src/Normalizer/JsonApiDocumentTopLevelNormalizer.php
+++ b/core/modules/jsonapi/src/Normalizer/JsonApiDocumentTopLevelNormalizer.php
@@ -7,10 +7,8 @@
 use Drupal\Component\Uuid\Uuid;
 use Drupal\Core\Cache\CacheableMetadata;
 use Drupal\Core\Entity\EntityTypeManagerInterface;
-use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
 use Drupal\jsonapi\JsonApiResource\ErrorCollection;
 use Drupal\jsonapi\JsonApiResource\OmittedData;
-use Drupal\jsonapi\JsonApiResource\ResourceObject;
 use Drupal\jsonapi\JsonApiSpec;
 use Drupal\jsonapi\Normalizer\Value\CacheableOmission;
 use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel;
@@ -190,13 +188,7 @@ public function normalize($object, $format = NULL, array $context = []) {
     }
     else {
       // Add data.
-      // @todo: remove this if-else and just call $this->serializer->normalize($data...) in https://www.drupal.org/project/jsonapi/issues/3036285.
-      if ($data instanceof EntityReferenceFieldItemListInterface) {
-        $document['data'] = $this->normalizeEntityReferenceFieldItemList($object, $format, $context);
-      }
-      else {
-        $document['data'] = $this->serializer->normalize($data, $format, $context);
-      }
+      $document['data'] = $this->serializer->normalize($data, $format, $context);
       // Add includes.
       $document['included'] = $this->serializer->normalize($object->getIncludes(), $format, $context)->omitIfEmpty();
       // Add omissions and metadata.
@@ -240,32 +232,6 @@ protected function normalizeErrorDocument(JsonApiDocumentTopLevel $document, $fo
     return new CacheableNormalization($cacheability, $errors);
   }
 
-  /**
-   * Normalizes an entity reference field, i.e. a relationship document.
-   *
-   * @param \Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel $document
-   *   The document to normalize.
-   * @param string $format
-   *   The normalization format.
-   * @param array $context
-   *   The normalization context.
-   *
-   * @return \Drupal\jsonapi\Normalizer\Value\CacheableNormalization
-   *   The normalized document.
-   *
-   * @todo: remove this in https://www.drupal.org/project/jsonapi/issues/3036285.
-   */
-  protected function normalizeEntityReferenceFieldItemList(JsonApiDocumentTopLevel $document, $format, array $context = []) {
-    $data = $document->getData();
-    $parent_entity = $data->getEntity();
-    $resource_type = $this->resourceTypeRepository->get($parent_entity->getEntityTypeId(), $parent_entity->bundle());
-    $context['resource_object'] = ResourceObject::createFromEntity($resource_type, $parent_entity);
-    $normalized_relationship = $this->serializer->normalize($data, $format, $context);
-    assert($normalized_relationship instanceof CacheableNormalization);
-    unset($context['resource_object']);
-    return new CacheableNormalization($normalized_relationship, $normalized_relationship->getNormalization()['data']);
-  }
-
   /**
    * Normalizes omitted data into a set of omission links.
    *
diff --git a/core/modules/jsonapi/src/Normalizer/RelationshipNormalizer.php b/core/modules/jsonapi/src/Normalizer/RelationshipNormalizer.php
new file mode 100644
index 0000000000000000000000000000000000000000..53354af0b6a22fc30b80d9237e8b2d3eacc8d8a5
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/RelationshipNormalizer.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\jsonapi\JsonApiResource\Relationship;
+use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
+
+/**
+ * Normalizes a JSON:API relationship object.
+ *
+ * @internal
+ */
+class RelationshipNormalizer extends NormalizerBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $supportedInterfaceOrClass = Relationship::class;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function normalize($object, $format = NULL, array $context = []) {
+    assert($object instanceof Relationship);
+    return CacheableNormalization::aggregate([
+      'data' => $this->serializer->normalize($object->getData(), $format, $context),
+      'links' => $this->serializer->normalize($object->getLinks(), $format, $context)->omitIfEmpty(),
+      'meta' => CacheableNormalization::permanent($object->getMeta())->omitIfEmpty(),
+    ]);
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/ResourceObjectNormalizer.php b/core/modules/jsonapi/src/Normalizer/ResourceObjectNormalizer.php
index 5fc9105b80ecf15f1adf6ad92788a790187f5fde..10a5e4cca56b8e2f91074713b0f2589cb517dfec 100644
--- a/core/modules/jsonapi/src/Normalizer/ResourceObjectNormalizer.php
+++ b/core/modules/jsonapi/src/Normalizer/ResourceObjectNormalizer.php
@@ -3,8 +3,10 @@
 namespace Drupal\jsonapi\Normalizer;
 
 use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
 use Drupal\Core\Field\FieldItemListInterface;
 use Drupal\jsonapi\EventSubscriber\ResourceObjectNormalizationCacher;
+use Drupal\jsonapi\JsonApiResource\Relationship;
 use Drupal\jsonapi\JsonApiResource\ResourceObject;
 use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
 use Drupal\jsonapi\Normalizer\Value\CacheableOmission;
@@ -176,7 +178,17 @@ protected function serializeField($field, array $context, $format) {
       if (!$field_access_result->isAllowed()) {
         return new CacheableOmission(CacheableMetadata::createFromObject($field_access_result));
       }
-      $normalized_field = $this->serializer->normalize($field, $format, $context);
+      if ($field instanceof EntityReferenceFieldItemListInterface) {
+        // Build the relationship object based on the entity reference and
+        // normalize that object instead.
+        assert(!empty($context['resource_object']) && $context['resource_object'] instanceof ResourceObject);
+        $resource_object = $context['resource_object'];
+        $relationship = Relationship::createFromEntityReferenceField($resource_object, $field);
+        $normalized_field = $this->serializer->normalize($relationship, $format, $context);
+      }
+      else {
+        $normalized_field = $this->serializer->normalize($field, $format, $context);
+      }
       assert($normalized_field instanceof CacheableNormalization);
       return $normalized_field->withCacheableDependency(CacheableMetadata::createFromObject($field_access_result));
     }
diff --git a/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php b/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php
index 006e2b414718d59e2b4910392a32b5fe90afbbc6..a173144f2267e7439aadf50e0bf2ef5eb927ebed 100644
--- a/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php
+++ b/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php
@@ -1719,7 +1719,6 @@ protected function getExpectedGetRelationshipDocument($relationship_field_name,
     if (static::$resourceTypeIsVersionable) {
       assert($entity instanceof RevisionableInterface);
       $version_query = ['resourceVersion' => 'id:' . $entity->getRevisionId()];
-      $self_link->setOption('query', $version_query);
       $related_link->setOption('query', $version_query);
     }
     $data = $this->getExpectedGetRelationshipDocumentData($relationship_field_name, $entity);
@@ -3094,6 +3093,7 @@ public function testRevisions() {
       $actual_response = $this->request('GET', $relationship_url, $request_options);
       $expected_response = $this->getExpectedGetRelationshipResponse('field_jsonapi_test_entity_ref', $revision);
       $expected_document = $expected_response->getResponseData();
+      $expected_document['links']['self']['href'] = $relationship_url->setAbsolute()->toString();
       $expected_cacheability = $expected_response->getCacheableMetadata();
       $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS');
       // Request the related route.
diff --git a/core/modules/jsonapi/tests/src/Kernel/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php b/core/modules/jsonapi/tests/src/Kernel/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php
index 99bd557e41775119344eb2ade06491972934a10b..0d2f093f0189234726ca090cdc257ea38d786196 100644
--- a/core/modules/jsonapi/tests/src/Kernel/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php
+++ b/core/modules/jsonapi/tests/src/Kernel/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php
@@ -254,8 +254,8 @@ public function testNormalize() {
         'id' => NodeType::load('article')->uuid(),
       ],
       'links' => [
-        'self' => ['href' => Url::fromUri('internal:/jsonapi/node/article/' . $this->node->uuid() . '/relationships/node_type', ['query' => ['resourceVersion' => 'id:' . $this->node->getRevisionId()]])->setAbsolute()->toString(TRUE)->getGeneratedUrl()],
         'related' => ['href' => Url::fromUri('internal:/jsonapi/node/article/' . $this->node->uuid() . '/node_type', ['query' => ['resourceVersion' => 'id:' . $this->node->getRevisionId()]])->setAbsolute()->toString(TRUE)->getGeneratedUrl()],
+        'self' => ['href' => Url::fromUri('internal:/jsonapi/node/article/' . $this->node->uuid() . '/relationships/node_type', ['query' => ['resourceVersion' => 'id:' . $this->node->getRevisionId()]])->setAbsolute()->toString(TRUE)->getGeneratedUrl()],
       ],
     ], $normalized['data']['relationships']['node_type']);
     $this->assertTrue(!isset($normalized['data']['attributes']['created']));
diff --git a/core/modules/jsonapi/tests/src/Kernel/Normalizer/EntityReferenceFieldNormalizerTest.php b/core/modules/jsonapi/tests/src/Kernel/Normalizer/RelationshipNormalizerTest.php
similarity index 94%
rename from core/modules/jsonapi/tests/src/Kernel/Normalizer/EntityReferenceFieldNormalizerTest.php
rename to core/modules/jsonapi/tests/src/Kernel/Normalizer/RelationshipNormalizerTest.php
index 96e21ef81f4c2364df7747b21162c4d885d8396f..f77007177017f7f38afb93835057533e072f3020 100644
--- a/core/modules/jsonapi/tests/src/Kernel/Normalizer/EntityReferenceFieldNormalizerTest.php
+++ b/core/modules/jsonapi/tests/src/Kernel/Normalizer/RelationshipNormalizerTest.php
@@ -8,8 +8,9 @@
 use Drupal\field\Entity\FieldConfig;
 use Drupal\field\Entity\FieldStorageConfig;
 use Drupal\file\Entity\File;
+use Drupal\jsonapi\JsonApiResource\Relationship;
 use Drupal\jsonapi\JsonApiResource\ResourceObject;
-use Drupal\jsonapi\Normalizer\EntityReferenceFieldNormalizer;
+use Drupal\jsonapi\Normalizer\RelationshipNormalizer;
 use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
 use Drupal\node\Entity\Node;
 use Drupal\node\Entity\NodeType;
@@ -17,12 +18,12 @@
 use Drupal\user\Entity\User;
 
 /**
- * @coversDefaultClass \Drupal\jsonapi\Normalizer\EntityReferenceFieldNormalizer
+ * @coversDefaultClass \Drupal\jsonapi\Normalizer\RelationshipNormalizer
  * @group jsonapi
  *
  * @internal
  */
-class EntityReferenceFieldNormalizerTest extends JsonapiKernelTestBase {
+class RelationshipNormalizerTest extends JsonapiKernelTestBase {
 
   /**
    * {@inheritdoc}
@@ -137,7 +138,7 @@ protected function setUp() {
 
     // Set up the test dependencies.
     $this->referencingResourceType = $this->container->get('jsonapi.resource_type.repository')->get('node', 'referencer');
-    $this->normalizer = new EntityReferenceFieldNormalizer();
+    $this->normalizer = new RelationshipNormalizer();
     $this->normalizer->setSerializer($this->container->get('jsonapi.serializer'));
   }
 
@@ -173,11 +174,10 @@ public function testNormalize($entity_property_names, $field_name, $expected) {
       }
       return $value;
     }, $entity_property_names);
+    $resource_object = ResourceObject::createFromEntity($this->referencingResourceType, $this->referencer);
+    $relationship = Relationship::createFromEntityReferenceField($resource_object, $resource_object->getField($field_name));
     // Normalize.
-    $actual = $this->normalizer->normalize($this->referencer->{$field_name}, 'api_json', [
-      'account' => $this->account,
-      'resource_object' => ResourceObject::createFromEntity($this->referencingResourceType, $this->referencer),
-    ]);
+    $actual = $this->normalizer->normalize($relationship, 'api_json');
     // Assert.
     assert($actual instanceof CacheableNormalization);
     $this->assertEquals($expected, $actual->getNormalization());