diff --git a/config/schema/experience_builder.schema.yml b/config/schema/experience_builder.schema.yml
index f25ec15bf15fc4d4e2389c18cb75f75f7cfdffa7..19722e2bc5917aadd9fef2d217b1a15cb2f051f2 100644
--- a/config/schema/experience_builder.schema.yml
+++ b/config/schema/experience_builder.schema.yml
@@ -524,6 +524,9 @@ experience_builder.content_template.*.*.*:
       constraints:
         # @todo Use `ConfigExists` once https://www.drupal.org/project/drupal/issues/3518273 is
         #   fixed in core.
+        # @todo Core entity types ship with a `full` view mode, but it's not guaranteed to
+        #   exist for every content entity type. Ensure XB creates it when opting a content
+        #   entity bundle into XB rendering in https://www.drupal.org/i/3518248.
         BetterConfigExists:
           prefix: 'core.entity_view_mode.[%parent.content_entity_type_id].'
     component_tree:
@@ -547,3 +550,51 @@ experience_builder.content_template.*.*.*:
               - Drupal\Core\Block\TitleBlockPluginInterface
               - Drupal\Core\Block\MessagesBlockPluginInterface
             presence: ~
+    exposed_slots:
+      type: sequence
+      # This is ordered by key because the keys are the machine names assigned to the
+      # exposed slots. These machine names are, essentially, "aliases" for a particular
+      # slot in a particular component in the component tree.
+      orderby: key
+      # If this sequence is empty, the site builder that crafted this template has not
+      # exposed any slots to content creators, which means there's nothing for content
+      # creators to do other than enter values for the content entity form (under the
+      # "Page Data" tab in the designs).
+      sequence:
+        type: mapping
+        constraints:
+          # Exposed slots are only supported by the "canonical" view mode, which is generally
+          # assumed to be `full` (see \Drupal\Core\Entity\Controller\EntityViewController::view()
+          # for where core makes this assumption). Example:
+          #
+          # exposed_slots:
+          #  profile_bio:
+          #    label: 'Profile Bio!'
+          #    component_uuid: 28bcab26-e434-4ad4-9eaf-0520bdb32fcc
+          #    slot_name: column_two
+          #  intro:
+          #    label: 'Intro'
+          #    component_uuid: 98bcab26-e434-4ad4-9eaf-0520bdb32fcc
+          #    slot_name: body
+          ValidExposedSlot: full
+        mapping:
+          # The UUID of the component that contains the slot being exposed. This CANNOT
+          # be the same as \Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure::ROOT_UUID,
+          # because that would mean the entire template could be overridden by an individual
+          # content entity, which is not an appropriate use of a content template (XB has
+          # the `Page` content entity type for that).
+          component_uuid:
+            type: uuid
+            label: 'UUID of the component instance that contains the exposed slot'
+          slot_name:
+            # @todo Add constraint in https://www.drupal.org/project/experience_builder/issues/3519891
+            type: string
+            label: 'The machine name of the component slot being exposed'
+          label:
+            type: required_label
+            label: 'A human-readable label for the exposed slot'
+      constraints:
+        SequenceKeysMatchRegex:
+          # Note: this is a subset of what slot names are allowed by SDCs: the machine names of XB's exposed content template slots must be simpler.
+          pattern: '/^[a-z0-9_]+$/'
+          message: "The %value key is not a valid machine name."
diff --git a/src/ComponentSource/ComponentSourceInterface.php b/src/ComponentSource/ComponentSourceInterface.php
index 15b9a0982e343c5555419fb3bc9078ee54169f5d..b2fb824699866ad53c5a9f42ead4259d0b9dae33 100644
--- a/src/ComponentSource/ComponentSourceInterface.php
+++ b/src/ComponentSource/ComponentSourceInterface.php
@@ -31,11 +31,15 @@ use Symfony\Component\Validator\ConstraintViolationListInterface;
  * - a block plugin component source — which renders the a block and needs
  *   settings for the block plugin
  *
