diff --git a/core/modules/ckeditor/js/plugins/drupalimage/plugin.es6.js b/core/modules/ckeditor/js/plugins/drupalimage/plugin.es6.js
index 4f37aa9b93..f3eddc6065 100644
--- a/core/modules/ckeditor/js/plugins/drupalimage/plugin.es6.js
+++ b/core/modules/ckeditor/js/plugins/drupalimage/plugin.es6.js
@@ -47,45 +47,7 @@
       return;
     }
 
-    // Override default behaviour of 'drupalunlink' command.
-    editor.getCommand('drupalunlink').on('exec', function(evt) {
-      const widget = getFocusedWidget(editor);
-
-      // Override 'drupalunlink' only when link truly belongs to the widget. If
-      // wrapped inline widget in a link, let default unlink work.
-      // @see https://dev.ckeditor.com/ticket/11814
-      if (!widget || !widget.parts.link) {
-        return;
-      }
-
-      widget.setData('link', null);
-
-      // Selection (which is fake) may not change if unlinked image in focused
-      // widget, i.e. if captioned image. Let's refresh command state manually
-      // here.
-      this.refresh(editor, editor.elementPath());
-
-      evt.cancel();
-    });
-
-    // Override default refresh of 'drupalunlink' command.
-    editor.getCommand('drupalunlink').on('refresh', function(evt) {
-      const widget = getFocusedWidget(editor);
-
-      if (!widget) {
-        return;
-      }
-
-      // Note that widget may be wrapped in a link, which
-      // does not belong to that widget (#11814).
-      this.setState(
-        widget.data.link || widget.wrapper.getAscendant('a')
-          ? CKEDITOR.TRISTATE_OFF
-          : CKEDITOR.TRISTATE_DISABLED,
-      );
-
-      evt.cancel();
-    });
+    CKEDITOR.plugins.drupallink.registerLinkableWidget('image');
   }
 
   CKEDITOR.plugins.add('drupalimage', {
diff --git a/core/modules/ckeditor/js/plugins/drupalimage/plugin.js b/core/modules/ckeditor/js/plugins/drupalimage/plugin.js
index 72180faa8e..eabb1c7404 100644
--- a/core/modules/ckeditor/js/plugins/drupalimage/plugin.js
+++ b/core/modules/ckeditor/js/plugins/drupalimage/plugin.js
@@ -21,31 +21,7 @@
       return;
     }
 
-    editor.getCommand('drupalunlink').on('exec', function (evt) {
-      var widget = getFocusedWidget(editor);
-
-      if (!widget || !widget.parts.link) {
-        return;
-      }
-
-      widget.setData('link', null);
-
-      this.refresh(editor, editor.elementPath());
-
-      evt.cancel();
-    });
-
-    editor.getCommand('drupalunlink').on('refresh', function (evt) {
-      var widget = getFocusedWidget(editor);
-
-      if (!widget) {
-        return;
-      }
-
-      this.setState(widget.data.link || widget.wrapper.getAscendant('a') ? CKEDITOR.TRISTATE_OFF : CKEDITOR.TRISTATE_DISABLED);
-
-      evt.cancel();
-    });
+    CKEDITOR.plugins.drupallink.registerLinkableWidget('image');
   }
 
   CKEDITOR.plugins.add('drupalimage', {
diff --git a/core/modules/ckeditor/js/plugins/drupallink/plugin.es6.js b/core/modules/ckeditor/js/plugins/drupallink/plugin.es6.js
index 7df1b8a9a3..c6be85c4da 100644
--- a/core/modules/ckeditor/js/plugins/drupallink/plugin.es6.js
+++ b/core/modules/ckeditor/js/plugins/drupallink/plugin.es6.js
@@ -61,6 +61,35 @@
     };
   }
 
