diff --git a/core/modules/field/tests/src/Kernel/FieldTypePluginManagerTest.php b/core/modules/field/tests/src/Kernel/FieldTypePluginManagerTest.php
index 80653074a60a5f2e8649a7f7b9631a355b9e8959..d8cb5110ffad416fe76fa4771086db1bedffb0c8 100644
--- a/core/modules/field/tests/src/Kernel/FieldTypePluginManagerTest.php
+++ b/core/modules/field/tests/src/Kernel/FieldTypePluginManagerTest.php
@@ -98,6 +98,9 @@ public function testMainProperty() {
     foreach ($field_type_manager->getDefinitions() as $plugin_id => $definition) {
       $class = $definition['class'];
       $property = $class::mainPropertyName();
+      if ($property === NULL) {
+        continue;
+      }
       $storage_definition = BaseFieldDefinition::create($plugin_id);
       $property_definitions = $class::propertyDefinitions($storage_definition);
       $properties = implode(', ', array_keys($property_definitions));
diff --git a/core/modules/jsonapi/src/Normalizer/FieldItemNormalizer.php b/core/modules/jsonapi/src/Normalizer/FieldItemNormalizer.php
index f3afb429927ea88762d5dfa3bd48a39c69df1835..9409aab03e6088195f312d8df1e98125b96d5391 100644
--- a/core/modules/jsonapi/src/Normalizer/FieldItemNormalizer.php
+++ b/core/modules/jsonapi/src/Normalizer/FieldItemNormalizer.php
@@ -60,6 +60,7 @@ public function __construct(EntityTypeManagerInterface $entity_type_manager) {
    * catch it, and pass it to the value object that JSON:API uses.
    */
   public function normalize($field_item, $format = NULL, array $context = []) {
+    assert($field_item instanceof FieldItemInterface);
     /** @var \Drupal\Core\TypedData\TypedDataInterface $property */
     $values = [];
     $context[CacheableNormalizerInterface::SERIALIZATION_CONTEXT_CACHEABILITY] = new CacheableMetadata();
@@ -71,7 +72,8 @@ public function normalize($field_item, $format = NULL, array $context = []) {
         $values[$property_name] = $this->serializer->normalize($property, $format, $context);
       }
       // Flatten if there is only a single property to normalize.
-      $values = static::rasterizeValueRecursive(count($field_properties) == 1 ? reset($values) : $values);
+      $flatten = count($field_properties) === 1 && $field_item::mainPropertyName() !== NULL;
+      $values = static::rasterizeValueRecursive($flatten ? reset($values) : $values);
     }
     else {
       $values = $field_item->getValue();
diff --git a/core/modules/jsonapi/tests/src/Kernel/Normalizer/FieldItemNormalizerTest.php b/core/modules/jsonapi/tests/src/Kernel/Normalizer/FieldItemNormalizerTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..cb40e712b5d5f92bf075a006901a6be19bf6d645
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Kernel/Normalizer/FieldItemNormalizerTest.php
@@ -0,0 +1,129 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Kernel\Normalizer;
+
+use Drupal\Core\Field\BaseFieldDefinition;
+use Drupal\Core\Field\FieldItemInterface;
+use Drupal\entity_test\Entity\EntityTest;
+use Drupal\jsonapi\Normalizer\FieldItemNormalizer;
+use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
+use Drupal\Tests\jsonapi\Kernel\JsonapiKernelTestBase;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Normalizer\FieldItemNormalizer
+ * @group jsonapi
+ *
+ * @internal
+ */
+class FieldItemNormalizerTest extends JsonapiKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'system',
+    'user',
+    'link',
+    'entity_test',
+    'serialization',
+  ];
+
+  /**
+   * The normalizer.
+   *
+   * @var \Drupal\jsonapi\Normalizer\FieldItemNormalizer
+   */
+  private $normalizer;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $etm = $this->container->get('entity_type.manager');
+    $this->normalizer = new FieldItemNormalizer($etm);
+    $this->normalizer->setSerializer($this->container->get('jsonapi.serializer'));
+
+    $definitions = [];
+    $definitions['links'] = BaseFieldDefinition::create('link')->setLabel('Links');
+    $definitions['internal_property_value'] = BaseFieldDefinition::create('single_internal_property_test')->setLabel('Internal property');
+    $definitions['no_main_property_value'] = BaseFieldDefinition::create('map')->setLabel('No main property');
+    $this->container->get('state')->set('entity_test.additional_base_field_definitions', $definitions);
+    $etm->clearCachedDefinitions();
+  }
+
+  /**
+   * Tests a field item that has no properties.
+   *
+   * @covers ::normalize
+   */
+  public function testNormalizeFieldItemWithoutProperties(): void {
+    $item = $this->prophesize(FieldItemInterface::class);
+    $item->getProperties(TRUE)->willReturn([]);
+    $item->getValue()->willReturn('Direct call to getValue');
+
+    $result = $this->normalizer->normalize($item->reveal(), 'api_json');
+    assert($result instanceof CacheableNormalization);
+    $this->assertSame('Direct call to getValue', $result->getNormalization());
+  }
+
+  /**
+   * Tests normalizing field item.
+   */
+  public function testNormalizeFieldItem(): void {
+    $entity = EntityTest::create([
+      'name' => 'Test entity',
+      'links' => [
+        [
+          'uri' => 'https://www.drupal.org',
+          'title' => 'Drupal.org',
+          'options' => [
+            'query' => 'foo=bar',
+          ],
+        ],
+      ],
+      'internal_property_value' => [
+        [
+          'value' => 'Internal property testing!',
+        ],
+      ],
+      'no_main_property_value' => [
+        [
+          'value' => 'No main property testing!',
+        ],
+      ],
+    ]);
+
+    // Verify a field with one property is flattened.
+    $result = $this->normalizer->normalize($entity->get('name')->first());
+    assert($result instanceof CacheableNormalization);
+    $this->assertEquals('Test entity', $result->getNormalization());
+
+    // Verify a field with multiple public properties has all of them returned.
+    $result = $this->normalizer->normalize($entity->get('links')->first());
+    assert($result instanceof CacheableNormalization);
+    $this->assertEquals([
+      'uri' => 'https://www.drupal.org',
+      'title' => 'Drupal.org',
+      'options' => [
+        'query' => 'foo=bar',
+      ],
+    ], $result->getNormalization());
+
+    // Verify a field with one public property and one internal only returns the
+    // public property, and is flattened.
+    $result = $this->normalizer->normalize($entity->get('internal_property_value')->first());
+    assert($result instanceof CacheableNormalization);
+    // Property `internal_value` will not exist.
+    $this->assertEquals('Internal property testing!', $result->getNormalization());
+
+    // Verify a field with one public property but no main property is not
+    // flattened.
+    $result = $this->normalizer->normalize($entity->get('no_main_property_value')->first());
+    assert($result instanceof CacheableNormalization);
+    $this->assertEquals([
+      'value' => 'No main property testing!',
+    ], $result->getNormalization());
+  }
+
+}
diff --git a/core/modules/system/tests/modules/entity_test/src/Plugin/Field/FieldType/SingleInternalPropertyTestFieldItem.php b/core/modules/system/tests/modules/entity_test/src/Plugin/Field/FieldType/SingleInternalPropertyTestFieldItem.php
new file mode 100644
index 0000000000000000000000000000000000000000..30add62df1d0a04b3a48d5448e9ac2f2f0e8df3f
--- /dev/null
+++ b/core/modules/system/tests/modules/entity_test/src/Plugin/Field/FieldType/SingleInternalPropertyTestFieldItem.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Drupal\entity_test\Plugin\Field\FieldType;
+
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
+use Drupal\Core\Field\Plugin\Field\FieldType\StringItem;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Drupal\Core\TypedData\DataDefinition;
+use Drupal\entity_test\TypedData\ComputedString;
+
+/**
+ * Defines the 'Single Internal Property' entity test field type.
+ *
+ * This is based off of the InternalPropertyTestFieldItem test field item type,
+ * but only adds a single computed property. This tests that fields with a main
+ * property name and one internal value are flattened.
+ *
+ * @see \Drupal\entity_test\Plugin\Field\FieldType\InternalPropertyTestFieldItem
+ *
+ * @FieldType(
+ *   id = "single_internal_property_test",
+ *   label = @Translation("Single Internal Property (test)"),
+ *   description = @Translation("A field containing one string, from which one internal string is computed."),
+ *   category = @Translation("Test"),
+ *   default_widget = "string_textfield",
+ *   default_formatter = "string"
+ * )
+ */
+class SingleInternalPropertyTestFieldItem extends StringItem {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function propertyDefinitions(FieldStorageDefinitionInterface $field_definition) {
+    $properties = parent::propertyDefinitions($field_definition);
+
+    // Add a computed property that is internal.
+    $properties['internal_value'] = DataDefinition::create('string')
+      ->setLabel(new TranslatableMarkup('Computed string, internal property'))
+      ->setComputed(TRUE)
+      ->setClass(ComputedString::class);
+    return $properties;
+  }
+
+}