+ * Not all component sources support slots. A source that supports slots should
+ * implement \Drupal\experience_builder\ComponentSource\ComponentSourceWithSlotsInterface.
+ *
  * @phpstan-import-type PropSourceArray from \Drupal\experience_builder\PropSource\PropSourceBase
  *
  * @see \Drupal\experience_builder\Attribute\ComponentSource
  * @see \Drupal\experience_builder\ComponentSource\ComponentSourceBase
  * @see \Drupal\experience_builder\ComponentSource\ComponentSourceManager
+ * @see \Drupal\experience_builder\ComponentSource\ComponentSourceWithSlotsInterface
  */
 interface ComponentSourceInterface extends PluginInspectionInterface, DerivativeInspectionInterface, ConfigurableInterface, PluginFormInterface, DependentPluginInterface, ContextAwarePluginInterface {
 
diff --git a/src/Entity/ContentTemplate.php b/src/Entity/ContentTemplate.php
index 2672b4beb7934d65388b2bf4e66000dae9fcbc4e..f3b6bf85ef8c70c4bab5a2128e0ff2036ed2ff9b 100644
--- a/src/Entity/ContentTemplate.php
+++ b/src/Entity/ContentTemplate.php
@@ -53,6 +53,7 @@ use Drupal\experience_builder\Plugin\Field\FieldType\ComponentTreeItemInstantiat
     'content_entity_type_bundle',
     'content_entity_type_view_mode',
     'component_tree',
+    'exposed_slots',
   ],
 )]
 final class ContentTemplate extends ConfigEntityBase implements ComponentTreeEntityInterface, EntityViewDisplayInterface {
@@ -68,7 +69,7 @@ final class ContentTemplate extends ConfigEntityBase implements ComponentTreeEnt
    *
    * @see \Drupal\experience_builder\Plugin\Validation\Constraint\StringPartsConstraint
    */
-  protected string $id;
+  protected ?string $id;
 
   /**
    * Entity type to be displayed.
@@ -98,6 +99,13 @@ final class ContentTemplate extends ConfigEntityBase implements ComponentTreeEnt
    */
   protected ?array $component_tree;
 
+  /**
+   * The exposed slots.
+   *
+   * @var ?array<string, array{'component_uuid': string, 'slot_name': string, 'label': string}>
+   */
+  protected ?array $exposed_slots = [];
+
   /**
    * Tries to load a template for a particular entity, in a specific view mode.
    *
diff --git a/src/Plugin/Validation/Constraint/SequenceKeysMatchRegexConstraint.php b/src/Plugin/Validation/Constraint/SequenceKeysMatchRegexConstraint.php
new file mode 100644
index 0000000000000000000000000000000000000000..8004db2ee041d1a7b0bd7208da429e38e4bf80de
--- /dev/null
+++ b/src/Plugin/Validation/Constraint/SequenceKeysMatchRegexConstraint.php
@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\experience_builder\Plugin\Validation\Constraint;
+
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Drupal\Core\Validation\Attribute\Constraint;
+use Symfony\Component\Validator\Constraints\Regex;
+
+#[Constraint(
+  id: self::PLUGIN_ID,
+  label: new TranslatableMarkup('Sequence keys match regex', options: ['context' => 'Validation']),
+)]
+final class SequenceKeysMatchRegexConstraint extends Regex {
+
+  public const string PLUGIN_ID = 'SequenceKeysMatchRegex';
+
+}
diff --git a/src/Plugin/Validation/Constraint/SequenceKeysMatchRegexConstraintValidator.php b/src/Plugin/Validation/Constraint/SequenceKeysMatchRegexConstraintValidator.php
new file mode 100644
index 0000000000000000000000000000000000000000..07f4b173798df74649f1c9e2b3299a05f6c50916
--- /dev/null
+++ b/src/Plugin/Validation/Constraint/SequenceKeysMatchRegexConstraintValidator.php
@@ -0,0 +1,36 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\experience_builder\Plugin\Validation\Constraint;
+
+use Symfony\Component\Validator\Constraint;
+use Symfony\Component\Validator\Constraints\RegexValidator;
+use Symfony\Component\Validator\Exception\UnexpectedTypeException;
+
+/**
+ * Validates the SequenceKeysMatchRegex constraint.
+ */
+final class SequenceKeysMatchRegexConstraintValidator extends RegexValidator {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validate(mixed $value, Constraint $constraint): void {
+    // If the value isn't NULL, it needs to be an associative array.
+    if ($value === NULL) {
+      return;
+    }
+    if (!is_array($value)) {
+      throw new UnexpectedTypeException($value, 'array');
+    }
+    if ($value && array_is_list($value)) {
+      throw new UnexpectedTypeException($value, 'associative array');
+    }
+
+    foreach (array_keys($value) as $key) {
+      parent::validate($key, $constraint);
+    }
+  }
+
+}
diff --git a/src/Plugin/Validation/Constraint/ValidExposedSlotConstraint.php b/src/Plugin/Validation/Constraint/ValidExposedSlotConstraint.php
new file mode 100644
index 0000000000000000000000000000000000000000..a71b7e7682b2999a33395c13554ea6620b109ee0
--- /dev/null
+++ b/src/Plugin/Validation/Constraint/ValidExposedSlotConstraint.php
@@ -0,0 +1,43 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\experience_builder\Plugin\Validation\Constraint;
+
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Drupal\Core\Validation\Attribute\Constraint;
+use Symfony\Component\Validator\Constraint as SymfonyConstraint;
+
+#[Constraint(
+  id: self::PLUGIN_ID,
+  label: new TranslatableMarkup('Validates an exposed slot', [], ['context' => 'Validation']),
+)]
+final class ValidExposedSlotConstraint extends SymfonyConstraint {
+
+  public const string PLUGIN_ID = 'ValidExposedSlot';
+
+  /**
+   * The view mode in which the exposed slots will be used.
+   *
+   * @var string
+   */
+  public string $viewMode = 'full';
+
+  public string $rootExposedMessage = 'Exposing the full component tree is not allowed.';
+
+  public string $unknownComponentMessage = 'The component %id does not exist in the tree.';
+
+  public string $slotNotEmptyMessage = 'The %slot slot must be empty.';
+
+  public string $undefinedSlotMessage = 'The component %id does not have a %slot slot.';
+
+  public string $viewModeMismatchMessage = 'Exposed slots are only allowed in the %mode view mode.';
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDefaultOption(): string {
+    return 'viewMode';
+  }
+
+}
diff --git a/src/Plugin/Validation/Constraint/ValidExposedSlotConstraintValidator.php b/src/Plugin/Validation/Constraint/ValidExposedSlotConstraintValidator.php
new file mode 100644
index 0000000000000000000000000000000000000000..355859e6b09b555035fbadc9da24ff2c36f609ed
--- /dev/null
+++ b/src/Plugin/Validation/Constraint/ValidExposedSlotConstraintValidator.php
@@ -0,0 +1,79 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\experience_builder\Plugin\Validation\Constraint;
+
+use Drupal\experience_builder\ComponentSource\ComponentSourceWithSlotsInterface;
+use Drupal\experience_builder\Entity\ContentTemplate;
+use Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure;
+use Symfony\Component\Validator\Constraint;
+use Symfony\Component\Validator\ConstraintValidator;
+use Symfony\Component\Validator\Exception\UnexpectedTypeException;
+
+/**
+ * Validates the `ValidExposedSlot` constraint.
+ */
+final class ValidExposedSlotConstraintValidator extends ConstraintValidator {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validate(mixed $value, Constraint $constraint): void {
+    assert($constraint instanceof ValidExposedSlotConstraint);
+
+    assert(is_array($value), new UnexpectedTypeException($value, 'array'));
+
+    // The root UUID (i.e., the entire component tree) cannot be exposed.
+    if ($value['component_uuid'] === ComponentTreeStructure::ROOT_UUID) {
+      $this->context->addViolation($constraint->rootExposedMessage);
+      return;
+    }
+
+    $root = $this->context->getRoot()->getEntity();
+    assert($root instanceof ContentTemplate);
+    /** @var \Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure $tree */
+    $tree = $root->getComponentTree()->get('tree');
+
+    $slot_exists = FALSE;
+    try {
+      $source = $tree->getComponentSource($value['component_uuid']);
+      if ($source instanceof ComponentSourceWithSlotsInterface) {
+        $slot_exists = array_key_exists($value['slot_name'], $source->getSlotDefinitions());
+      }
+    }
+    catch (\OutOfRangeException) {
+      // The component that contains the exposed slot isn't in the tree at all,
+      // so there's nothing else for us to do.
+      $this->context->addViolation($constraint->unknownComponentMessage, [
+        '%id' => $value['component_uuid'],
+      ]);
+      return;
+    }
+
+    // The component has to actually define the slot being exposed.
+    if ($slot_exists === FALSE) {
+      $this->context->addViolation($constraint->undefinedSlotMessage, [
+        '%id' => $value['component_uuid'],
+        '%slot' => $value['slot_name'],
+      ]);
+    }
+
+    // The exposed slot has to be empty.
+    foreach ($tree->getSlotChildrenDepthFirst() as $parent_uuid => $child) {
+      if ($parent_uuid === $value['component_uuid']) {
+        $this->context->addViolation($constraint->slotNotEmptyMessage, [
+          '%slot' => $child['slot'],
+        ]);
+        break;
+      }
+    }
+
+    if ($root->getMode() !== $constraint->viewMode) {
+      $this->context->addViolation($constraint->viewModeMismatchMessage, [
+        '%mode' => $constraint->viewMode,
+      ]);
+    }
+  }
+
+}
diff --git a/tests/src/Kernel/Config/ContentTemplateValidationTest.php b/tests/src/Kernel/Config/ContentTemplateValidationTest.php
index 48736f4f664b33310cbf22115d31cc359138f29b..f754e9784197e04c7c71082b6b12cd94c8a6ae1b 100644
--- a/tests/src/Kernel/Config/ContentTemplateValidationTest.php
+++ b/tests/src/Kernel/Config/ContentTemplateValidationTest.php
@@ -94,6 +94,8 @@ final class ContentTemplateValidationTest extends BetterConfigEntityValidationTe
             ['uuid' => 'sdc-dynamic-bundle-field', 'component' => 'sdc.xb_test_sdc.props-no-slots'],
             // A block component.
             ['uuid' => 'block', 'component' => 'block.system_branding_block'],