+  const registeredLinkableWidgets = [];
+
+  /**
+   * Registers a widget name as linkable.
+   *
+   * @param {string} widgetName
+   *   The name of the widget to register as linkable.
+   */
+  function registerLinkableWidget(widgetName) {
+    registeredLinkableWidgets.push(widgetName);
+  }
+
+  /**
+   * Gets the focused widget, if one of the registered linkable widget names.
+   *
+   * @param {CKEDITOR.editor} editor
+   *   A CKEditor instance.
+   *
+   * @return {?CKEDITOR.plugins.widget}
+   *   The focused linkable widget instance, or null.
+   */
+  function getFocusedLinkableWidget(editor) {
+    const widget = editor.widgets.focused;
+    if (widget && registeredLinkableWidgets.indexOf(widget.name) !== -1) {
+      return widget;
+    }
+    return null;
+  }
+
   /**
    * Get the surrounding link element of current selection.
    *
@@ -121,9 +150,7 @@
         modes: { wysiwyg: 1 },
         canUndo: true,
         exec(editor) {
-          const drupalImageUtils = CKEDITOR.plugins.drupalimage;
-          const focusedImageWidget =
-            drupalImageUtils && drupalImageUtils.getFocusedWidget(editor);
+          const focusedLinkableWidget = getFocusedLinkableWidget(editor);
           let linkElement = getSelectedLink(editor);
 
           // Set existing values based on selected element.
@@ -133,20 +160,20 @@
           }
           // Or, if an image widget is focused, we're editing a link wrapping
           // an image widget.
-          else if (focusedImageWidget && focusedImageWidget.data.link) {
-            existingValues = CKEDITOR.tools.clone(focusedImageWidget.data.link);
+          else if (focusedLinkableWidget && focusedLinkableWidget.data.link) {
+            existingValues = CKEDITOR.tools.clone(focusedLinkableWidget.data.link);
           }
 
           // Prepare a save callback to be used upon saving the dialog.
           const saveCallback = function(returnValues) {
             // If an image widget is focused, we're not editing an independent
             // link, but we're wrapping an image widget in a link.
-            if (focusedImageWidget) {
-              focusedImageWidget.setData(
+            if (focusedLinkableWidget) {
+              focusedLinkableWidget.setData(
                 'link',
                 CKEDITOR.tools.extend(
                   returnValues.attributes,
-                  focusedImageWidget.data.link,
+                  focusedLinkableWidget.data.link,
                 ),
               );
               editor.fire('saveSnapshot');
@@ -238,6 +265,16 @@
             alwaysRemoveElement: 1,
           });
           editor.removeStyle(style);
+
+          let widget = getFocusedLinkableWidget(editor);
+
+          if (widget && widget.data.hasOwnProperty('link')) {
+            widget.setData('link', null);
+            // Selection (which is fake) may not change if unlinked image in
+            // focused widget, i.e. if captioned image. Let's refresh command
+            // state manually here.
+            this.refresh(editor, editor.elementPath());
+          }
         },
         refresh(editor, path) {
           const element =
@@ -252,6 +289,18 @@
           } else {
             this.setState(CKEDITOR.TRISTATE_DISABLED);
           }
+
+          let widget = getFocusedLinkableWidget(editor);
+
+          if (widget && widget.data.hasOwnProperty('link')) {
+            // Note that widget may be wrapped in a link, which
+            // does not belong to that widget.
+            this.setState(
+              widget.data.link || widget.wrapper.getAscendant('a')
+                ? CKEDITOR.TRISTATE_OFF
+                : CKEDITOR.TRISTATE_DISABLED,
+            );
+          }
         },
       });
 
@@ -330,5 +379,6 @@
   CKEDITOR.plugins.drupallink = {
     parseLinkAttributes: parseAttributes,
     getLinkAttributes: getAttributes,
+    registerLinkableWidget: registerLinkableWidget,
   };
 })(jQuery, Drupal, drupalSettings, CKEDITOR);
diff --git a/core/modules/ckeditor/js/plugins/drupallink/plugin.js b/core/modules/ckeditor/js/plugins/drupallink/plugin.js
index f8e2505d5b..b62291e8fc 100644
--- a/core/modules/ckeditor/js/plugins/drupallink/plugin.js
+++ b/core/modules/ckeditor/js/plugins/drupallink/plugin.js
@@ -49,6 +49,20 @@
     };
   }
 
+  var registeredLinkableWidgets = [];
+
+  function registerLinkableWidget(widgetName) {
+    registeredLinkableWidgets.push(widgetName);
+  }
+
+  function getFocusedLinkableWidget(editor) {
+    var widget = editor.widgets.focused;
+    if (widget && registeredLinkableWidgets.indexOf(widget.name) !== -1) {
+      return widget;
+    }
+    return null;
+  }
+
   function getSelectedLink(editor) {
     var selection = editor.getSelection();
     var selectedElement = selection.getSelectedElement();
@@ -88,20 +102,19 @@
         modes: { wysiwyg: 1 },
         canUndo: true,
         exec: function exec(editor) {
-          var drupalImageUtils = CKEDITOR.plugins.drupalimage;
-          var focusedImageWidget = drupalImageUtils && drupalImageUtils.getFocusedWidget(editor);
+          var focusedLinkableWidget = getFocusedLinkableWidget(editor);
           var linkElement = getSelectedLink(editor);
 
           var existingValues = {};
           if (linkElement && linkElement.$) {
             existingValues = parseAttributes(editor, linkElement);
-          } else if (focusedImageWidget && focusedImageWidget.data.link) {
-              existingValues = CKEDITOR.tools.clone(focusedImageWidget.data.link);
+          } else if (focusedLinkableWidget && focusedLinkableWidget.data.link) {
+              existingValues = CKEDITOR.tools.clone(focusedLinkableWidget.data.link);
             }
 
           var saveCallback = function saveCallback(returnValues) {
-            if (focusedImageWidget) {
-              focusedImageWidget.setData('link', CKEDITOR.tools.extend(returnValues.attributes, focusedImageWidget.data.link));
+            if (focusedLinkableWidget) {
+              focusedLinkableWidget.setData('link', CKEDITOR.tools.extend(returnValues.attributes, focusedLinkableWidget.data.link));
               editor.fire('saveSnapshot');
               return;
             }
@@ -166,6 +179,14 @@
             alwaysRemoveElement: 1
           });
           editor.removeStyle(style);
+
+          var widget = getFocusedLinkableWidget(editor);
+
+          if (widget && widget.data.hasOwnProperty('link')) {
+            widget.setData('link', null);
+
+            this.refresh(editor, editor.elementPath());
+          }
         },
         refresh: function refresh(editor, path) {
           var element = path.lastElement && path.lastElement.getAscendant('a', true);
@@ -174,6 +195,12 @@
           } else {
             this.setState(CKEDITOR.TRISTATE_DISABLED);
           }
+
+          var widget = getFocusedLinkableWidget(editor);
+
+          if (widget && widget.data.hasOwnProperty('link')) {
+            this.setState(widget.data.link || widget.wrapper.getAscendant('a') ? CKEDITOR.TRISTATE_OFF : CKEDITOR.TRISTATE_DISABLED);
+          }
         }
       });
 
@@ -244,6 +271,7 @@
 
   CKEDITOR.plugins.drupallink = {
     parseLinkAttributes: parseAttributes,
-    getLinkAttributes: getAttributes
+    getLinkAttributes: getAttributes,
+    registerLinkableWidget: registerLinkableWidget
   };
 })(jQuery, Drupal, drupalSettings, CKEDITOR);
\ No newline at end of file
diff --git a/core/modules/media/css/filter.media_embed.css b/core/modules/media/css/filter.media_embed.css
new file mode 100644
index 0000000000..89d45ca94e
--- /dev/null
+++ b/core/modules/media/css/filter.media_embed.css
@@ -0,0 +1,16 @@
+/**
+ * @file
+ * Media Embed filter: default styling for displaying Media Embeds.
+ */
+
+/* Use display table so width is only as wide as the contents. */
+.missing-media {
+  display: table;
+  background-color: #ebebeb;
+  padding: 20px;
+  text-align: center;
+}
+
+.missing-media__message {
+  font-weight: bold;
+}
diff --git a/core/modules/media/css/plugins/drupalmedia/ckeditor.drupalmedia.css b/core/modules/media/css/plugins/drupalmedia/ckeditor.drupalmedia.css
new file mode 100644
index 0000000000..22d611fa0c
--- /dev/null
+++ b/core/modules/media/css/plugins/drupalmedia/ckeditor.drupalmedia.css
@@ -0,0 +1,33 @@
+/**
+ * @file
+ * Media embed: overrides to make focus styles and alignment work in CKEditor.
+ */
+
+drupal-media {
+  display: inline-block;
+}
+
+.drupal-media-wrapper drupal-media {
+  margin: 1em;
+}
+
+.drupal-media-wrapper drupal-media > * {
+  margin: 0
+}
+
+.drupal-media-wrapper {
+  display: flex;
+  justify-content: flex-start;
+}
+
+.drupal-media-wrapper.drupal-media--align-left {
+  justify-content: flex-start;
+}
+
+.drupal-media-wrapper.drupal-media--align-right {
+  justify-content: flex-end;
+}
+
+.drupal-media-wrapper.drupal-media--align-center {
+  justify-content: center;
+}
diff --git a/core/modules/media/js/plugins/drupalmedia/plugin.es6.js b/core/modules/media/js/plugins/drupalmedia/plugin.es6.js
new file mode 100644
index 0000000000..c851057818
--- /dev/null
+++ b/core/modules/media/js/plugins/drupalmedia/plugin.es6.js
@@ -0,0 +1,211 @@
+/**
+ * @file
+ * Drupal Media embed plugin.
+ */
+
+(function (jQuery, Drupal, CKEDITOR) {
+
+  "use strict";
+
+  /**
+   * Makes embedded items linkable by integrating with the drupallink plugin.
+   *
+   * @param {CKEDITOR.editor} editor
+   *   A CKEditor instance.
+   */
+  function linkCommandIntegrator(editor) {
+    if (!editor.plugins.drupallink) {
+      return;
+    }
+
+    CKEDITOR.plugins.drupallink.registerLinkableWidget('drupalmedia');
+  }
+
+  CKEDITOR.plugins.add('drupalmedia', {
+    requires: 'widget',
+
+    beforeInit(editor) {
+      // Configure CKEditor DTD for custom drupal-media element.
+      // @see https://www.drupal.org/node/2448449#comment-9717735
+      const dtd = CKEDITOR.dtd;
+      let tagName = null;
+      // Allow text within the drupal-media tag.
+      dtd['drupal-media'] = {'#': 1};
+      // Register drupal-media element as an allowed child in each tag that can
+      // contain a div element and as an allowed child of the a tag.
+      for (tagName in dtd) {
+        if (dtd[tagName].div) {
+          dtd[tagName]['drupal-media'] = 1;
+        }
+      }
+      dtd['a']['drupal-media'] = 1;
+
+      editor.widgets.add('drupalmedia', {
+        allowedContent: 'drupal-media[data-entity-type,data-entity-uuid,data-view-mode,data-align,data-caption,alt,title]',
+        // Minimum HTML which is required by this widget to work.
+        requiredContent: 'drupal-media[data-entity-type,data-entity-uuid]',
+
+        pathName: Drupal.t('Embedded media'),
+
+        editables: {
+          caption: {
+            selector: 'figcaption',
+            allowedContent: 'a[!href]; em strong cite code br',
+            pathName: Drupal.t('Caption'),
+          }
+        },
+
+        upcast(element, data) {
+          const attributes = element.attributes;
+          // This matches the behavior of the corresponding server-side text filter plugin.
+          if (element.name !== 'drupal-media' || attributes['data-entity-type'] !== 'media' || attributes['data-entity-uuid'] === undefined) {
+            return;
+          }
+          data.attributes = CKEDITOR.tools.copy(attributes);
+          data.hasCaption = data.attributes.hasOwnProperty('data-caption');
+          data.link = null;
+          if (element.parent.name === 'a') {
+            data.link = CKEDITOR.tools.copy(element.parent.attributes);
+            // Omit CKEditor-internal attributes.
+            Object.keys(element.parent.attributes).forEach(attrName => {
+              if (attrName.indexOf('data-cke-') !== -1) {
+                delete data.link[attrName];
+              }
+            });
+          }
+          return element;
+        },
+
+        destroy() {
+          this._tearDownDynamicEditables();
+        },
+
+        data(event) {
+          if (this._previewNeedsServerSideUpdate()) {
+            editor.fire('lockSnapshot');
+            this._tearDownDynamicEditables();
+
+            this._loadPreview(widget => {
+              widget._setUpDynamicEditables();
+              editor.fire('unlockSnapshot');
+            });
+          }
+
+          // Add a class to the .cke_widget_drupalmedia wrapper for styles in
+          // ckeditor.drupalmedia.css.
+          this.element.getParent().addClass('drupal-media-wrapper');
+          // Convert data-align attribute to class so we're not applying styles
+          // to data attributes.
+          if (this.data.attributes.hasOwnProperty('data-align')) {
+            this.element.getParent().addClass('drupal-media--align-' + this.data.attributes['data-align']);
+          }
+
+          // Track the previous state to allow checking if preview needs
+          // server side update.
+          this.oldData = CKEDITOR.tools.clone(this.data);
+        },
+
+        downcast() {
+          const downcastElement = new CKEDITOR.htmlParser.element('drupal-media', this.data.attributes);
+          if (this.data.link) {
+            const link = new CKEDITOR.htmlParser.element('a', this.data.link);
+            link.add(downcastElement);
+            return link;
+          }
+          return downcastElement;
+        },
+
+        _setUpDynamicEditables() {
+          // Now that the caption is available in the DOM, make it editable.
+          if (this.initEditable('caption', this.definition.editables.caption)) {
+            const captionEditable = this.editables.caption;
+            // @see core/modules/filter/css/filter.caption.css
+            // @see ckeditor_ckeditor_css_alter()
+            captionEditable.setAttribute('data-placeholder', Drupal.t('Enter caption here'));
+            // Ensure that any changes made to the caption are persisted in the
+            // widget's data-caption attribute.
+            this.captionObserver = new MutationObserver(() => {
+              const mediaAttributes = CKEDITOR.tools.clone(this.data.attributes);
+              mediaAttributes['data-caption'] = captionEditable.getData();
+              this.setData('attributes', mediaAttributes);
+            });
+            this.captionObserver.observe(captionEditable.$, {
+              characterData: true,
+              attributes: true,
+              childList: true,
+              subtree: true,
+            });
+          }
+        },
+
+        _tearDownDynamicEditables() {
+          // If we are watching for changes to the caption, stop doing that.
+          if (this.captionObserver) {
+            this.captionObserver.disconnect();
+          }
+        },
+
+        /**
+         * Determines if the preview needs to be re-rendered by the server.
+         *
+         * @returns {boolean}
+         */
+        _previewNeedsServerSideUpdate() {
+          // When the widget is first loading, it of course needs to still get a preview!
+          if (!this.ready) {
+            return true;
+          }
+
+          return this._hashData(this.oldData) !== this._hashData(this.data);
+        },
+
+        /**
+         * Computes a hash of the data that can only be previewed by the server.
+         *
+         * @return {string}
+         */
+        _hashData(data) {
+          const dataToHash = CKEDITOR.tools.clone(data);
+          // The caption does not need rendering.
+          delete dataToHash.attributes['data-caption'];
+          // Changed link destinations do not affect the visual preview.
+          if (dataToHash.link) {
+            delete dataToHash.link.href;
+          }
+          return JSON.stringify(dataToHash);
+        },
+
+        /**
+         * Loads an media embed preview and runs a callback after insertion.
+         *
+         * @param {function} callback
+         *   A callback function that will be called after the preview has
+         *   loaded. Receives the widget instance.
+         */
+        _loadPreview(callback) {
+          jQuery.get({
+            url: Drupal.url('media/' + editor.config.drupal.format + '/preview'),
+            data: {
+              text: this.downcast().getOuterHtml(),
+            },
+            dataType: 'html',
+            success: (previewHtml) => {
+              this.element.setHtml(previewHtml);
+              callback(this);
+            },
+            error: () => {
+              let error = Drupal.t('An error occurred while trying to preview the media.');
+              let html = '<div class="messages messages--error file-upload-js-error" aria-live="polite">' + error + '</div>';
+              this.element.setHtml(html);
+            },
+          });
+        }
+      });
+    },
+
+    afterInit(editor) {
+      linkCommandIntegrator(editor);
+    },
+  });
+
+})(jQuery, Drupal, CKEDITOR);
diff --git a/core/modules/media/js/plugins/drupalmedia/plugin.js b/core/modules/media/js/plugins/drupalmedia/plugin.js
new file mode 100644
index 0000000000..f59cda695c
--- /dev/null
+++ b/core/modules/media/js/plugins/drupalmedia/plugin.js
@@ -0,0 +1,171 @@
+/**
+* DO NOT EDIT THIS FILE.
+* See the following change record for more information,
+* https://www.drupal.org/node/2815083
+* @preserve
+**/
+
+(function (jQuery, Drupal, CKEDITOR) {
+
+  "use strict";
+
+  function linkCommandIntegrator(editor) {
+    if (!editor.plugins.drupallink) {
+      return;
+    }
+
+    CKEDITOR.plugins.drupallink.registerLinkableWidget('drupalmedia');
+  }
+
+  CKEDITOR.plugins.add('drupalmedia', {
+    requires: 'widget',
+
+    beforeInit: function beforeInit(editor) {
+      var dtd = CKEDITOR.dtd;
+      var tagName = null;
+
+      dtd['drupal-media'] = { '#': 1 };
+
+      for (tagName in dtd) {
+        if (dtd[tagName].div) {
+          dtd[tagName]['drupal-media'] = 1;
+        }
+      }
+      dtd['a']['drupal-media'] = 1;
+
+      editor.widgets.add('drupalmedia', {
+        allowedContent: 'drupal-media[data-entity-type,data-entity-uuid,data-view-mode,data-align,data-caption,alt,title]',
+
+        requiredContent: 'drupal-media[data-entity-type,data-entity-uuid]',
+
+        pathName: Drupal.t('Embedded media'),
+
+        editables: {
+          caption: {
+            selector: 'figcaption',
+            allowedContent: 'a[!href]; em strong cite code br',
+            pathName: Drupal.t('Caption')
+          }
+        },
+
+        upcast: function upcast(element, data) {
+          var attributes = element.attributes;
+
+          if (element.name !== 'drupal-media' || attributes['data-entity-type'] !== 'media' || attributes['data-entity-uuid'] === undefined) {
+            return;
+          }
+          data.attributes = CKEDITOR.tools.copy(attributes);
+          data.hasCaption = data.attributes.hasOwnProperty('data-caption');
+          data.link = null;
+          if (element.parent.name === 'a') {
+            data.link = CKEDITOR.tools.copy(element.parent.attributes);
+
+            Object.keys(element.parent.attributes).forEach(function (attrName) {
+              if (attrName.indexOf('data-cke-') !== -1) {
+                delete data.link[attrName];
+              }
+            });
+          }
+          return element;
+        },
+        destroy: function destroy() {
+          this._tearDownDynamicEditables();
+        },
+        data: function data(event) {
+          if (this._previewNeedsServerSideUpdate()) {
+            editor.fire('lockSnapshot');
+            this._tearDownDynamicEditables();
+
+            this._loadPreview(function (widget) {
+              widget._setUpDynamicEditables();
+              editor.fire('unlockSnapshot');
+            });
+          }
+
+          this.element.getParent().addClass('drupal-media-wrapper');
+
+          if (this.data.attributes.hasOwnProperty('data-align')) {
+            this.element.getParent().addClass('drupal-media--align-' + this.data.attributes['data-align']);
+          }
+
+          this.oldData = CKEDITOR.tools.clone(this.data);
+        },
+        downcast: function downcast() {
+          var downcastElement = new CKEDITOR.htmlParser.element('drupal-media', this.data.attributes);
+          if (this.data.link) {
+            var link = new CKEDITOR.htmlParser.element('a', this.data.link);
+            link.add(downcastElement);
+            return link;
+          }
+          return downcastElement;
+        },
+        _setUpDynamicEditables: function _setUpDynamicEditables() {
+          var _this = this;
+
+          if (this.initEditable('caption', this.definition.editables.caption)) {
+            var captionEditable = this.editables.caption;
+
+            captionEditable.setAttribute('data-placeholder', Drupal.t('Enter caption here'));
+
+            this.captionObserver = new MutationObserver(function () {
+              var mediaAttributes = CKEDITOR.tools.clone(_this.data.attributes);
+              mediaAttributes['data-caption'] = captionEditable.getData();
+              _this.setData('attributes', mediaAttributes);
+            });
+            this.captionObserver.observe(captionEditable.$, {
+              characterData: true,
+              attributes: true,
+              childList: true,
+              subtree: true
+            });
+          }
+        },
+        _tearDownDynamicEditables: function _tearDownDynamicEditables() {
+          if (this.captionObserver) {
+            this.captionObserver.disconnect();
+          }
+        },
+        _previewNeedsServerSideUpdate: function _previewNeedsServerSideUpdate() {
+          if (!this.ready) {
+            return true;
+          }
+
+          return this._hashData(this.oldData) !== this._hashData(this.data);
+        },
+        _hashData: function _hashData(data) {
+          var dataToHash = CKEDITOR.tools.clone(data);
+
+          delete dataToHash.attributes['data-caption'];
+
+          if (dataToHash.link) {
+            delete dataToHash.link.href;
+          }
+          return JSON.stringify(dataToHash);
+        },
+        _loadPreview: function _loadPreview(callback) {
+          var _this2 = this;
+
+          jQuery.get({
+            url: Drupal.url('media/' + editor.config.drupal.format + '/preview'),
+            data: {
+              text: this.downcast().getOuterHtml()
+            },
+            dataType: 'html',
+            success: function success(previewHtml) {
+              _this2.element.setHtml(previewHtml);
+              callback(_this2);
+            },
+            error: function error() {
+              var error = Drupal.t('An error occurred while trying to preview the media.');
+              var html = '<div class="messages messages--error file-upload-js-error" aria-live="polite">' + error + '</div>';
+              _this2.element.setHtml(html);
+            }
+          });
+        }
+      });
+    },
+    afterInit: function afterInit(editor) {
+      linkCommandIntegrator(editor);
+    }
+  });
+})(jQuery, Drupal, CKEDITOR);
\ No newline at end of file
diff --git a/core/modules/media/media.libraries.yml b/core/modules/media/media.libraries.yml
index 686e774521..e19f484579 100644
--- a/core/modules/media/media.libraries.yml
+++ b/core/modules/media/media.libraries.yml
@@ -31,3 +31,9 @@ filter.caption:
       css/filter.caption.css: {}
   dependencies:
     - filter/caption