+            // An SDC with a slot that can be exposed.
+            ['uuid' => 'b4937e35-ddc2-4f36-8d4c-b1cc14aaefef', 'component' => 'sdc.xb_test_sdc.props-slots'],
           ],
         ]),
         'inputs' => self::encodeXBData([
@@ -123,6 +125,13 @@ final class ContentTemplateValidationTest extends BetterConfigEntityValidationTe
             'use_site_name' => TRUE,
             'use_site_slogan' => TRUE,
           ],
+          'b4937e35-ddc2-4f36-8d4c-b1cc14aaefef' => [
+            'heading' => [
+              'sourceType' => 'static:field_item:string',
+              'value' => 'There be a slot here',
+              'expression' => 'ℹ︎string␟value',
+            ],
+          ],
         ]),
       ],
     ]);
@@ -148,6 +157,7 @@ final class ContentTemplateValidationTest extends BetterConfigEntityValidationTe
           'experience_builder.component.js.my-cta',
           'experience_builder.component.sdc.sdc_test.my-cta',
           'experience_builder.component.sdc.xb_test_sdc.props-no-slots',
+          'experience_builder.component.sdc.xb_test_sdc.props-slots',
         ],
       ],
       $this->entity->getDependencies()
@@ -159,6 +169,7 @@ final class ContentTemplateValidationTest extends BetterConfigEntityValidationTe
         'experience_builder.component.js.my-cta',
         'experience_builder.component.sdc.sdc_test.my-cta',
         'experience_builder.component.sdc.xb_test_sdc.props-no-slots',
+        'experience_builder.component.sdc.xb_test_sdc.props-slots',
         'experience_builder.js_component.my-cta',
       ],
       'module' => [
@@ -357,4 +368,114 @@ final class ContentTemplateValidationTest extends BetterConfigEntityValidationTe
     ]);
   }
 
+  public function testExposedSlotMustBeEmpty(): void {
+    assert($this->entity instanceof ContentTemplate);
+
+    // Add a component in one of the open slots.
+    $item = $this->entity->getComponentTree();
+    $tree = json_decode($item->get('tree')->getValue(), TRUE);
+    $tree['b4937e35-ddc2-4f36-8d4c-b1cc14aaefef']['the_footer'][] = [
+      'uuid' => 'greeting',
+      'component' => 'sdc.xb_test_sdc.props-no-slots',
+    ];
+    $inputs = json_decode($item->get('inputs')->getValue(), TRUE);
+    $inputs['greeting']['heading'] = [
+      'sourceType' => 'dynamic',
+      'expression' => 'ℹ︎␜entity:node:alpha␝title␞␟value',
+    ];
+    $this->entity->set('component_tree', [
+      'tree' => self::encodeXBData($tree),
+      'inputs' => self::encodeXBData($inputs),
+    ]);
+
+    $this->entity->set('exposed_slots', [
+      'filled_footer' => [
+        'component_uuid' => 'b4937e35-ddc2-4f36-8d4c-b1cc14aaefef',
+        'slot_name' => 'the_footer',
+        'label' => "Something's already here!",
+      ],
+    ]);
+    $this->assertValidationErrors([
+      'exposed_slots.filled_footer' => 'The <em class="placeholder">the_footer</em> slot must be empty.',
+    ]);
+  }
+
+  public static function providerInvalidExposedSlot(): iterable {
+    yield 'root uuid is exposed' => [
+      [
+        'not_allowed' => [
+          'component_uuid' => ComponentTreeStructure::ROOT_UUID,
+          'slot_name' => 'not-a-thing',
+          'label' => "This won't work",
+        ],
+      ],
+      [
+        'exposed_slots.not_allowed' => 'Exposing the full component tree is not allowed.',
+      ],
+    ];
+
+    yield 'component exposing the slot does not exist in the tree' => [
+      [
+        'not_a_thing' => [
+          'component_uuid' => '6348ee20-cf62-49e3-bc86-cf62abc09c74',
+          'slot_name' => 'not-a-thing',
+          'label' => "Can't expose a slot in a component we don't have!",
+        ],
+      ],
+      [
+        'exposed_slots.not_a_thing' => 'The component <em class="placeholder">6348ee20-cf62-49e3-bc86-cf62abc09c74</em> does not exist in the tree.',
+      ],
+    ];
+
+    yield 'exposed slot is not defined by the component' => [
+      [
+        'filled_footer' => [
+          'component_uuid' => 'b4937e35-ddc2-4f36-8d4c-b1cc14aaefef',
+          'slot_name' => 'not_a_real_slot',
+          'label' => "Whither this slot you speak of?",
+        ],
+      ],
+      [
+        'exposed_slots.filled_footer' => 'The component <em class="placeholder">b4937e35-ddc2-4f36-8d4c-b1cc14aaefef</em> does not have a <em class="placeholder">not_a_real_slot</em> slot.',
+      ],
+    ];
+
+    yield 'exposed slot machine name is not valid' => [
+      [
+        'not a valid exposed slot name' => [
+          'component_uuid' => 'b4937e35-ddc2-4f36-8d4c-b1cc14aaefef',
+          'slot_name' => 'the_footer',
+          'label' => "I got your footer right here",
+        ],
+      ],
+      [
+        'exposed_slots' => 'The <em class="placeholder">&quot;not a valid exposed slot name&quot;</em> key is not a valid machine name.',
+      ],
+    ];
+  }
+
+  /**
+   * @dataProvider providerInvalidExposedSlot
+   */
+  public function testInvalidExposedSlot(array $exposed_slots, array $expected_errors): void {
+    $this->entity->set('exposed_slots', $exposed_slots);
+    $this->assertValidationErrors($expected_errors);
+  }
+
+  public function testExposedSlotsOnlyAllowedInFullViewMode(): void {
+    $this->entity = $this->entity->createDuplicate();
+    $this->entity->set('content_entity_type_view_mode', 'teaser');
+    $this->entity->set('id', 'node.alpha.teaser');
+    $this->entity->set('exposed_slots', [
+      'footer_for_you' => [
+        'component_uuid' => 'b4937e35-ddc2-4f36-8d4c-b1cc14aaefef',
+        'slot_name' => 'the_footer',
+        'label' => "I got your footer right here",
+      ],
+    ]);
+    $this->assertValidationErrors([
+      'exposed_slots.footer_for_you' => 'Exposed slots are only allowed in the <em class="placeholder">full</em> view mode.',
+    ]);
+  }
+
 }