+
+filter.media_embed:
+  version: VERSION
+  css:
+    component:
+      css/filter.media_embed.css: {}
diff --git a/core/modules/media/media.routing.yml b/core/modules/media/media.routing.yml
index a9b634c89a..243b3d78f1 100644
--- a/core/modules/media/media.routing.yml
+++ b/core/modules/media/media.routing.yml
@@ -39,3 +39,11 @@ media.settings:
     _title: 'Media settings'
   requirements:
     _permission: 'administer media'
+
+media.filter.preview:
+  path: '/media/{filter_format}/preview'
+  defaults:
+    _controller: '\Drupal\media\Controller\MediaFilterController::preview'
+  requirements:
+    _entity_access: 'filter_format.use'
+    _custom_access: '\Drupal\media\Controller\MediaFilterController::formatUsesMediaEmbedFilter'
diff --git a/core/modules/media/src/Controller/MediaFilterController.php b/core/modules/media/src/Controller/MediaFilterController.php
new file mode 100644
index 0000000000..6c55519315
--- /dev/null
+++ b/core/modules/media/src/Controller/MediaFilterController.php
@@ -0,0 +1,110 @@
+<?php
+
+namespace Drupal\media\Controller;
+
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\Render\RendererInterface;
+use Drupal\filter\FilterFormatInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
+
+/**
+ * Controller which renders a preview of the provided text.
+ *
+ * @internal
+ *   This is an internal part of the media system in Drupal core and may be
+ *   subject to change in minor releases. This class should not be
+ *   instantiated or extended by external code.
+ */
+class MediaFilterController implements ContainerInjectionInterface {
+
+  /**
+   * The renderer service.
+   *
+   * @var \Drupal\Core\Render\RendererInterface
+   */
+  protected $renderer;
+
+  /**
+   * Constructs an MediaFilterController instance.
+   *
+   * @param \Drupal\Core\Render\RendererInterface $renderer
+   *   The renderer service.
+   */
+  public function __construct(RendererInterface $renderer) {
+    $this->renderer = $renderer;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('renderer')
+    );
+  }
+
+  /**
+   * Returns a HTML response containing a preview of the text after filtering.
+   *
+   * Applies all of the given text format's filters, not just the `entity_embed`
+   * filter, because for example `filter_align` and `filter_caption` may apply
+   * to it as well.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request object.
+   * @param \Drupal\filter\FilterFormatInterface $filter_format
+   *   The text format.
+   *
+   * @return \Symfony\Component\HttpFoundation\Response
+   *   The filtered text.
+   *
+   * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
+   *   Throws an exception if 'text' parameter is not found in the request.
+   *
+   * @see \Drupal\editor\EditorController::getUntransformedText
+   */
+  public function preview(Request $request, FilterFormatInterface $filter_format) {
+    $text = $request->get('text');
+    if ($text == '') {
+      throw new NotFoundHttpException();
+    }
+
+    $build = [
+      '#type' => 'processed_text',
+      '#text' => $text,
+      '#format' => $filter_format->id(),
+    ];
+    $html = $this->renderer->renderPlain($build);
+
+    // Note that we intentionally do not use:
+    // - \Drupal\Core\Cache\CacheableResponse because caching it on the server
+    //   side is wasteful, hence there is no need for cacheability metadata.
+    // - \Drupal\Core\Render\HtmlResponse because there is no need for
+    //   attachments nor cacheability metadata.
+    return (new Response($html))
+      // Do not allow any intermediary to cache the response, only the end user.
+      ->setPrivate()
+      // Allow the end user to cache it for up to 5 minutes.
+      ->setMaxAge(300);
+  }
+
+  /**
+   * Checks access based on media_embed filter status on the text format.
+   *
+   * @param \Drupal\filter\FilterFormatInterface $filter_format
+   *   The text format for which to check access.
+   *
+   * @return \Drupal\Core\Access\AccessResultInterface
+   *   The access result.
+   */
+  public function formatUsesMediaEmbedFilter(FilterFormatInterface $filter_format) {
+    $filters = $filter_format->filters();
+    return AccessResult::allowedIf($filters->has('media_embed') && $filters->get('media_embed')->status)
+      ->addCacheableDependency($filter_format);
+  }
+
+}
diff --git a/core/modules/media/src/Controller/OEmbedIframeController.php b/core/modules/media/src/Controller/OEmbedIframeController.php
index faef56c1a1..d5a879186e 100644
--- a/core/modules/media/src/Controller/OEmbedIframeController.php
+++ b/core/modules/media/src/Controller/OEmbedIframeController.php
@@ -29,8 +29,9 @@
  * of an iframe.
  *
  * @internal
- *   This is an internal part of the oEmbed system and should only be used by
- *   oEmbed-related code in Drupal core.
+ *   This is an internal part of the media system in Drupal core and may be
+ *   subject to change in minor releases. This class should not be
+ *   instantiated or extended by external code.
  */
 class OEmbedIframeController implements ContainerInjectionInterface {
 
diff --git a/core/modules/media/src/Plugin/CKEditorPlugin/DrupalMedia.php b/core/modules/media/src/Plugin/CKEditorPlugin/DrupalMedia.php
new file mode 100644
index 0000000000..05ba74c6cc
--- /dev/null
+++ b/core/modules/media/src/Plugin/CKEditorPlugin/DrupalMedia.php
@@ -0,0 +1,141 @@
+<?php
+
+namespace Drupal\media\Plugin\CKEditorPlugin;
+
+use Drupal\ckeditor\CKEditorPluginContextualInterface;
+use Drupal\ckeditor\CKEditorPluginCssInterface;
+use Drupal\Core\Extension\ModuleExtensionList;
+use Drupal\Core\Extension\ThemeExtensionList;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Drupal\Core\Plugin\PluginBase;
+use Drupal\editor\Entity\Editor;
+
+/**
+ * Defines the "drupalmedia" plugin.
+ *
+ * @CKEditorPlugin(
+ *   id = "drupalmedia",
+ *   label = @Translation("Media Embed"),
+ * )
+ *
+ * @internal
+ *   This is an internal part of the media system in Drupal core and may be
+ *   subject to change in minor releases. This class should not be
+ *   instantiated or extended by external code.
+ */
+class DrupalMedia extends PluginBase implements ContainerFactoryPluginInterface, CKEditorPluginContextualInterface, CKEditorPluginCssInterface {
+
+  /**
+   * The module extension list.
+   *
+   * @var \Drupal\Core\Extension\ModuleExtensionList
+   */
+  protected $moduleExtensionList;
+
+  /**
+   * The theme extension list.
+   *
+   * @var \Drupal\Core\Extension\ThemeExtensionList
+   */
+  protected $themeExtensionList;
+
+  /**
+   * Constructs a new DrupalMedia filter plugin object.
+   *
+   * @param array $configuration
+   *   A configuration array containing information about the plugin instance.
+   * @param string $plugin_id
+   *   The plugin_id for the plugin instance.
+   * @param array $plugin_definition
+   *   The plugin implementation definition.
+   * @param \Drupal\Core\Extension\ModuleExtensionList $extension_list_module
+   *   The module extension list.
+   * @param \Drupal\Core\Extension\ThemeExtensionList $extension_list_theme
+   *   The theme extension list.
+   */
+  public function __construct(array $configuration, $plugin_id, $plugin_definition, ModuleExtensionList $extension_list_module, ThemeExtensionList $extension_list_theme) {
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+    $this->moduleExtensionList = $extension_list_module;
+    $this->themeExtensionList = $extension_list_theme;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+    return new static(
+      $configuration,
+      $plugin_id,
+      $plugin_definition,
+      $container->get('extension.list.module'),
+      $container->get('extension.list.theme')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function isInternal() {
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDependencies(Editor $editor) {
+    return [];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getLibraries(Editor $editor) {
+    return [
+      'core/jquery',
+      'core/drupal',
+      'core/drupal.ajax',
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFile() {
+    return $this->moduleExtensionList->getPath('media') . '/js/plugins/drupalmedia/plugin.js';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getConfig(Editor $editor) {
+    return [];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function isEnabled(Editor $editor) {
+    if (!$editor->hasAssociatedFilterFormat()) {
+      return FALSE;
+    }
+
+    // Automatically enable this plugin if the text format associated with this
+    // text editor uses the media_embed filter.
+    $filters = $editor->getFilterFormat()->filters();
+    return $filters->has('media_embed') && $filters->get('media_embed')->status;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCssFiles(Editor $editor) {
+    return [
+      $this->moduleExtensionList->getPath('media') . '/css/plugins/drupalmedia/ckeditor.drupalmedia.css',
+      $this->moduleExtensionList->getPath('media') . '/css/filter.media_embed.css',
+      $this->moduleExtensionList->getPath('system') . '/css/components/hidden.module.css',
+      $this->themeExtensionList->getPath('classy') . '/css/components/messages.css',
+    ];
+  }
+
+}
diff --git a/core/modules/media/src/Plugin/Filter/MediaEmbed.php b/core/modules/media/src/Plugin/Filter/MediaEmbed.php
index 895cad9edf..282bb0f09b 100644
--- a/core/modules/media/src/Plugin/Filter/MediaEmbed.php
+++ b/core/modules/media/src/Plugin/Filter/MediaEmbed.php
@@ -6,6 +6,7 @@
 use Drupal\Core\Entity\EntityDisplayRepositoryInterface;
 use Drupal\Core\Entity\EntityRepositoryInterface;
 use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Extension\ModuleExtensionList;
 use Drupal\Core\Field\Plugin\Field\FieldFormatter\EntityReferenceEntityFormatter;
 use Drupal\Core\Form\FormStateInterface;
 use Drupal\Core\Logger\LoggerChannelFactoryInterface;
@@ -73,6 +74,13 @@ class MediaEmbed extends FilterBase implements ContainerFactoryPluginInterface,
    */
   protected $loggerFactory;
 
+  /**
+   * The module extension list.
+   *
+   * @var \Drupal\Core\Extension\ModuleExtensionList
+   */
+  protected $moduleExtensionList;
+
   /**
    * An array of counters for the recursive rendering protection.
    *
@@ -104,14 +112,17 @@ class MediaEmbed extends FilterBase implements ContainerFactoryPluginInterface,
    *   The renderer.
    * @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $logger_factory
    *   The logger factory.
+   * @param \Drupal\Core\Extension\ModuleExtensionList $extension_list_module
+   *   The module extension list.
    */
-  public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityRepositoryInterface $entity_repository, EntityTypeManagerInterface $entity_type_manager, EntityDisplayRepositoryInterface $entity_display_repository, RendererInterface $renderer, LoggerChannelFactoryInterface $logger_factory) {
+  public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityRepositoryInterface $entity_repository, EntityTypeManagerInterface $entity_type_manager, EntityDisplayRepositoryInterface $entity_display_repository, RendererInterface $renderer, LoggerChannelFactoryInterface $logger_factory, ModuleExtensionList $extension_list_module) {
     parent::__construct($configuration, $plugin_id, $plugin_definition);
     $this->entityRepository = $entity_repository;
     $this->entityTypeManager = $entity_type_manager;
     $this->entityDisplayRepository = $entity_display_repository;
     $this->renderer = $renderer;
     $this->loggerFactory = $logger_factory;
+    $this->moduleExtensionList = $extension_list_module;
   }
 
   /**
@@ -126,7 +137,8 @@ public static function create(ContainerInterface $container, array $configuratio
       $container->get('entity_type.manager'),
       $container->get('entity_display.repository'),
       $container->get('renderer'),
-      $container->get('logger.factory')
+      $container->get('logger.factory'),
+      $container->get('extension.list.module')
     );
   }
 
@@ -187,14 +199,15 @@ protected function renderMedia(MediaInterface $media, $view_mode, $langcode) {
 
     // There are a few concerns when rendering an embedded media entity:
     // - entity access checking happens not during rendering but during routing,
-    //   and therefore we have to do it explicitly here for the embedded entity;
+    //   and therefore we have to do it explicitly here for the embedded entity.
     $build['#access'] = $media->access('view', NULL, TRUE);
     // - caching an embedded media entity separately is unnecessary; the host
-    //   entity is already render cached;
+    //   entity is already render cached.
     unset($build['#cache']['keys']);
     // - Contextual Links do not make sense for embedded entities; we only allow
-    //   the host entity to be contextually managed;
+    //   the host entity to be contextually managed.
     $build['#pre_render'][] = static::class . '::disableContextualLinks';
+    $build[':media_embed']['#attached']['library'][] = 'media/filter.media_embed';
     // - default styling may break captioned media embeds; attach asset library
     //   to ensure captions behave as intended. Do not set this at the root
     //   level of the render array, otherwise it will be attached always,
@@ -213,12 +226,28 @@ protected function renderMedia(MediaInterface $media, $view_mode, $langcode) {
    */
   protected function renderMissingMedia() {
     return [
-      '#theme' => 'image',
-      '#uri' => file_url_transform_relative(file_create_url('core/modules/media/images/icons/no-thumbnail.png')),
-      '#width' => 180,
-      '#height' => 180,
-      '#alt' => $this->t('Missing media.'),
-      '#title' => $this->t('Missing media.'),
+      '#type' => 'container',
+      '#attributes' => ['class' => ['missing-media']],
+      'image' => [
+        '#theme' => 'image',
+        '#uri' => file_url_transform_relative(file_create_url($this->moduleExtensionList->getPath('media') . '/images/icons/no-thumbnail.png')),
+        '#width' => 100,
+        '#height' => 100,
+        '#alt' => $this->t('Missing media.'),
+        '#title' => $this->t('Missing media.'),
+      ],
+      'message' => [
+        '#type' => 'html_tag',
+        '#tag' => 'p',
+        '#value' => $this->t('Missing media.'),
+        '#attributes' => [
+          'aria-hidden' => 'true',
+          'class' => ['missing-media__message'],
+        ],
+      ],
+      '#attached' => [
+        'library' => ['media/filter.media_embed'],
+      ],
     ];
   }
 
@@ -303,7 +332,7 @@ public function tips($long = FALSE) {
    * Renders the given render array into the given DOM node.
    *
    * @param array $build
-   *   The render array to render in isolation
+   *   The render array to render in isolation.
    * @param \DOMNode $node
    *   The DOM node to render into.
    * @param \Drupal\filter\FilterProcessResult $result
diff --git a/core/modules/media/tests/modules/media_test_ckeditor/media_test_ckeditor.info.yml b/core/modules/media/tests/modules/media_test_ckeditor/media_test_ckeditor.info.yml
new file mode 100644
index 0000000000..7a7255f047
--- /dev/null
+++ b/core/modules/media/tests/modules/media_test_ckeditor/media_test_ckeditor.info.yml
@@ -0,0 +1,8 @@
+name: Media CKEditor plugin test
+description: 'Provides functionality to test the Media Embed CKEditor integration.'
+type: module
+package: Testing
+version: VERSION
+core: 8.x
+dependencies:
+  - drupal:media
diff --git a/core/modules/media/tests/modules/media_test_ckeditor/media_test_ckeditor.module b/core/modules/media/tests/modules/media_test_ckeditor/media_test_ckeditor.module
new file mode 100644
index 0000000000..b6430c39d8
--- /dev/null
+++ b/core/modules/media/tests/modules/media_test_ckeditor/media_test_ckeditor.module
@@ -0,0 +1,17 @@
+<?php
+
+/**
+ * @file
+ * Helper module for the Media Embed CKEditor plugin tests.
+ */
+
+use Drupal\Core\Entity\Display\EntityViewDisplayInterface;
+use Drupal\Core\Entity\EntityInterface;
+
+/**
+ * Implements hook_entity_view_alter().
+ */
+function media_test_ckeditor_entity_view_alter(&$build, EntityInterface $entity, EntityViewDisplayInterface $display) {
+  // @see \Drupal\Tests\media\FunctionalJavascript\CKEditorIntegrationTest::testPreviewUsesDefaultThemeAndIsClientCacheable()
+  $build['#attributes']['data-media-embed-test-active-theme'] = \Drupal::theme()->getActiveTheme()->getName();
+}
diff --git a/core/modules/media/tests/src/FunctionalJavascript/CKEditorIntegrationTest.php b/core/modules/media/tests/src/FunctionalJavascript/CKEditorIntegrationTest.php
new file mode 100644
index 0000000000..95abff5b4b
--- /dev/null
+++ b/core/modules/media/tests/src/FunctionalJavascript/CKEditorIntegrationTest.php
@@ -0,0 +1,592 @@
+<?php
+
+namespace Drupal\Tests\media\FunctionalJavascript;
+
+use Drupal\Component\Utility\Html;
+use Drupal\Core\Url;
+use Drupal\editor\Entity\Editor;
+use Drupal\file\Entity\File;
+use Drupal\filter\Entity\FilterFormat;
+use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
+use Drupal\media\Entity\Media;
+use Drupal\Tests\media\Traits\MediaTypeCreationTrait;
+use Drupal\Tests\TestFileCreationTrait;
+
+/**
+ * @coversDefaultClass \Drupal\media\Plugin\CKEditorPlugin\DrupalMedia
+ * @group media
+ */
+class CKEditorIntegrationTest extends WebDriverTestBase {
+
+  use MediaTypeCreationTrait;
+  use TestFileCreationTrait;
+
+  /**
+   * The user to use during testing.
+   *
+   * @var \Drupal\user\UserInterface
+   */
+  protected $adminUser;
+
+  /**
+   * The sample Media entity to embed.
+   *
+   * @var \Drupal\media\MediaInterface
+   */
+  protected $media;
+
+  /**
+   * A host entity with a body field to embed media in.
+   *
+   * @var \Drupal\node\NodeInterface
+   */
+  protected $host;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'ckeditor',
+    'media',
+    'node',
+    'text',
+    'media_test_ckeditor',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    FilterFormat::create([
+      'format' => 'test_format',
+      'name' => 'Test format',
+      'filters' => [
+        'filter_align' => ['status' => TRUE],
+        'filter_caption' => ['status' => TRUE],
+        'media_embed' => ['status' => TRUE],
+      ],
+    ])->save();
+    Editor::create([
+      'editor' => 'ckeditor',
+      'format' => 'test_format',
+      'settings' => [
+        'toolbar' => [
+          'rows' => [
+            [
+              [
+                'name' => 'All the things',
+                'items' => [
+                  'Source',
+                  'Bold',
+                  'Italic',
+                  'DrupalLink',
+                ],
+              ],
+            ],
+          ],
+        ],
+      ],
+    ])->save();
+
+    // Note that media_install() grants 'view media' to all users by default.
+    $this->adminUser = $this->drupalCreateUser([
+      'use text format test_format',
+      'bypass node access',
+    ]);
+
+    // Create a sample media entity to be embedded.
+    $this->createMediaType('image', ['id' => 'image']);
+    File::create([
+      'uri' => $this->getTestFiles('image')[0]->uri,
+    ])->save();
+    $this->media = Media::create([
+      'bundle' => 'image',
+      'name' => 'Screaming hairy armadillo',
+      'field_media_image' => [
+        [
+          'target_id' => 1,
+          'alt' => 'default alt',
+          'title' => 'default title',
+        ],
+      ],
+    ]);
+    $this->media->save();
+
+    // Create a sample host entity to embed media in.
+    $this->drupalCreateContentType(['type' => 'blog']);
+    $this->host = $this->createNode([
+      'type' => 'blog',
+      'title' => 'Animals with strange names',
+      'body' => [
+        'value' => '<drupal-media data-caption="baz" data-entity-type="media" data-entity-uuid="' . $this->media->uuid() . '"></drupal-media>',
+        'format' => 'test_format',
+      ],
+    ]);
+    $this->host->save();
+
+    $this->drupalLogin($this->adminUser);
+  }
+
+  /**
+   * Tests that only <drupal-media> tags are processed.
+   *
+   * @see \Drupal\Tests\media\Kernel\MediaEmbedFilterTest::testOnlyDrupalMediaTagProcessed()
+   */
+  public function testOnlyDrupalMediaTagProcessed() {
+    $original_value = $this->host->body->value;
+    $this->host->body->value = str_replace('drupal-media', 'p', $original_value);
+    $this->host->save();
+
+    // Assert that `<p data-* …>` is not upcast into a CKEditor Widget.
+    $this->drupalGet($this->host->toUrl('edit-form'));
+    $this->waitForEditor();
+    $this->assignNameToCkeditorIframe();
+    $this->getSession()->switchToIFrame('ckeditor');
+    $assert_session = $this->assertSession();
+    $this->assertEmpty($assert_session->waitForElementVisible('css', 'img[src*="image-test.png"]', 1000));
+    $assert_session->elementNotExists('css', 'figure');
+
+    $this->host->body->value = $original_value;
+    $this->host->save();
+
+    // Assert that `<drupal-media data-* …>` is upcast into a CKEditor Widget.
+    $this->getSession()->reload();
+    $this->waitForEditor();
+    $this->assignNameToCkeditorIframe();
+    $this->getSession()->switchToIFrame('ckeditor');
+    $this->assertNotEmpty($assert_session->waitForElementVisible('css', 'img[src*="image-test.png"]'));
+    $assert_session->elementExists('css', 'figure');
+  }
+
+  /**
+   * The CKEditor Widget must load a preview generated using the default theme.
+   */
+  public function testPreviewUsesDefaultThemeAndIsClientCacheable() {
+    // Make the node edit form use the admin theme, like on most Drupal sites.
+    $this->config('node.settings')
+      ->set('use_admin_theme', TRUE)
+      ->save();
+    $this->container->get('router.builder')->rebuild();
+
+    // Allow the test user to view the admin theme.
+    $this->adminUser->addRole($this->drupalCreateRole(['view the administration theme']));
+    $this->adminUser->save();
+
+    // Configure a different default and admin theme, like on most Drupal sites.
+    $this->config('system.theme')
+      ->set('default', 'stable')
+      ->set('admin', 'classy')
+      ->save();
+
+    // Assert that when looking at an embedded entity in the CKEditor Widget,
+    // the preview is generated using the default theme, not the admin theme.
+    // @see media_test_ckeditor_entity_view_alter()
+    $this->drupalGet($this->host->toUrl('edit-form'));
+    $this->waitForEditor();
+    $this->assignNameToCkeditorIframe();
+    $this->getSession()->switchToIFrame('ckeditor');
+    $assert_session = $this->assertSession();
+    $this->assertNotEmpty($assert_session->waitForElementVisible('css', 'img[src*="image-test.png"]'));
+    $element = $assert_session->elementExists('css', '[data-media-embed-test-active-theme]');
+    $this->assertSame('stable', $element->getAttribute('data-media-embed-test-active-theme'));
+
+    // Assert that the first preview request transferred >500 B over the wire.
+    // Then toggle source mode on and off. This causes the CKEditor widget to be
+    // destroyed and then reconstructed. Assert that during this reconstruction,
+    // a second request is sent. This second request should have transferred 0
+    // bytes: the browser should have cached the response, thus resulting in a
+    // much better user experience.
+    $this->assertGreaterThan(500, $this->getLastPreviewRequestTransferSize());
+    $this->pressEditorButton('source');
+    $this->assertNotEmpty($assert_session->waitForElement('css', 'textarea.cke_source'));
+    $this->pressEditorButton('source');
+    $this->assignNameToCkeditorIframe();
+    $this->getSession()->switchToIFrame('ckeditor');
+    $this->assertNotEmpty($assert_session->waitForElementVisible('css', 'img[src*="image-test.png"]'));
+    $this->assertSame(0, $this->getLastPreviewRequestTransferSize());
+  }
+
+  /**
+   * Tests caption editing in the CKEditor widget.
+   */
+  public function testEditableCaption() {
+    $this->drupalGet($this->host->toUrl('edit-form'));
+    $this->waitForEditor();
+    $this->assignNameToCkeditorIframe();
+
+    // Type in the widget's editable for the caption.
+    $this->getSession()->switchToIFrame('ckeditor');
+    $assert_session = $this->assertSession();
+    $this->assertNotEmpty($assert_session->waitForElement('css', 'figcaption'));
+    $this->setCaption('Caught in a <strong>landslide</strong>! No escape from <em>reality</em>!');
+    $this->getSession()->switchToIFrame('ckeditor');
+    $assert_session->elementExists('css', 'figcaption > em');
+    $assert_session->elementExists('css', 'figcaption > strong')->click();
+
+    // Select the <strong> element and unbold it.
+    $this->clickPathLinkByTitleAttribute("strong element");
+    $this->pressEditorButton('bold');
+    $this->getSession()->switchToIFrame('ckeditor');
+    $assert_session->elementExists('css', 'figcaption > em');
+    $assert_session->elementNotExists('css', 'figcaption > strong');
+
+    // Select the <em> element and unitalicize it.
+    $assert_session->elementExists('css', 'figcaption > em')->click();
+    $this->clickPathLinkByTitleAttribute("em element");
+    $this->pressEditorButton('italic');
+
+    // The "source" button should reveal the HTML source in a state matching
+    // what is shown in the CKEditor widget.
+    $this->pressEditorButton('source');
+    $source = $assert_session->elementExists('css', 'textarea.cke_source');
+    $value = $source->getValue();
+    $dom = Html::load($value);
+    $xpath = new \DOMXPath($dom);
+    $drupal_media = $xpath->query('//drupal-media')[0];
+    $this->assertEquals('Caught in a landslide! No escape from reality!', $drupal_media->getAttribute('data-caption'));
+
+    // Change the caption by modifying the HTML source directly. When exiting
+    // "source" mode, this should be respected.
+    $poor_boy_text = "I'm just a <strong>poor boy</strong>, I need no sympathy!";
+    $drupal_media->setAttribute("data-caption", $poor_boy_text);
+    $source->setValue(Html::serialize($dom));
+    $this->pressEditorButton('source');
+    $this->assignNameToCkeditorIframe();
+    $this->getSession()->switchToIFrame('ckeditor');
+    $figcaption = $assert_session->waitForElement('css', 'figcaption');
+    $this->assertNotEmpty($figcaption);
+    $this->assertEquals($poor_boy_text, $figcaption->getHtml());
+
+    // Select the <strong> element that we just set in "source" mode. This
+    // proves that it was indeed rendered by the CKEditor widget.
+    $figcaption->find('css', 'strong')->click();
+    $this->pressEditorButton('bold');
+
+    // Insert a link into the caption.
+    $this->clickPathLinkByTitleAttribute("Caption element");
+    $this->pressEditorButton('drupallink');
+    $assert_session->waitForId('drupal-modal');
+    $form = $assert_session->waitForElementVisible('css', '#editor-link-dialog-form');
+    $this->assertNotEmpty($form);
+    $form->findField('attributes[href]')
+      ->setValue('https://www.drupal.org');
+    $assert_session->elementExists('css', 'button.form-submit')->press();
+    $assert_session->assertWaitOnAjaxRequest();
+
+    // Wait for the live preview in the CKEditor widget to finish loading, then
+    // edit the link; no `data-cke-saved-href` attribute should exist on it.
+    $this->getSession()->switchToIFrame('ckeditor');
+    $figcaption = $assert_session->waitForElement('css', 'figcaption');
+    $this->assertNotEmpty($figcaption);
+    $figcaption->find('css', 'a')->click();
+    $this->clickPathLinkByTitleAttribute("a element");
+    $this->pressEditorButton('drupallink');
+    $assert_session->waitForId('drupal-modal');
+    $form = $assert_session->waitForElementVisible('css', '#editor-link-dialog-form');
+    $this->assertNotEmpty($form);
+    $form
+      ->findField('attributes[href]')
+      ->setValue('https://www.drupal.org/project/drupal');
+    $assert_session->elementExists('css', 'button.form-submit')->press();
+    $assert_session->assertWaitOnAjaxRequest();
+    $this->pressEditorButton('source');
+    $source = $assert_session->elementExists('css', "textarea.cke_source");
+    $value = $source->getValue();
+    $this->assertContains('https://www.drupal.org/project/drupal', $value);
+    $this->assertNotContains('data-cke-saved-href', $value);
+
+    // Save the entity.
+    $assert_session->buttonExists('Save')->press();
+
+    // Verify the saved entity when viewed also contains the captioned media.
+    $link = $assert_session->elementExists('css', 'figcaption > a');
+    $this->assertEquals('https://www.drupal.org/project/drupal', $link->getAttribute('href'));
+    $this->assertEquals("I'm just a poor boy, I need no sympathy!", $link->getText());
+
+    // Edit it again, type a different caption in the widget.
+    $this->drupalGet($this->host->toUrl('edit-form'));
+    $this->waitForEditor();
+    $this->assignNameToCkeditorIframe();
+    $this->getSession()->switchToIFrame('ckeditor');
+    $this->assertNotEmpty($assert_session->waitForElementVisible('css', 'figcaption'));
+    $this->setCaption('Scaramouch, <em>Scaramouch</em>, will you do the <strong>Fandango</strong>?');
+
+    // Erase the caption in the CKEditor Widget, verify the <figcaption> still
+    // exists and contains placeholder text, then type something else.
+    $this->setCaption('');
+    $this->getSession()->switchToIFrame('ckeditor');
+    $assert_session->elementContains('css', 'figcaption', '');
+    $assert_session->elementAttributeContains('css', 'figcaption', 'data-placeholder', 'Enter caption here');
+    $this->setCaption('Fin.');
+    $this->getSession()->switchToIFrame('ckeditor');
+    $assert_session->elementContains('css', 'figcaption', 'Fin.');
+  }
+
+  /**
+   * Tests linkability of the CKEditor widget.
+   */
+  public function testLinkability() {
+    $this->drupalGet($this->host->toUrl('edit-form'));
+    $this->waitForEditor();
+    $this->assignNameToCkeditorIframe();
+    $this->getSession()->switchToIFrame('ckeditor');
+
+    $assert_session = $this->assertSession();
+    // Select the CKEditor Widget and click the "link" button.
+    $drupalmedia = $assert_session->waitForElementVisible('css', 'drupal-media');
+    $this->assertNotEmpty($drupalmedia);
+    $drupalmedia->click();
+    $this->pressEditorButton('drupallink');
+    $assert_session->waitForId('drupal-modal');
+
+    // Enter a link in the link dialog and save.
+    $form = $assert_session->waitForElementVisible('css', '#editor-link-dialog-form');
+    $this->assertNotEmpty($form);
+    $form->findField('attributes[href]')
+      ->setValue('https://www.drupal.org');
+    $assert_session->elementExists('css', 'button.form-submit')->press();
+    $assert_session->assertWaitOnAjaxRequest();
+
+    // Save the entity.
+    $assert_session->buttonExists('Save')->press();
+
+    // Verify the saved entity when viewed also contains the linked media.
+    $assert_session->elementExists('css', 'figure > a[href="https://www.drupal.org"] > .media--type-image > .field--type-image > img[src*="image-test.png"]');
+
+    // Test that `drupallink` also still works independently: inserting a link
+    // is possible.
+    $this->drupalGet($this->host->toUrl('edit-form'));
+    $this->waitForEditor();
+    $this->pressEditorButton('drupallink');
+    $assert_session->waitForId('drupal-modal');
+    $form = $assert_session->waitForElementVisible('css', '#editor-link-dialog-form');
+    $this->assertNotEmpty($form);
+    $form->findField('attributes[href]')
+      ->setValue('https://wikipedia.org');
+    $assert_session->elementExists('css', 'button.form-submit')->press();
+    $assert_session->assertWaitOnAjaxRequest();
+    $this->assignNameToCkeditorIframe();
+    $this->getSession()->switchToIFrame('ckeditor');
+    $assert_session->elementExists('css', 'body > a[href="https://wikipedia.org"]');
+    $assert_session->elementExists('css', 'body > .cke_widget_drupalmedia > drupal-media > figure > a[href="https://www.drupal.org"]');
+  }
+
+  /**
+   * Tests linkability when `drupalimage` is disabled.
+   */
+  public function testLinkabilityWhenDrupalImageIsAbsent() {
+    // Remove the `drupalimage` plugin's `DrupalImage` button.
+    $editor = Editor::load('test_format');
+    $settings = $editor->getSettings();
+    $rows = $settings['toolbar']['rows'];
+    foreach ($rows as $row_key => $row) {
+      foreach ($row as $group_key => $group) {
+        foreach ($group['items'] as $item_key => $item) {
+          if ($item === 'DrupalImage') {
+            unset($settings['toolbar']['rows'][$row_key][$group_key]['items'][$item_key]);
+          }
+        }
+      }
+    }
+    $editor->setSettings($settings);
+    $editor->save();
+
+    $this->testLinkability();
+  }
+
+  /**
+   * Tests preview route access.
+   *
+   * @param bool $media_embed_enabled
+   *   Whether to test with media_embed filter enabled on the text format.
+   * @param bool $can_use_format
+   *   Whether the logged in user is allowed to use the text format.
+   *
+   * @dataProvider previewAccessProvider
+   */
+  public function testEmbedPreviewAccess($media_embed_enabled, $can_use_format) {
+    $format = FilterFormat::create([
+      'format' => $this->randomMachineName(),
+      'name' => $this->randomString(),
+      'filters' => [
+        'filter_align' => ['status' => TRUE],
+        'filter_caption' => ['status' => TRUE],
+        'media_embed' => ['status' => $media_embed_enabled],
+      ],
+    ]);
+    $format->save();
+
+    if ($can_use_format) {
+      $user = $this->drupalCreateUser([
+        'bypass node access',
+        $format->getPermissionName(),
+      ]);
+    }
+    else {
+      $user = $this->drupalCreateUser([
+        'bypass node access',
+      ]);
+    }
+    $this->drupalLogin($user);
+
+    $text = '<drupal-media data-caption="baz" data-entity-type="media" data-entity-uuid="' . $this->media->uuid() . '"></drupal-media>';
+    $route_parameters = ['filter_format' => $format->id()];
+    $options = ['query' => ['text' => $text]];
+    $this->drupalGet(Url::fromRoute('media.filter.preview', $route_parameters, $options));
+
+    $assert_session = $this->assertSession();
+    if ($media_embed_enabled && $can_use_format) {
+      $assert_session->elementExists('css', 'img');
+      $assert_session->responseContains('baz');
+    }
+    else {
+      $assert_session->responseContains('You are not authorized to access this page.');
+    }
+  }
+
+  /**
+   * Data Provider for ::testEmbedPreviewAccess().
+   */
+  public function previewAccessProvider() {
+    return [
+      'media_embed enabled on format' => [
+        TRUE,
+        TRUE,
+      ],
+      'media_embed disabled on format' => [
+        FALSE,
+        TRUE,
+      ],
+      'media_embed enabled, user lacks access to format' => [
+        TRUE,
+        FALSE,
+      ],
+    ];
+  }
+
+  /**
+   * Tests that alignment classes are added to <drupal-media> element.
+   */
+  public function testAlignmentClasses() {
+    $alignments = [
+      'right',
+      'left',
+      'center',
+    ];
+    $assert_session = $this->assertSession();
+    foreach ($alignments as $alignment) {
+      $this->host->body->value = '<drupal-media data-align="' . $alignment . '" data-entity-type="media" data-entity-uuid="' . $this->media->uuid() . '"></drupal-media>';
+      $this->host->save();
+
+      // Assert that drupal-media tag has the appropriate class.
+      $this->drupalGet($this->host->toUrl('edit-form'));
+      $this->waitForEditor();
+      $this->assignNameToCkeditorIframe();
+      $this->getSession()->switchToIFrame('ckeditor');
+      $wrapper = $assert_session->waitForElementVisible('css', '.drupal-media-wrapper', 1000);
+      $this->assertNotEmpty($wrapper);
+      $this->assertTrue($wrapper->hasClass('drupal-media--align-' . $alignment));
+    }
+  }
+
+  /**
+   * Gets the transfer size of the last preview request.
+   *
+   * @return int
+   */
+  protected function getLastPreviewRequestTransferSize() {
+    $this->getSession()->switchToIFrame();
+    $javascript = <<<JS
+(function(){
+  return window.performance
+    .getEntries()
+    .filter(function (entry) {
+      return entry.initiatorType == 'xmlhttprequest' && entry.name.indexOf('/media/test_format/preview') !== -1;
+    })
+    .pop()
+    .transferSize;
+})()
+JS;
+    return $this->getSession()->evaluateScript($javascript);
+  }
+
+  /**
+   * Assigns a name to the CKEditor iframe, to allow use of ::switchToIFrame().
+   *
+   * @see \Behat\Mink\Session::switchToIFrame()
+   */
+  protected function assignNameToCkeditorIframe() {
+    $javascript = <<<JS
+(function(){
+  document.getElementsByClassName('cke_wysiwyg_frame')[0].id = 'ckeditor';
+})()
+JS;
+    $this->getSession()->evaluateScript($javascript);
+  }
+
+  /**
+   * Clicks a CKEditor button.
+   *
+   * @param string $name
+   *   The name of the button, such as drupalink, source, etc.
+   */
+  protected function pressEditorButton($name) {
+    $this->getSession()->switchToIFrame();
+    $button = $this->assertSession()->waitForElementVisible('css', 'a.cke_button__' . $name);
+    $this->assertNotEmpty($button);
+    $button->click();
+  }
+
+  /**
+   * Waits for CKEditor to initialize.
+   *
+   * @param string $instance_id
+   *   The CKEditor instance ID.
+   * @param int $timeout
+   *   (optional) Timeout in milliseconds, defaults to 10000.
+   */
+  protected function waitForEditor($instance_id = 'edit-body-0-value', $timeout = 10000) {
+    $condition = <<<JS
+      (function() {
+        return (
+          typeof CKEDITOR !== 'undefined'
+          && typeof CKEDITOR.instances["$instance_id"] !== 'undefined'
+          && CKEDITOR.instances["$instance_id"].instanceReady
+        );
+      }());
+JS;
+
+    $this->getSession()->wait($timeout, $condition);
+  }
+
+  /**
+   * Set the text of the editable caption to the given text.
+   *
+   * @param string $text
+   *   The text to set in the caption.
+   */
+  protected function setCaption($text) {
+    $this->getSession()->switchToIFrame();
+    $select_and_edit_caption = "var editor = CKEDITOR.instances['edit-body-0-value'];
+       var figcaption = editor.widgets.getByElement(editor.editable().findOne('figcaption'));
+       figcaption.editables.caption.setData('" . $text . "')";
+    $this->getSession()->executeScript($select_and_edit_caption);
+  }
+
+  /**
+   * Clicks a link in the editor's path links with the given title text.
+   *
+   * @param string $text
+   *   The title attribute of the link to click.
+   *
+   * @throws \Behat\Mink\Exception\ElementNotFoundException
+   */
+  protected function clickPathLinkByTitleAttribute($text) {
+    $this->getSession()->switchToIFrame();
+    $selector = '//span[@id="cke_1_path"]//a[@title="' . $text . '"]';
+    $this->assertSession()->elementExists('xpath', $selector)->click();
+  }
+
+}
diff --git a/core/modules/media/tests/src/Kernel/MediaAccessControlHandlerTest.php b/core/modules/media/tests/src/Kernel/MediaAccessControlHandlerTest.php
index 43eb335d82..fd7bb341f5 100644
--- a/core/modules/media/tests/src/Kernel/MediaAccessControlHandlerTest.php
+++ b/core/modules/media/tests/src/Kernel/MediaAccessControlHandlerTest.php
@@ -87,7 +87,7 @@ public function testCreateAccess(array $permissions, AccessResultInterface $expe
    * @param string[] $expected_cache_contexts
    *   Expected contexts.
    * @param string[] $expected_cache_tags
-   *   Expected cache tags
+   *   Expected cache tags.
    * @param \Drupal\Core\Access\AccessResultInterface $actual
    *   The actual access result.
    */
diff --git a/core/modules/media/tests/src/Kernel/MediaEmbedFilterTest.php b/core/modules/media/tests/src/Kernel/MediaEmbedFilterTest.php
index f39a233531..def0d74a36 100644
--- a/core/modules/media/tests/src/Kernel/MediaEmbedFilterTest.php
+++ b/core/modules/media/tests/src/Kernel/MediaEmbedFilterTest.php
@@ -37,7 +37,11 @@ public function testBasics(array $embed_attributes, $expected_view_mode, array $
     $this->assertSame($expected_cacheability->getCacheContexts(), $result->getCacheContexts());
     $this->assertSame($expected_cacheability->getCacheMaxAge(), $result->getCacheMaxAge());
     $this->assertSame(['library'], array_keys($result->getAttachments()));
-    $this->assertSame(['media/filter.caption'], $result->getAttachments()['library']);
+    $expected_libraries = [
+      'media/filter.media_embed',
+      'media/filter.caption',
+    ];
+    $this->assertSame($expected_libraries, $result->getAttachments()['library']);
   }
 
   /**
@@ -163,7 +167,7 @@ public function providerAccessUnpublished() {
           ])
           ->setCacheContexts(['user.permissions'])
           ->setCacheMaxAge(Cache::PERMANENT),
-        [],
+        'attachments' => [],
       ],
       'user can access embedded media' => [
         TRUE,
@@ -180,7 +184,12 @@ public function providerAccessUnpublished() {
           ])
           ->setCacheContexts(['timezone', 'user', 'user.permissions'])
           ->setCacheMaxAge(Cache::PERMANENT),
-        ['library' => ['media/filter.caption']],
+        'attachments' => [
+          'library' => [
+            'media/filter.media_embed',
+            'media/filter.caption',
+          ],
+        ],
       ],
     ];
   }
@@ -300,6 +309,8 @@ public function providerMissingEntityIndicator() {
 
   /**
    * Tests that only <drupal-media> tags are processed.
+   *
+   * @see \Drupal\Tests\media\FunctionalJavascript\CKEditorIntegrationTest::testOnlyDrupalMediaTagProcessed()
    */
   public function testOnlyDrupalMediaTagProcessed() {
     $content = $this->createEmbedCode([
@@ -369,7 +380,15 @@ public function testFilterIntegration(array $filter_ids, array $additional_attri
    * Data provider for testFilterIntegration().
    */
   public function providerFilterIntegration() {
-    $default_asset_libraries = ['media/filter.caption'];
+    $default_asset_libraries = [
+      'media/filter.media_embed',
+      'media/filter.caption',
+    ];
+    $asset_libraries_with_filter_caption = [
+      'filter/caption',
+      'media/filter.media_embed',
+      'media/filter.caption',
+    ];
 
     $caption_additional_attributes = ['data-caption' => 'Yo.'];
     $caption_verification_selector = 'figure > figcaption';
@@ -386,14 +405,14 @@ public function providerFilterIntegration() {
         $caption_additional_attributes,
         $caption_verification_selector,
         TRUE,
-        ['filter/caption', 'media/filter.caption'],
+        $asset_libraries_with_filter_caption,
       ],
       '`<a>` + `data-caption`; `filter_caption` + `media_embed` ⇒ caption present, link preserved' => [
         ['filter_caption', 'media_embed'],
         $caption_additional_attributes,
         'figure > a[href="https://www.drupal.org"] + figcaption',
         TRUE,
-        ['filter/caption', 'media/filter.caption'],
+        $asset_libraries_with_filter_caption,
         '<a href="https://www.drupal.org">',
         '</a>',
       ],
@@ -433,14 +452,14 @@ public function providerFilterIntegration() {
         $align_additional_attributes + $caption_additional_attributes,
         'figure.align-center > figcaption',
         TRUE,
-        ['filter/caption', 'media/filter.caption'],
+        $asset_libraries_with_filter_caption,
       ],
       '`<a>` + `data-caption` + `data-align`; `filter_align` + `filter_caption` + `media_embed` ⇒ aligned caption present, link preserved' => [
         ['filter_align', 'filter_caption', 'media_embed'],
         $align_additional_attributes + $caption_additional_attributes,
         'figure.align-center > a[href="https://www.drupal.org"] + figcaption',
         TRUE,
-        ['filter/caption', 'media/filter.caption'],
+        $asset_libraries_with_filter_caption,
         '<a href="https://www.drupal.org">',
         '</a>',
       ],
diff --git a/core/themes/bartik/css/components/captions.css b/core/themes/bartik/css/components/captions.css
index 03ad4799e9..9dda3d2915 100644
--- a/core/themes/bartik/css/components/captions.css
+++ b/core/themes/bartik/css/components/captions.css
@@ -2,6 +2,7 @@
 .caption {
   margin-bottom: 1.2em;
 }
+
 .caption > * {
   padding: 0.5ex;
   border: 1px solid #ccc;
diff --git a/core/themes/stable/css/media/filter.caption.css b/core/themes/stable/css/media/filter.caption.css
index a92505c308..776d00b8ec 100644
--- a/core/themes/stable/css/media/filter.caption.css
+++ b/core/themes/stable/css/media/filter.caption.css
@@ -8,3 +8,4 @@
   float: none;
   margin: unset;
 }
+
diff --git a/core/themes/stable/css/media/filter.media_embed.css b/core/themes/stable/css/media/filter.media_embed.css
new file mode 100644
index 0000000000..b5a1c2303b
--- /dev/null
+++ b/core/themes/stable/css/media/filter.media_embed.css
@@ -0,0 +1,13 @@
+/**
+ * @file
+ * Media Embed filter: default styling for displaying Media Embeds.
+ */
+
+/* Use display table so width is only as wide as the contents. */
+.missing-media {
+  display: table;
+}
+
+.missing-media__message {
+  text-align: center;
+}
diff --git a/core/themes/stable/stable.info.yml b/core/themes/stable/stable.info.yml
index 91deed83a2..04a94d6d9e 100644
--- a/core/themes/stable/stable.info.yml
+++ b/core/themes/stable/stable.info.yml
@@ -148,7 +148,10 @@ libraries-override:
     css:
       component:
         css/filter.caption.css: css/media/filter.caption.css
-
+  media/filter.media_embed:
+    css:
+      component:
+        css/filter.media_embed.css: css/media/filter.media_embed.css
   media/oembed.formatter:
     css:
       component:
