diff --git a/core/includes/theme.inc b/core/includes/theme.inc
index b1a911a..092eadf 100644
--- a/core/includes/theme.inc
+++ b/core/includes/theme.inc
@@ -1290,9 +1290,6 @@ function template_preprocess_html(&$variables) {
 function template_preprocess_page(&$variables) {
   $language_interface = \Drupal::languageManager()->getCurrentLanguage();
 
-  // Move some variables to the top level for themer convenience and template cleanliness.
-  $variables['title'] = $variables['page']['#title'];
-
   foreach (\Drupal::theme()->getActiveTheme()->getRegions() as $region) {
     if (!isset($variables['page'][$region])) {
       $variables['page'][$region] = array();
@@ -1413,6 +1410,10 @@ function template_preprocess_maintenance_page(&$variables) {
   $variables['logo'] = theme_get_setting('logo.url');
   $variables['site_name'] = $site_config->get('name');
   $variables['site_slogan'] = $site_config->get('slogan');
+
+  // Maintenance page and install page need page title in variable because there
+  // are no blocks.
+  $variables['title'] = $variables['page']['#title'];
 }
 
 /**
@@ -1659,6 +1660,9 @@ function drupal_common_theme() {
     'page' => array(
       'render element' => 'page',
     ),
+    'page_title' => array(
+      'variables' => array('title' => NULL),
+    ),
     'region' => array(
       'render element' => 'elements',
     ),
diff --git a/core/lib/Drupal/Core/Block/MainContentBlockPluginInterface.php b/core/lib/Drupal/Core/Block/MainContentBlockPluginInterface.php
index 2516348..38da7b3 100644
--- a/core/lib/Drupal/Core/Block/MainContentBlockPluginInterface.php
+++ b/core/lib/Drupal/Core/Block/MainContentBlockPluginInterface.php
@@ -10,7 +10,7 @@
 /**
  * The interface for "main page content" blocks.
  *
- * A main page content block represents the content returns by the controller.
+ * A main page content block represents the content returned by the controller.
  *
  * @ingroup block_api
  */
diff --git a/core/lib/Drupal/Core/Block/Plugin/Block/PageTitleBlock.php b/core/lib/Drupal/Core/Block/Plugin/Block/PageTitleBlock.php
new file mode 100644
index 0000000..7ff4c2c
--- /dev/null
+++ b/core/lib/Drupal/Core/Block/Plugin/Block/PageTitleBlock.php
@@ -0,0 +1,55 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\Core\Plugin\Block\PageTitleBlock.
+ */
+
+namespace Drupal\Core\Block\Plugin\Block;
+
+use Drupal\Core\Block\BlockBase;
+use Drupal\Core\Block\TitleBlockPluginInterface;
+
+/**
+ * Provides a block to display the page title.
+ *
+ * @Block(
+ *   id = "page_title_block",
+ *   admin_label = @Translation("Page title")
+ * )
+ */
+class PageTitleBlock extends BlockBase implements TitleBlockPluginInterface {
+
+  /**
+   * The page title: a string (plain title) or a render array (formatted title).
+   *
+   * @var string|array
+   */
+  protected $title = '';
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setTitle($title) {
+    $this->title = $title;
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function defaultConfiguration() {
+    return ['label_display' => FALSE];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function build() {
+    return [
+      '#type' => 'page_title',
+      '#title' => $this->title,
+    ];
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Block/TitleBlockPluginInterface.php b/core/lib/Drupal/Core/Block/TitleBlockPluginInterface.php
new file mode 100644
index 0000000..19ab275
--- /dev/null
+++ b/core/lib/Drupal/Core/Block/TitleBlockPluginInterface.php
@@ -0,0 +1,30 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\Core\Block\TitleBlockPluginInterface.
+ */
+
+namespace Drupal\Core\Block;
+
+/**
+ * The interface for "title" blocks.
+ *
+ * A title block shows the title returned by the controller.
+ *
+ * @ingroup block_api
+ *
+ * @see \Drupal\Core\Render\Element\PageTitle
+ */
+interface TitleBlockPluginInterface extends BlockPluginInterface {
+
+  /**
+   * Sets the title.
+   *
+   * @param string|array $title
+   *   The page title: either a string for plain titles or a render array for
+   *   formatted titles.
+   */
+  public function setTitle($title);
+
+}
diff --git a/core/lib/Drupal/Core/Display/PageVariantInterface.php b/core/lib/Drupal/Core/Display/PageVariantInterface.php
index 4c50c0a..dec04b6 100644
--- a/core/lib/Drupal/Core/Display/PageVariantInterface.php
+++ b/core/lib/Drupal/Core/Display/PageVariantInterface.php
@@ -36,4 +36,15 @@
    */
   public function setMainContent(array $main_content);
 
+  /**
+   * Sets the title for the page being rendered.
+   *
+   * @param string|array $title
+   *   The page title: either a string for plain titles or a #markup render
+   *   array for formatted titles.
+   *
+   * @return $this
+   */
+  public function setTitle($title);
+
 }
diff --git a/core/lib/Drupal/Core/Render/Element/PageTitle.php b/core/lib/Drupal/Core/Render/Element/PageTitle.php
new file mode 100644
index 0000000..27fbf45
--- /dev/null
+++ b/core/lib/Drupal/Core/Render/Element/PageTitle.php
@@ -0,0 +1,31 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\Core\Render\Element\PageTitle.
+ */
+
+namespace Drupal\Core\Render\Element;
+
+/**
+ * Provides a render element for the title of an HTML page.
+ *
+ * This represents the title of the HTML page's body.
+ *
+ * @RenderElement("page_title")
+ */
+class PageTitle extends RenderElement {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getInfo() {
+    return [
+      '#theme' => 'page_title',
+      // The page title: either a string for plain titles or a render array for
+      // formatted titles.
+      '#title' => NULL,
+    ];
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Render/MainContent/HtmlRenderer.php b/core/lib/Drupal/Core/Render/MainContent/HtmlRenderer.php
index 5cfa312..cd0d482 100644
--- a/core/lib/Drupal/Core/Render/MainContent/HtmlRenderer.php
+++ b/core/lib/Drupal/Core/Render/MainContent/HtmlRenderer.php
@@ -192,11 +192,18 @@ public function renderResponse(array $main_content, Request $request, RouteMatch
    *   If the selected display variant does not implement PageVariantInterface.
    */
   protected function prepare(array $main_content, Request $request, RouteMatchInterface $route_match) {
+    // Determine the title: use the title provided by the main content if any,
+    // otherwise get it from the routing information.
+    $get_title = function (array $main_content) use ($request, $route_match) {
+      return isset($main_content['#title']) ? $main_content['#title'] : $this->titleResolver->getTitle($request, $route_match->getRouteObject());
+    };
+
     // If the _controller result already is #type => page,
     // we have no work to do: The "main content" already is an entire "page"
     // (see html.html.twig).
     if (isset($main_content['#type']) && $main_content['#type'] === 'page') {
       $page = $main_content;
+      $title = $get_title($page);
     }
     // Otherwise, render it as the main content of a #type => page, by selecting
     // page display variant to do that and building that page display variant.
@@ -228,6 +235,8 @@ protected function prepare(array $main_content, Request $request, RouteMatchInte
         ];
       }
 
+      $title = $get_title($main_content);
+
       // Instantiate the page display, and give it the main content.
       $page_display = $this->displayVariantManager->createInstance($variant_id);
       if (!$page_display instanceof PageVariantInterface) {
@@ -235,6 +244,7 @@ protected function prepare(array $main_content, Request $request, RouteMatchInte
       }
       $page_display
         ->setMainContent($main_content)
+        ->setTitle($title)
         ->setConfiguration($event->getPluginConfiguration());
 
       // Generate a #type => page render array using the page display variant,
@@ -258,10 +268,6 @@ protected function prepare(array $main_content, Request $request, RouteMatchInte
     // Allow hooks to add attachments to $page['#attached'].
     $this->invokePageAttachmentHooks($page);
 
-    // Determine the title: use the title provided by the main content if any,
-    // otherwise get it from the routing information.
-    $title = isset($main_content['#title']) ? $main_content['#title'] : $this->titleResolver->getTitle($request, $route_match->getRouteObject());
-
     return [$page, $title];
   }
 
diff --git a/core/lib/Drupal/Core/Render/Plugin/DisplayVariant/SimplePageVariant.php b/core/lib/Drupal/Core/Render/Plugin/DisplayVariant/SimplePageVariant.php
index baef325..f281c82 100644
--- a/core/lib/Drupal/Core/Render/Plugin/DisplayVariant/SimplePageVariant.php
+++ b/core/lib/Drupal/Core/Render/Plugin/DisplayVariant/SimplePageVariant.php
@@ -28,6 +28,13 @@ class SimplePageVariant extends VariantBase implements PageVariantInterface {
   protected $mainContent;
 
   /**
+   * The page title: a string (plain title) or a render array (formatted title).
+   *
+   * @var string|array
+   */
+  protected $title = '';
+
+  /**
    * {@inheritdoc}
    */
   public function setMainContent(array $main_content) {
@@ -38,14 +45,27 @@ public function setMainContent(array $main_content) {
   /**
    * {@inheritdoc}
    */
+  public function setTitle($title) {
+    $this->title = $title;
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
   public function build() {
     $build = [
       'content' => [
-        'main_content' => $this->mainContent,
         'messages' => [
           '#type' => 'status_messages',
           '#weight' => -1000,
         ],
+        'page_title' => [
+          '#type' => 'page_title',
+          '#title' => $this->title,
+          '#weight' => -900,
+        ],
+        'main_content' => ['#weight' => -800] + $this->mainContent,
       ],
     ];
     return $build;
diff --git a/core/modules/aggregator/src/Tests/AggregatorRenderingTest.php b/core/modules/aggregator/src/Tests/AggregatorRenderingTest.php
index 351f48c..63a7e97 100644
--- a/core/modules/aggregator/src/Tests/AggregatorRenderingTest.php
+++ b/core/modules/aggregator/src/Tests/AggregatorRenderingTest.php
@@ -23,6 +23,12 @@ class AggregatorRenderingTest extends AggregatorTestBase {
    */
   public static $modules = array('block', 'test_page_test');
 
+  protected function setUp() {
+    parent::setUp();
+
+    $this->drupalPlaceBlock('page_title_block');
+  }
+
   /**
    * Adds a feed block to the page and checks its links.
    */
diff --git a/core/modules/block/src/BlockViewBuilder.php b/core/modules/block/src/BlockViewBuilder.php
index 9a582352..ffd79ce 100644
--- a/core/modules/block/src/BlockViewBuilder.php
+++ b/core/modules/block/src/BlockViewBuilder.php
@@ -8,6 +8,7 @@
 namespace Drupal\block;
 
 use Drupal\Core\Block\MainContentBlockPluginInterface;
+use Drupal\Core\Block\TitleBlockPluginInterface;
 use Drupal\Core\Cache\Cache;
 use Drupal\Core\Cache\CacheableMetadata;
 use Drupal\Core\Entity\EntityManagerInterface;
@@ -99,12 +100,13 @@ public function viewMultiple(array $entities = array(), $view_mode = 'full', $la
           'tags' => $cache_tags,
           'max-age' => $plugin->getCacheMaxAge(),
         ],
+        '#weight' => $entity->getWeight()
       );
 
       // Allow altering of cacheability metadata or setting #create_placeholder.
       $this->moduleHandler->alter(['block_build', "block_build_" . $plugin->getBaseId()], $build[$entity_id], $plugin);
 
-      if ($plugin instanceof MainContentBlockPluginInterface) {
+      if ($plugin instanceof MainContentBlockPluginInterface || $plugin instanceof TitleBlockPluginInterface) {
         // Immediately build a #pre_render-able block, since this block cannot
         // be built lazily.
         $build[$entity_id] += static::buildPreRenderableBlock($entity, $this->moduleHandler());
diff --git a/core/modules/block/src/Plugin/DisplayVariant/BlockPageVariant.php b/core/modules/block/src/Plugin/DisplayVariant/BlockPageVariant.php
index 650cf78..5abe289 100644
--- a/core/modules/block/src/Plugin/DisplayVariant/BlockPageVariant.php
+++ b/core/modules/block/src/Plugin/DisplayVariant/BlockPageVariant.php
@@ -9,6 +9,7 @@
 
 use Drupal\block\BlockRepositoryInterface;
 use Drupal\Core\Block\MainContentBlockPluginInterface;
+use Drupal\Core\Block\TitleBlockPluginInterface;
 use Drupal\Core\Block\MessagesBlockPluginInterface;
 use Drupal\Core\Cache\CacheableMetadata;
 use Drupal\Core\Display\PageVariantInterface;
@@ -64,6 +65,13 @@ class BlockPageVariant extends VariantBase implements PageVariantInterface, Cont
   protected $mainContent = [];
 
   /**
+   * The page title: a string (plain title) or a render array (formatted title).
+   *
+   * @var string|array
+   */
+  protected $title = '';
+
+  /**
    * Constructs a new BlockPageVariant.
    *
    * @param array $configuration
@@ -111,6 +119,14 @@ public function setMainContent(array $main_content) {
   /**
    * {@inheritdoc}
    */
+  public function setTitle($title) {
+    $this->title = $title;
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
   public function build() {
     // Track whether blocks showing the main content and messages are displayed.
     $main_content_block_displayed = FALSE;
@@ -131,6 +147,9 @@ public function build() {
           $block_plugin->setMainContent($this->mainContent);
           $main_content_block_displayed = TRUE;
         }
+        elseif ($block_plugin instanceof TitleBlockPluginInterface) {
+          $block_plugin->setTitle($this->title);
+        }
         elseif ($block_plugin instanceof MessagesBlockPluginInterface) {
           $messages_block_displayed = TRUE;
         }
@@ -138,8 +157,9 @@ public function build() {
 
         // The main content block cannot be cached: it is a placeholder for the
         // render array returned by the controller. It should be rendered as-is,
-        // with other placed blocks "decorating" it.
-        if ($block_plugin instanceof MainContentBlockPluginInterface) {
+        // with other placed blocks "decorating" it. Analogous reasoning for the
+        // title block.
+        if ($block_plugin instanceof MainContentBlockPluginInterface || $block_plugin instanceof TitleBlockPluginInterface) {
           unset($build[$region][$key]['#cache']['keys']);
         }
       }
@@ -154,7 +174,7 @@ public function build() {
     // content. Otherwise the end user will see all displayed blocks, but not
     // the main content they came for.
     if (!$main_content_block_displayed) {
-      $build['content']['system_main'] = $this->mainContent;
+      $build['content']['system_main'] = ['#weight' => -800] + $this->mainContent;
     }
 
     // If no block displays status messages, still render them.
@@ -165,6 +185,12 @@ public function build() {
       ];
     }
 
+    // If any render arrays are manually placed, render arrays and blocks must
+    // be sorted.
+    if (!$main_content_block_displayed || !$messages_block_displayed) {
+      unset($build['content']['#sorted']);
+    }
+
     // The access results' cacheability is currently added to the top level of the
     // render array. This is done to prevent issues with empty regions being
     // displayed.
diff --git a/core/modules/block/src/Tests/BlockTest.php b/core/modules/block/src/Tests/BlockTest.php
index bd360f6..c98b042 100644
--- a/core/modules/block/src/Tests/BlockTest.php
+++ b/core/modules/block/src/Tests/BlockTest.php
@@ -135,6 +135,9 @@ function testBlockVisibilityListedEmpty() {
    * Test configuring and moving a module-define block to specific regions.
    */
   function testBlock() {
+    // Place page title block to test error messages.
+    $this->drupalPlaceBlock('page_title_block');
+
     // Select the 'Powered by Drupal' block to be configured and moved.
     $block = array();
     $block['id'] = 'system_powered_by_block';
diff --git a/core/modules/block/tests/src/Unit/Plugin/DisplayVariant/BlockPageVariantTest.php b/core/modules/block/tests/src/Unit/Plugin/DisplayVariant/BlockPageVariantTest.php
index 147e64a..53be808 100644
--- a/core/modules/block/tests/src/Unit/Plugin/DisplayVariant/BlockPageVariantTest.php
+++ b/core/modules/block/tests/src/Unit/Plugin/DisplayVariant/BlockPageVariantTest.php
@@ -74,28 +74,32 @@ public function setUpDisplayVariant($configuration = array(), $definition = arra
   public function providerBuild() {
     $blocks_config = array(
       'block1' => array(
-        // region, is main content block, is messages block
-        'top', FALSE, FALSE,
+        // region, is main content block, is messages block, is title block
+        'top', FALSE, FALSE, FALSE,
       ),
       // Test multiple blocks in the same region.
       'block2' => array(
-        'bottom', FALSE, FALSE,
+        'bottom', FALSE, FALSE, FALSE,
       ),
       'block3' => array(
-        'bottom', FALSE, FALSE,
+        'bottom', FALSE, FALSE, FALSE,
       ),
       // Test a block implementing MainContentBlockPluginInterface.
       'block4' => array(
-        'center', TRUE, FALSE,
+        'center', TRUE, FALSE, FALSE,
       ),
       // Test a block implementing MessagesBlockPluginInterface.
       'block5' => array(
-        'center', FALSE, TRUE,
+        'center', FALSE, TRUE, FALSE,
+      ),
+      // Test a block implementing TitleBlockPluginInterface.
+      'block7' => array(
+        'center', FALSE, FALSE, TRUE,
       ),
     );
 
     $test_cases = [];
-    $test_cases[] = [$blocks_config, 5,
+    $test_cases[] = [$blocks_config, 6,
       [
         '#cache' => [
           'tags' => [
@@ -113,6 +117,7 @@ public function providerBuild() {
         'center' => [
           'block4' => [],
           'block5' => [],
+          'block7' => [],
           '#sorted' => TRUE,
         ],
         'bottom' => [
@@ -123,7 +128,7 @@ public function providerBuild() {
       ],
     ];
     unset($blocks_config['block5']);
-    $test_cases[] = [$blocks_config, 4,
+    $test_cases[] = [$blocks_config, 5,
       [
         '#cache' => [
           'tags' => [
@@ -139,6 +144,7 @@ public function providerBuild() {
         ],
         'center' => [
           'block4' => [],
+          'block7' => [],
           '#sorted' => TRUE,
         ],
         'bottom' => [
@@ -157,6 +163,7 @@ public function providerBuild() {
       ],
     ];
     unset($blocks_config['block4']);
+    unset($blocks_config['block7']);
     $test_cases[] = [$blocks_config, 3,
       [
         '#cache' => [
@@ -179,7 +186,10 @@ public function providerBuild() {
         // The main content & messages are rendered via the fallback in case
         // there are no blocks rendering them.
         'content' => [
-          'system_main' => ['#markup' => 'Hello kittens!'],
+          'system_main' => [
+            '#weight' => -800,
+            '#markup' => 'Hello kittens!',
+          ],
           'messages' => [
             '#weight' => -1000,
             '#type' => 'status_messages',
@@ -205,6 +215,7 @@ public function testBuild(array $blocks_config, $visible_block_count, array $exp
     $block_plugin = $this->getMock('Drupal\Core\Block\BlockPluginInterface');
     $main_content_block_plugin = $this->getMock('Drupal\Core\Block\MainContentBlockPluginInterface');
     $messages_block_plugin = $this->getMock('Drupal\Core\Block\MessagesBlockPluginInterface');
+    $title_block_plugin = $this->getMock('Drupal\Core\Block\TitleBlockPluginInterface');
     foreach ($blocks_config as $block_id => $block_config) {
       $block = $this->getMock('Drupal\block\BlockInterface');
       $block->expects($this->any())
@@ -212,7 +223,7 @@ public function testBuild(array $blocks_config, $visible_block_count, array $exp
         ->willReturn([]);
       $block->expects($this->atLeastOnce())
         ->method('getPlugin')
-        ->willReturn($block_config[1] ? $main_content_block_plugin : ($block_config[2] ? $messages_block_plugin : $block_plugin));
+        ->willReturn($block_config[1] ? $main_content_block_plugin : ($block_config[2] ? $messages_block_plugin : ($block_config[3] ? $title_block_plugin : $block_plugin)));
       $blocks[$block_config[0]][$block_id] = $block;
     }
     $this->blockViewBuilder->expects($this->exactly($visible_block_count))
@@ -249,7 +260,9 @@ public function testBuildWithoutMainContent() {
         'max-age' => -1,
       ],
       'content' => [
-        'system_main' => [],
+        'system_main' => [
+          '#weight' => -800,
+        ],
         'messages' => [
           '#weight' => -1000,
           '#type' => 'status_messages',
diff --git a/core/modules/block_content/src/Tests/BlockContentTranslationUITest.php b/core/modules/block_content/src/Tests/BlockContentTranslationUITest.php
index 964f0f9..91ccfda 100644
--- a/core/modules/block_content/src/Tests/BlockContentTranslationUITest.php
+++ b/core/modules/block_content/src/Tests/BlockContentTranslationUITest.php
@@ -50,6 +50,8 @@ protected function setUp() {
     $this->bundle = 'basic';
     $this->testLanguageSelector = FALSE;
     parent::setUp();
+
+    $this->drupalPlaceBlock('page_title_block');
   }
 
   /**
diff --git a/core/modules/block_content/src/Tests/BlockContentTypeTest.php b/core/modules/block_content/src/Tests/BlockContentTypeTest.php
index 35e57a4..2d6a883 100644
--- a/core/modules/block_content/src/Tests/BlockContentTypeTest.php
+++ b/core/modules/block_content/src/Tests/BlockContentTypeTest.php
@@ -42,6 +42,12 @@ class BlockContentTypeTest extends BlockContentTestBase {
    */
   protected $autoCreateBasicBlockType = FALSE;
 
+  protected function setUp() {
+    parent::setUp();
+
+    $this->drupalPlaceBlock('page_title_block');
+  }
+
   /**
    * Tests creating a block type programmatically and via a form.
    */
diff --git a/core/modules/block_content/src/Tests/PageEditTest.php b/core/modules/block_content/src/Tests/PageEditTest.php
index e2ce38f..b5b6697 100644
--- a/core/modules/block_content/src/Tests/PageEditTest.php
+++ b/core/modules/block_content/src/Tests/PageEditTest.php
@@ -17,6 +17,12 @@
  */
 class PageEditTest extends BlockContentTestBase {
 
+  protected function setUp() {
+    parent::setUp();
+
+    $this->drupalPlaceBlock('page_title_block');
+  }
+
   /**
    * Checks block edit functionality.
    */
diff --git a/core/modules/book/src/Tests/BookTest.php b/core/modules/book/src/Tests/BookTest.php
index b530265..86c23be 100644
--- a/core/modules/book/src/Tests/BookTest.php
+++ b/core/modules/book/src/Tests/BookTest.php
@@ -68,6 +68,7 @@ class BookTest extends WebTestBase {
   protected function setUp() {
     parent::setUp();
     $this->drupalPlaceBlock('system_breadcrumb_block');
+    $this->drupalPlaceBlock('page_title_block');
 
     // node_access_test requires a node_access_rebuild().
     node_access_rebuild();
diff --git a/core/modules/comment/src/Tests/CommentAdminTest.php b/core/modules/comment/src/Tests/CommentAdminTest.php
index ee03405..ee434f7 100644
--- a/core/modules/comment/src/Tests/CommentAdminTest.php
+++ b/core/modules/comment/src/Tests/CommentAdminTest.php
@@ -15,6 +15,13 @@
  * @group comment
  */
 class CommentAdminTest extends CommentTestBase {
+
+  protected function setUp() {
+    parent::setUp();
+
+    $this->drupalPlaceBlock('page_title_block');
+  }
+
   /**
    * Test comment approval functionality through admin/content/comment.
    */
diff --git a/core/modules/comment/src/Tests/CommentNonNodeTest.php b/core/modules/comment/src/Tests/CommentNonNodeTest.php
index 8d8e38b..98099fb 100644
--- a/core/modules/comment/src/Tests/CommentNonNodeTest.php
+++ b/core/modules/comment/src/Tests/CommentNonNodeTest.php
@@ -50,6 +50,7 @@ class CommentNonNodeTest extends WebTestBase {
   protected function setUp() {
     parent::setUp();
     $this->drupalPlaceBlock('system_breadcrumb_block');
+    $this->drupalPlaceBlock('page_title_block');
 
     // Create a bundle for entity_test.
     entity_test_create_bundle('entity_test', 'Entity Test', 'entity_test');
diff --git a/core/modules/comment/src/Tests/CommentTestBase.php b/core/modules/comment/src/Tests/CommentTestBase.php
index 12ea894..75efbd4 100644
--- a/core/modules/comment/src/Tests/CommentTestBase.php
+++ b/core/modules/comment/src/Tests/CommentTestBase.php
@@ -188,14 +188,22 @@ public function postComment($entity, $comment, $subject = '', $contact = NULL, $
    */
   function commentExists(CommentInterface $comment = NULL, $reply = FALSE) {
     if ($comment) {
-      $regex = '!' . ($reply ? '<div class="indented">(.*?)' : '');
-      $regex .= '<a id="comment-' . $comment->id() . '"(.*?)';
-      $regex .= $comment->getSubject() . '(.*?)';
-      $regex .= $comment->comment_body->value . '(.*?)';
-      $regex .= ($reply ? '</article>\s</div>(.*?)' : '');
-      $regex .= '!s';
-
-      return (boolean) preg_match($regex, $this->getRawContent());
+      $comment_element = $this->cssSelect('.comment-wrapper ' . ($reply ? '.indented ' : '') . '#comment-' . $comment->id() . ' ~ article');
+      if (empty($comment_element)) {
+        return FALSE;
+      }
+
+      $comment_title = $comment_element[0]->xpath('div/h3/a');
+      if (empty($comment_title) || ((string)$comment_title[0]) !== $comment->getSubject()) {
+        return FALSE;
+      }
+
+      $comment_body = $comment_element[0]->xpath('div/div/p');
+      if (empty($comment_body) || ((string)$comment_body[0]) !== $comment->comment_body->value) {
+        return FALSE;
+      }
+
+      return TRUE;
     }
     else {
       return FALSE;
diff --git a/core/modules/comment/src/Tests/CommentTypeTest.php b/core/modules/comment/src/Tests/CommentTypeTest.php
index 6efc6b6..a2c4c6c 100644
--- a/core/modules/comment/src/Tests/CommentTypeTest.php
+++ b/core/modules/comment/src/Tests/CommentTypeTest.php
@@ -43,6 +43,9 @@ class CommentTypeTest extends CommentTestBase {
    */
   protected function setUp() {
     parent::setUp();
+
+    $this->drupalPlaceBlock('page_title_block');
+
     $this->adminUser = $this->drupalCreateUser($this->permissions);
   }
 
diff --git a/core/modules/config/src/Tests/ConfigSingleImportExportTest.php b/core/modules/config/src/Tests/ConfigSingleImportExportTest.php
index 0a382e2..7b3336d 100644
--- a/core/modules/config/src/Tests/ConfigSingleImportExportTest.php
+++ b/core/modules/config/src/Tests/ConfigSingleImportExportTest.php
@@ -28,6 +28,12 @@ class ConfigSingleImportExportTest extends WebTestBase {
     'config_test'
   ];
 
+  protected function setUp() {
+    parent::setUp();
+
+    $this->drupalPlaceBlock('page_title_block');
+  }
+
   /**
    * Tests importing a single configuration file.
    */
diff --git a/core/modules/config_translation/src/Tests/ConfigTranslationOverviewTest.php b/core/modules/config_translation/src/Tests/ConfigTranslationOverviewTest.php
index 687da5b..7e303b3 100644
--- a/core/modules/config_translation/src/Tests/ConfigTranslationOverviewTest.php
+++ b/core/modules/config_translation/src/Tests/ConfigTranslationOverviewTest.php
@@ -68,6 +68,7 @@ protected function setUp() {
     }
     $this->localeStorage = $this->container->get('locale.storage');
     $this->drupalPlaceBlock('local_tasks_block');
+    $this->drupalPlaceBlock('page_title_block');
   }
 
   /**
diff --git a/core/modules/config_translation/src/Tests/ConfigTranslationUiTest.php b/core/modules/config_translation/src/Tests/ConfigTranslationUiTest.php
index 13890c0..a359c9f 100644
--- a/core/modules/config_translation/src/Tests/ConfigTranslationUiTest.php
+++ b/core/modules/config_translation/src/Tests/ConfigTranslationUiTest.php
@@ -119,6 +119,7 @@ protected function setUp() {
     }
     $this->localeStorage = $this->container->get('locale.storage');
     $this->drupalPlaceBlock('local_tasks_block');
+    $this->drupalPlaceBlock('page_title_block');
   }
 
   /**
diff --git a/core/modules/contact/src/Tests/ContactSitewideTest.php b/core/modules/contact/src/Tests/ContactSitewideTest.php
index 651c283..39cd34a 100644
--- a/core/modules/contact/src/Tests/ContactSitewideTest.php
+++ b/core/modules/contact/src/Tests/ContactSitewideTest.php
@@ -40,6 +40,7 @@ protected function setUp() {
     parent::setUp();
     $this->drupalPlaceBlock('system_breadcrumb_block');
     $this->drupalPlaceBlock('local_actions_block');
+    $this->drupalPlaceBlock('page_title_block');
   }
 
   /**
diff --git a/core/modules/dblog/src/Tests/DbLogTest.php b/core/modules/dblog/src/Tests/DbLogTest.php
index 2f5499f..bffdb2e 100644
--- a/core/modules/dblog/src/Tests/DbLogTest.php
+++ b/core/modules/dblog/src/Tests/DbLogTest.php
@@ -49,6 +49,7 @@ class DbLogTest extends WebTestBase {
   protected function setUp() {
     parent::setUp();
     $this->drupalPlaceBlock('system_breadcrumb_block');
+    $this->drupalPlaceBlock('page_title_block');
 
     // Create users with specific permissions.
     $this->adminUser = $this->drupalCreateUser(array('administer site configuration', 'access administration pages', 'access site reports', 'administer users'));
diff --git a/core/modules/field_ui/src/Tests/EntityDisplayModeTest.php b/core/modules/field_ui/src/Tests/EntityDisplayModeTest.php
index 1a6007c..2847d07 100644
--- a/core/modules/field_ui/src/Tests/EntityDisplayModeTest.php
+++ b/core/modules/field_ui/src/Tests/EntityDisplayModeTest.php
@@ -30,6 +30,7 @@ protected function setUp() {
     parent::setUp();
 
     $this->drupalPlaceBlock('local_actions_block');
+    $this->drupalPlaceBlock('page_title_block');
   }
 
   /**
diff --git a/core/modules/field_ui/src/Tests/ManageFieldsTest.php b/core/modules/field_ui/src/Tests/ManageFieldsTest.php
index 1f3d191..e65f778 100644
--- a/core/modules/field_ui/src/Tests/ManageFieldsTest.php
+++ b/core/modules/field_ui/src/Tests/ManageFieldsTest.php
@@ -70,6 +70,7 @@ protected function setUp() {
     $this->drupalPlaceBlock('system_breadcrumb_block');
     $this->drupalPlaceBlock('local_actions_block');
     $this->drupalPlaceBlock('local_tasks_block');
+    $this->drupalPlaceBlock('page_title_block');
 
     // Create a test user.
     $admin_user = $this->drupalCreateUser(array('access content', 'administer content types', 'administer node fields', 'administer node form display', 'administer node display', 'administer taxonomy', 'administer taxonomy_term fields', 'administer taxonomy_term display', 'administer users', 'administer account settings', 'administer user display', 'bypass node access'));
diff --git a/core/modules/filter/src/Tests/FilterFormatAccessTest.php b/core/modules/filter/src/Tests/FilterFormatAccessTest.php
index 441be0d..590a337 100644
--- a/core/modules/filter/src/Tests/FilterFormatAccessTest.php
+++ b/core/modules/filter/src/Tests/FilterFormatAccessTest.php
@@ -71,6 +71,8 @@ class FilterFormatAccessTest extends WebTestBase {
   protected function setUp() {
     parent::setUp();
 
+    $this->drupalPlaceBlock('page_title_block');
+
     $this->drupalCreateContentType(array('type' => 'page', 'name' => 'Basic page'));
 
     // Create a user who can administer text formats, but does not have
diff --git a/core/modules/forum/src/Tests/ForumTest.php b/core/modules/forum/src/Tests/ForumTest.php
index 767e3e3..974e400 100644
--- a/core/modules/forum/src/Tests/ForumTest.php
+++ b/core/modules/forum/src/Tests/ForumTest.php
@@ -83,6 +83,7 @@ class ForumTest extends WebTestBase {
   protected function setUp() {
     parent::setUp();
     $this->drupalPlaceBlock('system_breadcrumb_block');
+    $this->drupalPlaceBlock('page_title_block');
 
     // Create users.
     $this->adminUser = $this->drupalCreateUser(array(
diff --git a/core/modules/menu_ui/src/Tests/MenuNodeTest.php b/core/modules/menu_ui/src/Tests/MenuNodeTest.php
index 22b10f3..b3397cc 100644
--- a/core/modules/menu_ui/src/Tests/MenuNodeTest.php
+++ b/core/modules/menu_ui/src/Tests/MenuNodeTest.php
@@ -35,6 +35,7 @@ protected function setUp() {
     parent::setUp();
 
     $this->drupalPlaceBlock('system_menu_block:main');
+    $this->drupalPlaceBlock('page_title_block');
 
     $this->drupalCreateContentType(array('type' => 'page', 'name' => 'Basic page'));
 
diff --git a/core/modules/menu_ui/src/Tests/MenuTest.php b/core/modules/menu_ui/src/Tests/MenuTest.php
index e87cd86..ef79989 100644
--- a/core/modules/menu_ui/src/Tests/MenuTest.php
+++ b/core/modules/menu_ui/src/Tests/MenuTest.php
@@ -70,6 +70,8 @@ class MenuTest extends MenuWebTestBase {
   protected function setUp() {
     parent::setUp();
 
+    $this->drupalPlaceBlock('page_title_block');
+
     $this->drupalCreateContentType(array('type' => 'article', 'name' => 'Article'));
 
     // Create users.
diff --git a/core/modules/page_cache/src/Tests/PageCacheTagsIntegrationTest.php b/core/modules/page_cache/src/Tests/PageCacheTagsIntegrationTest.php
index 05bb16d..69aafca 100644
--- a/core/modules/page_cache/src/Tests/PageCacheTagsIntegrationTest.php
+++ b/core/modules/page_cache/src/Tests/PageCacheTagsIntegrationTest.php
@@ -100,6 +100,7 @@ function testPageCacheTags() {
       'config:block.block.bartik_messages',
       'config:block.block.bartik_local_actions',
       'config:block.block.bartik_local_tasks',
+      'config:block.block.bartik_page_title',
       'node_view',
       'node:' . $node_1->id(),
       'user:' . $author_1->id(),
@@ -137,6 +138,7 @@ function testPageCacheTags() {
       'config:block.block.bartik_messages',
       'config:block.block.bartik_local_actions',
       'config:block.block.bartik_local_tasks',
+      'config:block.block.bartik_page_title',
       'node_view',
       'node:' . $node_2->id(),
       'user:' . $author_2->id(),
diff --git a/core/modules/search/src/Tests/SearchConfigSettingsFormTest.php b/core/modules/search/src/Tests/SearchConfigSettingsFormTest.php
index 2c17ed6..5c2a1c2 100644
--- a/core/modules/search/src/Tests/SearchConfigSettingsFormTest.php
+++ b/core/modules/search/src/Tests/SearchConfigSettingsFormTest.php
@@ -59,6 +59,7 @@ protected function setUp() {
     // Enable the search block.
     $this->drupalPlaceBlock('search_form_block');
     $this->drupalPlaceBlock('local_tasks_block');
+    $this->drupalPlaceBlock('page_title_block');
   }
 
   /**
diff --git a/core/modules/search/src/Tests/SearchPageTextTest.php b/core/modules/search/src/Tests/SearchPageTextTest.php
index 8a95fe6..d47325c 100644
--- a/core/modules/search/src/Tests/SearchPageTextTest.php
+++ b/core/modules/search/src/Tests/SearchPageTextTest.php
@@ -39,6 +39,7 @@ protected function setUp() {
     // Create user.
     $this->searchingUser = $this->drupalCreateUser(array('search content', 'access user profiles', 'use advanced search'));
     $this->drupalPlaceBlock('local_tasks_block');
+    $this->drupalPlaceBlock('page_title_block');
   }
 
   /**
diff --git a/core/modules/shortcut/shortcut.module b/core/modules/shortcut/shortcut.module
index be83839..ec49ea1 100644
--- a/core/modules/shortcut/shortcut.module
+++ b/core/modules/shortcut/shortcut.module
@@ -298,9 +298,9 @@ function shortcut_preprocess_block(&$variables) {
 }
 
 /**
- * Implements hook_preprocess_HOOK() for page templates.
+ * Implements hook_preprocess_HOOK() for page title templates.
  */
-function shortcut_preprocess_page(&$variables) {
+function shortcut_preprocess_page_title(&$variables) {
   // Only display the shortcut link if the user has the ability to edit
   // shortcuts and if the page's actual content is being shown (for example,
   // we do not want to display it on "access denied" or "page not found"
@@ -309,9 +309,12 @@ function shortcut_preprocess_page(&$variables) {
     $link = Url::fromRouteMatch(\Drupal::routeMatch())->getInternalPath();
     $route_match = \Drupal::routeMatch();
 
+    // Replicate template_preprocess_html()'s processing to get the title in
+    // string form, so we can set the default name for the shortcut.
+    $name = render($variables['title']);
     $query = array(
       'link' => $link,
-      'name' => $variables['title'],
+      'name' => $name,
     );
 
     $shortcut_set = shortcut_current_displayed_set();
diff --git a/core/modules/shortcut/src/Tests/ShortcutLinksTest.php b/core/modules/shortcut/src/Tests/ShortcutLinksTest.php
index 84f315b..e52b9b6 100644
--- a/core/modules/shortcut/src/Tests/ShortcutLinksTest.php
+++ b/core/modules/shortcut/src/Tests/ShortcutLinksTest.php
@@ -27,6 +27,15 @@ class ShortcutLinksTest extends ShortcutTestBase {
   public static $modules = array('router_test', 'views', 'block');
 
   /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $this->drupalPlaceBlock('page_title_block');
+  }
+
+  /**
    * Tests that creating a shortcut works properly.
    */
   public function testShortcutLinkAdd() {
diff --git a/core/modules/system/src/Tests/Entity/EntityViewControllerTest.php b/core/modules/system/src/Tests/Entity/EntityViewControllerTest.php
index 5aeef20..5e8e5fa 100644
--- a/core/modules/system/src/Tests/Entity/EntityViewControllerTest.php
+++ b/core/modules/system/src/Tests/Entity/EntityViewControllerTest.php
@@ -47,7 +47,7 @@ protected function setUp() {
    */
   function testEntityViewController() {
     $get_label_markup = function($label) {
-      return '<h1>
+      return '<h1 class="page-title">
             <div class="field field--name-name field--type-string field--label-hidden field__item">' . $label . '</div>
       </h1>';
     };
diff --git a/core/modules/system/src/Tests/Installer/InstallerTest.php b/core/modules/system/src/Tests/Installer/InstallerTest.php
index b17e3e7..debb891 100644
--- a/core/modules/system/src/Tests/Installer/InstallerTest.php
+++ b/core/modules/system/src/Tests/Installer/InstallerTest.php
@@ -40,6 +40,41 @@ protected function setUpLanguage() {
     // metatags as expected to the first page of the installer.
     $this->assertRaw('core/themes/seven/css/components/buttons.css');
     $this->assertRaw('<meta charset="utf-8" />');
+
+    // Assert that the expected title is present.
+    $this->assertEqual('Choose language', $this->cssSelect('main h1')[0]);
+
     parent::setUpLanguage();
   }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpProfile() {
+    // Assert that the expected title is present.
+    $this->assertEqual('Select an installation profile', $this->cssSelect('main h1')[0]);
+
+    parent::setUpProfile();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpSettings() {
+    // Assert that the expected title is present.
+    $this->assertEqual('Database configuration', $this->cssSelect('main h1')[0]);
+
+    parent::setUpSettings();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpSite() {
+    // Assert that the expected title is present.
+    $this->assertEqual('Configure site', $this->cssSelect('main h1')[0]);
+
+    parent::setUpSite();
+  }
+
 }
diff --git a/core/modules/system/src/Tests/Menu/MenuRouterTest.php b/core/modules/system/src/Tests/Menu/MenuRouterTest.php
index 21052a4..42332e0 100644
--- a/core/modules/system/src/Tests/Menu/MenuRouterTest.php
+++ b/core/modules/system/src/Tests/Menu/MenuRouterTest.php
@@ -44,6 +44,7 @@ protected function setUp() {
 
     $this->drupalPlaceBlock('system_menu_block:tools');
     $this->drupalPlaceBlock('local_tasks_block');
+    $this->drupalPlaceBlock('page_title_block');
   }
 
   /**
diff --git a/core/modules/system/src/Tests/System/AccessDeniedTest.php b/core/modules/system/src/Tests/System/AccessDeniedTest.php
index 0f37320..b068802 100644
--- a/core/modules/system/src/Tests/System/AccessDeniedTest.php
+++ b/core/modules/system/src/Tests/System/AccessDeniedTest.php
@@ -30,6 +30,8 @@ class AccessDeniedTest extends WebTestBase {
   protected function setUp() {
     parent::setUp();
 
+    $this->drupalPlaceBlock('page_title_block');
+
     // Create an administrative user.
     $this->adminUser = $this->drupalCreateUser(['access administration pages', 'administer site configuration', 'link to any page', 'administer blocks']);
 
diff --git a/core/modules/system/src/Tests/System/PageTitleTest.php b/core/modules/system/src/Tests/System/PageTitleTest.php
index 73ef4bd..8dab86f 100644
--- a/core/modules/system/src/Tests/System/PageTitleTest.php
+++ b/core/modules/system/src/Tests/System/PageTitleTest.php
@@ -36,6 +36,8 @@ protected function setUp() {
 
     $this->drupalCreateContentType(array('type' => 'page', 'name' => 'Basic page'));
 
+    $this->drupalPlaceBlock('page_title_block');
+
     $this->contentUser = $this->drupalCreateUser(array('create page content', 'access content', 'administer themes', 'administer site configuration', 'link to any page'));
     $this->drupalLogin($this->contentUser);
   }
@@ -105,14 +107,14 @@ public function testRoutingTitle() {
     $this->drupalGet('test-render-title');
 
     $this->assertTitle('Foo | Drupal');
-    $result = $this->xpath('//h1');
+    $result = $this->xpath('//h1[@class="page-title"]');
     $this->assertEqual('Foo', (string) $result[0]);
 
     // Test forms
     $this->drupalGet('form-test/object-builder');
 
     $this->assertTitle('Test dynamic title | Drupal');
-    $result = $this->xpath('//h1');
+    $result = $this->xpath('//h1[@class="page-title"]');
     $this->assertEqual('Test dynamic title', (string) $result[0]);
 
     // Set some custom translated strings.
@@ -125,14 +127,14 @@ public function testRoutingTitle() {
     $this->drupalGet('test-page-static-title');
 
     $this->assertTitle('Static title translated | Drupal');
-    $result = $this->xpath('//h1');
+    $result = $this->xpath('//h1[@class="page-title"]');
     $this->assertEqual('Static title translated', (string) $result[0]);
 
     // Test the dynamic '_title_callback' route option.
     $this->drupalGet('test-page-dynamic-title');
 
     $this->assertTitle('Dynamic title | Drupal');
-    $result = $this->xpath('//h1');
+    $result = $this->xpath('//h1[@class="page-title"]');
     $this->assertEqual('Dynamic title', (string) $result[0]);
 
     // Ensure that titles are cacheable and are escaped normally if the
diff --git a/core/modules/system/src/Tests/System/SiteMaintenanceTest.php b/core/modules/system/src/Tests/System/SiteMaintenanceTest.php
index 54ab8b7..b92c3ec 100644
--- a/core/modules/system/src/Tests/System/SiteMaintenanceTest.php
+++ b/core/modules/system/src/Tests/System/SiteMaintenanceTest.php
@@ -67,10 +67,13 @@ protected function testSiteMaintenance() {
     // Logout and verify that offline message is displayed.
     $this->drupalLogout();
     $this->drupalGet('');
+    $this->assertEqual('Site under maintenance', $this->cssSelect('main h1')[0]);
     $this->assertText($offline_message);
     $this->drupalGet('node');
+    $this->assertEqual('Site under maintenance', $this->cssSelect('main h1')[0]);
     $this->assertText($offline_message);
     $this->drupalGet('user/register');
+    $this->assertEqual('Site under maintenance', $this->cssSelect('main h1')[0]);
     $this->assertText($offline_message);
 
     // Verify that user is able to log in.
@@ -103,6 +106,7 @@ protected function testSiteMaintenance() {
     // Logout and verify that custom site offline message is displayed.
     $this->drupalLogout();
     $this->drupalGet('');
+    $this->assertEqual('Site under maintenance', $this->cssSelect('main h1')[0]);
     $this->assertRaw($offline_message, 'Found the site offline message.');
 
     // Verify that custom site offline message is not displayed on user/password.
@@ -121,5 +125,14 @@ protected function testSiteMaintenance() {
     // Log in with temporary login link.
     $this->drupalPostForm($path, array(), t('Log in'));
     $this->assertText($user_message);
+
+    // Regression test to check if title displays in Bartik on maintenance page.
+    \Drupal::service('theme_handler')->install(array('bartik'));
+    \Drupal::service('theme_handler')->setDefault('bartik');
+
+    // Logout and verify that offline message is displayed in Bartik.
+    $this->drupalLogout();
+    $this->drupalGet('');
+    $this->assertEqual('Site under maintenance', $this->cssSelect('main h1')[0]);
   }
 }
diff --git a/core/modules/system/src/Tests/Update/PageTitleConvertedIntoBlockUpdateTest.php b/core/modules/system/src/Tests/Update/PageTitleConvertedIntoBlockUpdateTest.php
new file mode 100644
index 0000000..1602a52
--- /dev/null
+++ b/core/modules/system/src/Tests/Update/PageTitleConvertedIntoBlockUpdateTest.php
@@ -0,0 +1,82 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\system\Tests\Update\PageTitleConvertedIntoBlockUpdateTest.
+ */
+
+namespace Drupal\system\Tests\Update;
+
+use Drupal\node\Entity\Node;
+
+/**
+ * Tests the upgrade path for page title being converted into a block.
+ *
+ * @see https://www.drupal.org/node/2476947
+ *
+ * @group system
+ */
+class PageTitleConvertedIntoBlockUpdateTest extends UpdatePathTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setDatabaseDumpFiles() {
+    $this->databaseDumpFiles = [
+      __DIR__ . '/../../../../system/tests/fixtures/update/drupal-8.bare.standard.php.gz',
+      __DIR__ . '/../../../../system/tests/fixtures/update/drupal-8.page-title-into-block-2476947.php',
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    // @todo Remove in https://www.drupal.org/node/2568069.
+    /** @var \Drupal\Core\Extension\ThemeHandlerInterface $theme_handler */
+    $theme_handler = \Drupal::service('theme_handler');
+    $theme_handler->refreshInfo();
+  }
+
+  /**
+   * Tests that page title is being converted into a block.
+   */
+  public function testUpdateHookN() {
+    $this->runUpdates();
+
+    /** @var \Drupal\block\BlockInterface $block_storage */
+    $block_storage = \Drupal::entityManager()->getStorage('block');
+
+    $this->assertRaw('Because your site has custom theme(s) installed, we have placed the page title block in the content region. Please manually review the block configuration and remove the page title variables from your page templates.');
+
+    // Disable maintenance mode.
+    // @todo Can be removed once maintenance mode is automatically turned off
+    // after updates in https://www.drupal.org/node/2435135.
+    \Drupal::state()->set('system.maintenance_mode', FALSE);
+
+    // We finished updating so we can login the user now.
+    $this->drupalLogin($this->rootUser);
+
+    $page = Node::create([
+      'type' => 'page',
+      'title' => 'Page node',
+    ]);
+    $page->save();
+
+    // Page title is visible on the home page.
+    $this->drupalGet('/node');
+    $this->assertRaw('page-title');
+
+    // Page title is visible on a node page.
+    $this->drupalGet('node/' . $page->id());
+    $this->assertRaw('page-title');
+
+    $this->drupalGet('admin/structure/block/list/bartik');
+
+    /** @var \Drupal\Core\Config\StorageInterface $config_storage */
+    $config_storage = \Drupal::service('config.storage');
+    $this->assertTrue($config_storage->exists('block.block.test_theme_page_title'), 'Page title block has been created for the custom theme.');
+  }
+
+}
diff --git a/core/modules/system/system.install b/core/modules/system/system.install
index c168b24..bb46855 100644
--- a/core/modules/system/system.install
+++ b/core/modules/system/system.install
@@ -1468,16 +1468,16 @@ function system_update_8005() {
 
       default:
         $custom_themes_installed = TRUE;
-        $name = sprintf('block.block.%s_local_actions', $theme_name);
+        $name = 'block.block.' . $theme_name . '_local_actions';
         $values = [
-          'id' => sprintf('%s_local_actions', $theme_name),
+          'id' => $theme_name . '_local_actions',
           'weight' => -10,
         ] + $local_actions_default_settings;
         _system_update_create_block($name, $theme_name, $values);
 
         $name = sprintf('block.block.%s_local_tasks', $theme_name);
         $values = [
-          'id' => sprintf('%s_local_tasks', $theme_name),
+          'id' => $theme_name . '_local_tasks',
           'weight' => -20,
         ] + $tabs_default_settings;
         _system_update_create_block($name, $theme_name, $values);
@@ -1642,5 +1642,88 @@ function system_update_8007() {
 }
 
 /**
+ * Place page title blocks in every theme.
+ */
+function system_update_8008() {
+  // When block module is not installed, there is nothing that could be done
+  // except showing a warning.
+  if (!\Drupal::moduleHandler()->moduleExists('block')) {
+    return t('Block module is not enabled. The page title has been converted to a block, but default page title markup will still display at the top of the main content area.');
+  }
+
+  /** @var \Drupal\Core\Extension\ThemeHandlerInterface $theme_handler */
+  $theme_handler = \Drupal::service('theme_handler');
+  $custom_themes_installed = FALSE;
+  $message = NULL;
+  $langcode = \Drupal::service('language_manager')->getCurrentLanguage()->getId();
+
+  $page_title_default_settings = [
+    'plugin' => 'page_title_block',
+    'region' => 'content',
+    'settings.label' => 'Page title',
+    'settings.label_display' => 0,
+    'visibility' => [],
+    'weight' => -50,
+    'langcode' => $langcode,
+  ];
+  foreach ($theme_handler->listInfo() as $theme) {
+    $theme_name = $theme->getName();
+    switch ($theme_name) {
+      case 'bartik':
+        $name = 'block.block.bartik_page_title';
+        $values = [
+          'id' => 'bartik_page_title',
+        ] + $page_title_default_settings;
+        _system_update_create_block($name, $theme_name, $values);
+        break;
+
+      case 'stark':
+        $name = 'block.block.stark_page_title';
+        $values = [
+          'id' => 'stark_branding',
+          'region' => 'content',
+        ] + $page_title_default_settings;
+        _system_update_create_block($name, $theme_name, $values);
+        break;
+
+      case 'seven':
+        $name = 'block.block.seven_page_title';
+        $values = [
+          'id' => 'seven_page_title',
+          'region' => 'header',
+        ] + $page_title_default_settings;
+        _system_update_create_block($name, $theme_name, $values);
+        break;
+
+      case 'classy':
+        $name = 'block.block.classy_page_title';
+        $values = [
+          'id' => 'classy_page_title',
+          'region' => 'content',
+        ] + $page_title_default_settings;
+        _system_update_create_block($name, $theme_name, $values);
+        break;
+
+      default:
+        $custom_themes_installed = TRUE;
+        $name = sprintf('block.block.%s_page_title', $theme_name);
+        $values = [
+          'id' => sprintf('%s_page_title', $theme_name),
+          'region' => 'content',
+          'weight' => '-50',
+        ] + $page_title_default_settings;
+        _system_update_create_block($name, $theme_name, $values);
+        break;
+    }
+  }
+
+  if ($custom_themes_installed) {
+    $message = t('Because your site has custom theme(s) installed, we have placed the page title block in the content region. Please manually review the block configuration and remove the page title variables from your page templates.');
+  }
+
+  return $message;
+}
+
+/**
  * @} End of "addtogroup updates-8.0.0-beta".
  */
diff --git a/core/modules/system/templates/page-title.html.twig b/core/modules/system/templates/page-title.html.twig
new file mode 100644
index 0000000..2b994bc
--- /dev/null
+++ b/core/modules/system/templates/page-title.html.twig
@@ -0,0 +1,23 @@
+{#
+/**
+ * @file
+ * Default theme implementation for page titles.
+ *
+ * Available variables:
+ * - title_attributes: HTML attributes for the page title element.
+ * - title_prefix: Additional output populated by modules, intended to be
+ *   displayed in front of the main title tag that appears in the template.
+ * - title: The page title, for use in the actual content.
+ * - title_suffix: Additional output populated by modules, intended to be
+ *   displayed after the main title tag that appears in the template.
+ *
+ * @see template_preprocess_page_title()
+ *
+ * @ingroup themeable
+ */
+#}
+{{ title_prefix }}
+{% if title %}
+  <h1{{ title_attributes }}>{{ title }}</h1>
+{% endif %}
+{{ title_suffix }}
diff --git a/core/modules/system/templates/page.html.twig b/core/modules/system/templates/page.html.twig
index d9532bf..897ffef 100644
--- a/core/modules/system/templates/page.html.twig
+++ b/core/modules/system/templates/page.html.twig
@@ -26,11 +26,6 @@
  *   slogan has been disabled in theme settings.
  *
  * Page content (in order of occurrence in the default page.html.twig):
- * - title_prefix: Additional output populated by modules, intended to be
- *   displayed in front of the main title tag that appears in the template.
- * - title: The page title, for use in the actual content.
- * - title_suffix: Additional output populated by modules, intended to be
- *   displayed after the main title tag that appears in the template.
  * - messages: Status and error messages. Should be displayed prominently.
  * - node: Fully loaded node, if there is an automatically-loaded node
  *   associated with the page and the node ID is the second argument in the
@@ -74,12 +69,6 @@
     <a id="main-content" tabindex="-1"></a>{# link is in html.html.twig #}
 
     <div class="layout-content">
-
-      {{ title_prefix }}
-      {% if title %}
-        <h1>{{ title }}</h1>
-      {% endif %}
-      {{ title_suffix }}
       {{ page.content }}
     </div>{# /.layout-content #}
 
diff --git a/core/modules/system/tests/fixtures/update/block.block.testfor2476947.yml b/core/modules/system/tests/fixtures/update/block.block.testfor2476947.yml
new file mode 100644
index 0000000..ff067d6
--- /dev/null
+++ b/core/modules/system/tests/fixtures/update/block.block.testfor2476947.yml
@@ -0,0 +1,20 @@
+uuid: 6d4df0fb-e985-4798-b400-f5242d95f0f7
+langcode: en
+status: true
+dependencies:
+  theme:
+    - bartik
+id: bartik_page_title
+theme: bartik
+region: content
+weight: -50
+provider: null
+plugin: page_title_block
+settings:
+  id: page_title_block
+  label: 'Page title'
+  provider: core
+  label_display: '0'
+  cache:
+    max_age: -1
+visibility: {  }
diff --git a/core/modules/system/tests/fixtures/update/drupal-8.page-title-into-block-2476947.php b/core/modules/system/tests/fixtures/update/drupal-8.page-title-into-block-2476947.php
new file mode 100644
index 0000000..ae3e777
--- /dev/null
+++ b/core/modules/system/tests/fixtures/update/drupal-8.page-title-into-block-2476947.php
@@ -0,0 +1,60 @@
+<?php
+
+/**
+ * @file
+ * Contains database additions to drupal-8.bare.standard.php.gz for testing the
+ * upgrade path of https://www.drupal.org/node/2476947.
+ */
+
+use Drupal\Core\Database\Database;
+
+$connection = Database::getConnection();
+
+// Structure of a custom block with visibility settings.
+$block_configs[] = \Drupal\Component\Serialization\Yaml::decode(file_get_contents(__DIR__ . '/block.block.testfor2476947.yml'));
+
+foreach ($block_configs as $block_config) {
+  $connection->insert('config')
+    ->fields([
+      'collection',
+      'name',
+      'data',
+    ])
+    ->values([
+      'collection' => '',
+      'name' => 'block.block.' . $block_config['id'],
+      'data' => serialize($block_config),
+    ])
+    ->execute();
+}
+
+// Update the config entity query "index".
+$existing_blocks = $connection->select('key_value')
+  ->fields('key_value', ['value'])
+  ->condition('collection', 'config.entity.key_store.block')
+  ->condition('name', 'theme:bartik')
+  ->execute()
+  ->fetchField();
+$existing_blocks = unserialize($existing_blocks);
+
+$connection->update('key_value')
+  ->fields([
+    'value' => serialize(array_merge($existing_blocks, ['block.block.bartik_page_title']))
+  ])
+  ->condition('collection', 'config.entity.key_store.block')
+  ->condition('name', 'theme:bartik')
+  ->execute();
+
+// Enable test theme.
+$extensions = $connection->select('config')
+  ->fields('config', ['data'])
+  ->condition('name', 'core.extension')
+  ->execute()
+  ->fetchField();
+$extensions = unserialize($extensions);
+$connection->update('config')
+  ->fields([
+    'data' => serialize(array_merge_recursive($extensions, ['theme' => ['test_theme' => 0]]))
+  ])
+  ->condition('name', 'core.extension')
+  ->execute();
diff --git a/core/modules/system/tests/modules/display_variant_test/src/Plugin/DisplayVariant/TestDisplayVariant.php b/core/modules/system/tests/modules/display_variant_test/src/Plugin/DisplayVariant/TestDisplayVariant.php
index 2057272..fd903af 100644
--- a/core/modules/system/tests/modules/display_variant_test/src/Plugin/DisplayVariant/TestDisplayVariant.php
+++ b/core/modules/system/tests/modules/display_variant_test/src/Plugin/DisplayVariant/TestDisplayVariant.php
@@ -28,6 +28,13 @@ class TestDisplayVariant extends VariantBase implements PageVariantInterface {
   protected $mainContent = [];
 
   /**
+   * The page title: a string (plain title) or a render array (formatted title).
+   *
+   * @var string|array
+   */
+  protected $title = '';
+
+  /**
    * {@inheritdoc}
    */
   public function setMainContent(array $main_content) {
@@ -38,6 +45,14 @@ public function setMainContent(array $main_content) {
   /**
    * {@inheritdoc}
    */
+  public function setTitle($title) {
+    $this->title = $title;
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
   public function build() {
     $config = $this->getConfiguration();
     if (empty($config['required_configuration'])) {
diff --git a/core/modules/taxonomy/src/Tests/TermTest.php b/core/modules/taxonomy/src/Tests/TermTest.php
index 9d06719..4bf221f 100644
--- a/core/modules/taxonomy/src/Tests/TermTest.php
+++ b/core/modules/taxonomy/src/Tests/TermTest.php
@@ -50,6 +50,7 @@ protected function setUp() {
 
     $this->drupalPlaceBlock('local_actions_block');
     $this->drupalPlaceBlock('local_tasks_block');
+    $this->drupalPlaceBlock('page_title_block');
 
     $this->drupalLogin($this->drupalCreateUser(['administer taxonomy', 'bypass node access']));
     $this->vocabulary = $this->createVocabulary();
diff --git a/core/modules/taxonomy/src/Tests/VocabularyPermissionsTest.php b/core/modules/taxonomy/src/Tests/VocabularyPermissionsTest.php
index 00df789..6294e60 100644
--- a/core/modules/taxonomy/src/Tests/VocabularyPermissionsTest.php
+++ b/core/modules/taxonomy/src/Tests/VocabularyPermissionsTest.php
@@ -14,6 +14,12 @@
  */
 class VocabularyPermissionsTest extends TaxonomyTestBase {
 
+  protected function setUp() {
+    parent::setUp();
+
+    $this->drupalPlaceBlock('page_title_block');
+  }
+
   /**
    * Create, edit and delete a taxonomy term via the user interface.
    */
diff --git a/core/modules/taxonomy/src/Tests/VocabularyUiTest.php b/core/modules/taxonomy/src/Tests/VocabularyUiTest.php
index 029e99d..6787c7c 100644
--- a/core/modules/taxonomy/src/Tests/VocabularyUiTest.php
+++ b/core/modules/taxonomy/src/Tests/VocabularyUiTest.php
@@ -30,6 +30,7 @@ protected function setUp() {
     $this->drupalLogin($this->drupalCreateUser(['administer taxonomy']));
     $this->vocabulary = $this->createVocabulary();
     $this->drupalPlaceBlock('local_actions_block');
+    $this->drupalPlaceBlock('page_title_block');
   }
 
   /**
diff --git a/core/modules/views/js/views-contextual.js b/core/modules/views/js/views-contextual.js
deleted file mode 100644
index e6586eb..0000000
--- a/core/modules/views/js/views-contextual.js
+++ /dev/null
@@ -1,28 +0,0 @@
-/**
- * @file
- * Javascript related to contextual links.
- */
-
-(function ($) {
-
-  "use strict";
-
-  /**
-   * Attaches contextual region classes to views elements.
-   *
-   * @type {Drupal~behavior}
-   *
-   * @prop {Drupal~behaviorAttach} attach
-   *   Adds class `contextual-region` to views elements.
-   */
-  Drupal.behaviors.viewsContextualLinks = {
-    attach: function (context) {
-      var id = $('body').attr('data-views-page-contextual-id');
-
-      $('[data-contextual-id="' + id + '"]')
-        .closest(':has(.view)')
-        .addClass('contextual-region');
-    }
-  };
-
-})(jQuery);
diff --git a/core/modules/views/src/Routing/ViewPageController.php b/core/modules/views/src/Routing/ViewPageController.php
index cf3ae53..592b7d1 100644
--- a/core/modules/views/src/Routing/ViewPageController.php
+++ b/core/modules/views/src/Routing/ViewPageController.php
@@ -60,6 +60,8 @@ public function handle($view_id, $display_id, RouteMatchInterface $route_match)
       $build = $class::buildBasicRenderable($view_id, $display_id, $args, $route);
       Page::setPageRenderArray($build);
 
+      views_add_contextual_links($build, 'page', $display_id, $build);
+
       return $build;
     }
   }
diff --git a/core/modules/views/src/Tests/DefaultViewsTest.php b/core/modules/views/src/Tests/DefaultViewsTest.php
index 3df4082..1a268cf 100644
--- a/core/modules/views/src/Tests/DefaultViewsTest.php
+++ b/core/modules/views/src/Tests/DefaultViewsTest.php
@@ -47,6 +47,8 @@ class DefaultViewsTest extends ViewTestBase {
   protected function setUp() {
     parent::setUp();
 
+    $this->drupalPlaceBlock('page_title_block');
+
     // Create Basic page node type.
     $this->drupalCreateContentType(array('type' => 'page', 'name' => 'Basic page'));
 
diff --git a/core/modules/views/src/Tests/Plugin/DisabledDisplayTest.php b/core/modules/views/src/Tests/Plugin/DisabledDisplayTest.php
index e9052b4..2268a47 100644
--- a/core/modules/views/src/Tests/Plugin/DisabledDisplayTest.php
+++ b/core/modules/views/src/Tests/Plugin/DisabledDisplayTest.php
@@ -34,6 +34,8 @@ protected function setUp() {
 
     $this->enableViewsTestModule();
 
+    $this->drupalPlaceBlock('page_title_block');
+
     $admin_user = $this->drupalCreateUser(array('administer site configuration'));
     $this->drupalLogin($admin_user);
   }
@@ -58,7 +60,7 @@ public function testDisabledDisplays() {
 
     // Enabled page display should return content.
     $this->drupalGet('test-disabled-display');
-    $result = $this->xpath('//h1');
+    $result = $this->xpath('//h1[@class="page-title"]');
     $this->assertEqual($result[0], 'test_disabled_display', 'The enabled page_1 display is accessible.');
 
     // Disabled page view should 404.
@@ -77,7 +79,7 @@ public function testDisabledDisplays() {
 
     // Check that the originally disabled page_2 display is now enabled.
     $this->drupalGet('test-disabled-display-2');
-    $result = $this->xpath('//h1');
+    $result = $this->xpath('//h1[@class="page-title"]');
     $this->assertEqual($result[0], 'test_disabled_display', 'The enabled page_2 display is accessible.');
 
     // Disable each disabled display and save the view.
diff --git a/core/modules/views/src/Tests/Wizard/BasicTest.php b/core/modules/views/src/Tests/Wizard/BasicTest.php
index d84722b..8a64df8 100644
--- a/core/modules/views/src/Tests/Wizard/BasicTest.php
+++ b/core/modules/views/src/Tests/Wizard/BasicTest.php
@@ -19,6 +19,12 @@
  */
 class BasicTest extends WizardTestBase {
 
+  protected function setUp() {
+    parent::setUp();
+
+    $this->drupalPlaceBlock('page_title_block');
+  }
+
   function testViewsWizardAndListing() {
     $this->drupalCreateContentType(array('type' => 'article'));
     $this->drupalCreateContentType(array('type' => 'page'));
diff --git a/core/modules/views/src/Tests/Wizard/ItemsPerPageTest.php b/core/modules/views/src/Tests/Wizard/ItemsPerPageTest.php
index 132df58..9e3a80e 100644
--- a/core/modules/views/src/Tests/Wizard/ItemsPerPageTest.php
+++ b/core/modules/views/src/Tests/Wizard/ItemsPerPageTest.php
@@ -15,6 +15,12 @@
  */
 class ItemsPerPageTest extends WizardTestBase {
 
+  protected function setUp() {
+    parent::setUp();
+
+    $this->drupalPlaceBlock('page_title_block');
+  }
+
   /**
    * Tests the number of items per page.
    */
diff --git a/core/modules/views/src/Tests/Wizard/SortingTest.php b/core/modules/views/src/Tests/Wizard/SortingTest.php
index 41a0040..9fccdcd 100644
--- a/core/modules/views/src/Tests/Wizard/SortingTest.php
+++ b/core/modules/views/src/Tests/Wizard/SortingTest.php
@@ -14,6 +14,12 @@
  */
 class SortingTest extends WizardTestBase {
 
+  protected function setUp() {
+    parent::setUp();
+
+    $this->drupalPlaceBlock('page_title_block');
+  }
+
   /**
    * Tests the sorting functionality.
    */
diff --git a/core/modules/views/tests/src/Unit/Routing/ViewPageControllerTest.php b/core/modules/views/tests/src/Unit/Routing/ViewPageControllerTest.php
index 4604406..87bee46 100644
--- a/core/modules/views/tests/src/Unit/Routing/ViewPageControllerTest.php
+++ b/core/modules/views/tests/src/Unit/Routing/ViewPageControllerTest.php
@@ -5,7 +5,7 @@
  * Contains \Drupal\Tests\views\Unit\Routing\ViewPageControllerTest.
  */
 
-namespace Drupal\Tests\views\Unit\Routing;
+namespace Drupal\Tests\views\Unit\Routing {
 
 use Drupal\Core\Routing\RouteMatch;
 use Drupal\Tests\UnitTestCase;
@@ -181,3 +181,13 @@ public function testHandleWithArgumentsOnOverriddenRouteWithUpcasting() {
   }
 
 }
+
+}
+
+namespace {
+  // @todo replace views_add_contextual_links()
+  if (!function_exists('views_add_contextual_links')) {
+    function views_add_contextual_links() {
+    }
+  }
+}
diff --git a/core/modules/views/views.libraries.yml b/core/modules/views/views.libraries.yml
index 03efb2c..640492d 100644
--- a/core/modules/views/views.libraries.yml
+++ b/core/modules/views/views.libraries.yml
@@ -16,12 +16,3 @@ views.ajax:
     - core/jquery.once
     - core/jquery.form
     - core/drupal.ajax
-
-views.contextual-links:
-  version: VERSION
-  js:
-    # Ensure to run before contextual/drupal.contextual-links.
-    js/views-contextual.js: { weight: -10 }
-  dependencies:
-    - core/jquery
-    - core/drupal
diff --git a/core/modules/views/views.module b/core/modules/views/views.module
index 22424a1..fd7f33a 100644
--- a/core/modules/views/views.module
+++ b/core/modules/views/views.module
@@ -302,39 +302,6 @@ function views_theme_suggestions_container_alter(array &$suggestions, array $var
 }
 
 /**
- * Implements MODULE_preprocess_HOOK().
- */
-function views_preprocess_html(&$variables) {
-  if (!\Drupal::moduleHandler()->moduleExists('contextual')) {
-    return;
-  }
-
-  // If the main content of this page contains a view, attach its contextual
-  // links to the overall page array. This allows them to be rendered directly
-  // next to the page title.
-  if ($render_array = Page::getPageRenderArray()) {
-    views_add_contextual_links($variables['page'], 'page', $render_array['#display_id'], $render_array);
-  }
-
-  // If the page contains a view as its main content, contextual links may have
-  // been attached to the page as a whole; for example, by
-  // views_page_display_pre_render().
-  // This allows them to be associated with the page and rendered by default
-  // next to the page title (which we want). However, it also causes the
-  // Contextual Links module to treat the wrapper for the entire page (i.e.,
-  // the <body> tag) as the HTML element that these contextual links are
-  // associated with. This we don't want; for better visual highlighting, we
-  // prefer a smaller region to be chosen. The region we prefer differs from
-  // theme to theme and depends on the details of the theme's markup in
-  // page.html.twig, so we can only find it using JavaScript. We therefore
-  // remove the "contextual-region" class from the <body> tag here and add
-  // JavaScript that will insert it back in the correct place.
-  if (!empty($variables['page']['#views_contextual_links'])) {
-    $variables['attributes']['data-views-page-contextual-id'] = _contextual_links_to_id($variables['page']['#contextual_links']);
-  }
-}
-
-/**
  * Adds contextual links associated with a view display to a renderable array.
  *
  * This function should be called when a view is being rendered in a particular
@@ -470,9 +437,6 @@ function views_add_contextual_links(&$render_element, $location, $display_id, ar
           // user that may use contextual links, attach Views' contextual links
           // JavaScript.
           $render_element['#cache']['contexts'][] = 'user.permissions';
-          if ($location === 'page' && $render_element['#type'] === 'page' && \Drupal::currentUser()->hasPermission('access contextual links')) {
-            $render_element['#attached']['library'][] = 'views/views.contextual-links';
-          }
         }
       }
     }
diff --git a/core/modules/views/views.theme.inc b/core/modules/views/views.theme.inc
index f34d076..0ad38b0 100644
--- a/core/modules/views/views.theme.inc
+++ b/core/modules/views/views.theme.inc
@@ -38,6 +38,18 @@ function template_preprocess_views_view(&$variables) {
     $variables['attributes']['class'][] = $variables['css_class'];
   }
 
+  // contextual_preproces() only works on render elements, and since this theme
+  // hook is not for a render element, contextual_preprocess() falls back to the
+  // first argument and checks if that is a render element. The first element is
+  // view_array. However, view_array does not get set anywhere, but since we do
+  // have access to the View object, we can also access the View object's
+  // element, which is a render element that does have #contextual_links set if
+  // the display supports it. Doing this allows contextual_preprocess() to
+  // access this theme hook's render element, and therefore allows this template
+  // to have contextual links.
+  // @see views_theme()
+  $variables['view_array'] = $variables['view']->element;
+
   // Attachments are always updated with the outer view, never by themselves,
   // so they do not have dom ids.
   if (empty($view->is_attachment)) {
diff --git a/core/modules/views_ui/src/Tests/DefaultViewsTest.php b/core/modules/views_ui/src/Tests/DefaultViewsTest.php
index 416f276..84496ee 100644
--- a/core/modules/views_ui/src/Tests/DefaultViewsTest.php
+++ b/core/modules/views_ui/src/Tests/DefaultViewsTest.php
@@ -25,6 +25,13 @@ class DefaultViewsTest extends UITestBase {
    */
   public static $testViews = array('test_view_status', 'test_page_display_menu', 'test_page_display_arguments');
 
+
+  protected function setUp() {
+    parent::setUp();
+
+    $this->drupalPlaceBlock('page_title_block');
+  }
+
   /**
    * Tests default views.
    */
diff --git a/core/modules/views_ui/src/Tests/DisplayPathTest.php b/core/modules/views_ui/src/Tests/DisplayPathTest.php
index 275dce7..5e8f595 100644
--- a/core/modules/views_ui/src/Tests/DisplayPathTest.php
+++ b/core/modules/views_ui/src/Tests/DisplayPathTest.php
@@ -17,6 +17,12 @@
  */
 class DisplayPathTest extends UITestBase {
 
+  protected function setUp() {
+    parent::setUp();
+
+    $this->drupalPlaceBlock('page_title_block');
+  }
+
   /**
    * {@inheritdoc}
    */
diff --git a/core/modules/views_ui/src/Tests/DisplayTest.php b/core/modules/views_ui/src/Tests/DisplayTest.php
index 535cd8e..c6dd7c0 100644
--- a/core/modules/views_ui/src/Tests/DisplayTest.php
+++ b/core/modules/views_ui/src/Tests/DisplayTest.php
@@ -180,6 +180,8 @@ public function testPageContextualLinks() {
     $view->enable()->save();
     $this->container->get('router.builder')->rebuildIfNeeded();
 
+    // When no "main content" block is placed, we find a contextual link
+    // placeholder for editing just the view.
     $this->drupalGet('test-display');
     $id = 'entity.view.edit_form:view=test_display:location=page&name=test_display&display_id=page_1&langcode=en';
     // @see \Drupal\contextual\Tests\ContextualDynamicContextTest:assertContextualLinkPlaceHolder()
@@ -192,6 +194,15 @@ public function testPageContextualLinks() {
     $this->assertResponse(200);
     $json = Json::decode($response);
     $this->assertIdentical($json[$id], '<ul class="contextual-links"><li class="entityviewedit-form"><a href="' . base_path() . 'admin/structure/views/view/test_display/edit/page_1">Edit view</a></li></ul>');
+
+    // When a "main content" is placed, we still find a contextual link
+    // placeholder for editing just the view (not the main content block).
+    // @see system_block_view_system_main_block_alter()
+    $this->drupalPlaceBlock('system_main_block', ['id' => 'main_content']);
+    $this->drupalGet('test-display');
+    $id = 'entity.view.edit_form:view=test_display:location=page&name=test_display&display_id=page_1&langcode=en';
+    // @see \Drupal\contextual\Tests\ContextualDynamicContextTest:assertContextualLinkPlaceHolder()
+    $this->assertRaw('<div' . new Attribute(array('data-contextual-id' => $id)) . '></div>', format_string('Contextual link placeholder with id @id exists.', array('@id' => $id)));
   }
 
   /**
diff --git a/core/modules/views_ui/src/Tests/DuplicateTest.php b/core/modules/views_ui/src/Tests/DuplicateTest.php
index e8f55b3..3f4a5a4 100644
--- a/core/modules/views_ui/src/Tests/DuplicateTest.php
+++ b/core/modules/views_ui/src/Tests/DuplicateTest.php
@@ -14,6 +14,12 @@
  */
 class DuplicateTest extends UITestBase {
 
+  protected function setUp() {
+    parent::setUp();
+
+    $this->drupalPlaceBlock('page_title_block');
+  }
+
   /**
    * Checks if duplicated view exists and has correct label.
    */
diff --git a/core/modules/views_ui/src/Tests/HandlerTest.php b/core/modules/views_ui/src/Tests/HandlerTest.php
index c53ddd7..99eed48 100644
--- a/core/modules/views_ui/src/Tests/HandlerTest.php
+++ b/core/modules/views_ui/src/Tests/HandlerTest.php
@@ -26,6 +26,15 @@ class HandlerTest extends UITestBase {
   public static $testViews = array('test_view_empty', 'test_view_broken');
 
   /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $this->drupalPlaceBlock('page_title_block');
+  }
+
+  /**
    * Overrides \Drupal\views\Tests\ViewTestBase::schemaDefinition().
    *
    * Adds a uid column to test the relationships.
@@ -155,7 +164,7 @@ public function testBrokenHandlers() {
       $this->assertIdentical((string) $result[0], $text, 'Ensure the broken handler text was found.');
 
       $this->drupalGet($href);
-      $result = $this->xpath('//h1');
+      $result = $this->xpath('//h1[@class="page-title"]');
       $this->assertTrue(strpos((string) $result[0], $text) !== FALSE, 'Ensure the broken handler text was found.');
 
       $original_configuration = [
diff --git a/core/modules/views_ui/src/Tests/OverrideDisplaysTest.php b/core/modules/views_ui/src/Tests/OverrideDisplaysTest.php
index 2a693f7..25efc32 100644
--- a/core/modules/views_ui/src/Tests/OverrideDisplaysTest.php
+++ b/core/modules/views_ui/src/Tests/OverrideDisplaysTest.php
@@ -14,6 +14,12 @@
  */
 class OverrideDisplaysTest extends UITestBase {
 
+  protected function setUp() {
+    parent::setUp();
+
+    $this->drupalPlaceBlock('page_title_block');
+  }
+
   /**
    * Tests that displays can be overridden via the UI.
    */
diff --git a/core/profiles/minimal/config/install/block.block.stark_page_title.yml b/core/profiles/minimal/config/install/block.block.stark_page_title.yml
new file mode 100644
index 0000000..6222884
--- /dev/null
+++ b/core/profiles/minimal/config/install/block.block.stark_page_title.yml
@@ -0,0 +1,18 @@
+id: stark_page_title
+theme: stark
+weight: -30
+status: true
+langcode: en
+region: content
+plugin: page_title_block
+settings:
+  id: page_title_block
+  label: Page title
+  provider: core
+  label_display: '0'
+  cache:
+    max_age: -1
+dependencies:
+  theme:
+    - stark
+visibility: {  }
diff --git a/core/profiles/standard/config/install/block.block.bartik_page_title.yml b/core/profiles/standard/config/install/block.block.bartik_page_title.yml
new file mode 100644
index 0000000..f23677d
--- /dev/null
+++ b/core/profiles/standard/config/install/block.block.bartik_page_title.yml
@@ -0,0 +1,18 @@
+id: bartik_page_title
+theme: bartik
+weight: -50
+status: true
+langcode: en
+region: content
+plugin: page_title_block
+settings:
+  id: page_title_block
+  label: Page title
+  provider: core
+  label_display: '0'
+  cache:
+    max_age: -1
+dependencies:
+  theme:
+    - bartik
+visibility: {  }
diff --git a/core/profiles/standard/config/install/block.block.classy_page_title.yml b/core/profiles/standard/config/install/block.block.classy_page_title.yml
new file mode 100644
index 0000000..5674a48
--- /dev/null
+++ b/core/profiles/standard/config/install/block.block.classy_page_title.yml
@@ -0,0 +1,18 @@
+id: classy_page_title
+theme: classy
+weight: -50
+status: true
+langcode: en
+region: content
+plugin: page_title_block
+settings:
+  id: page_title_block
+  label: Page title
+  provider: core
+  label_display: '0'
+  cache:
+    max_age: -1
+dependencies:
+  theme:
+    - classy
+visibility: {  }
diff --git a/core/profiles/standard/config/install/block.block.seven_page_title.yml b/core/profiles/standard/config/install/block.block.seven_page_title.yml
new file mode 100644
index 0000000..b03f162
--- /dev/null
+++ b/core/profiles/standard/config/install/block.block.seven_page_title.yml
@@ -0,0 +1,18 @@
+id: page_title
+theme: seven
+weight: -30
+status: true
+langcode: en
+region: header
+plugin: page_title_block
+settings:
+  id: page_title_block
+  label: Page Title
+  provider: core
+  label_display: '0'
+  cache:
+    max_age: -1
+dependencies:
+  theme:
+    - seven
+visibility: {  }
diff --git a/core/themes/bartik/bartik.theme b/core/themes/bartik/bartik.theme
index 556342f..03dc8b4 100644
--- a/core/themes/bartik/bartik.theme
+++ b/core/themes/bartik/bartik.theme
@@ -39,7 +39,7 @@ function bartik_preprocess_html(&$variables) {
 /**
  * Implements hook_preprocess_HOOK() for page templates.
  */
-function bartik_preprocess_page(&$variables) {
+function bartik_preprocess_page_title(&$variables) {
   // Since the title and the shortcut link are both block level elements,
   // positioning them next to each other is much simpler with a wrapper div.
   if (!empty($variables['title_suffix']['add_or_remove_shortcut']) && $variables['title']) {
diff --git a/core/themes/bartik/templates/page-title.html.twig b/core/themes/bartik/templates/page-title.html.twig
new file mode 100644
index 0000000..e061cd2
--- /dev/null
+++ b/core/themes/bartik/templates/page-title.html.twig
@@ -0,0 +1,16 @@
+{% extends "@classy/content/page-title.html.twig" %}
+{#
+/**
+ * @file
+ * Bartik's theme implementation for a page title.
+ *
+ * Available variables:
+ * - title_attributes: HTML attributes for the page title element.
+ * - title_prefix: Additional output populated by modules, intended to be
+ *   displayed in front of the main title tag that appears in the template.
+ * - title: The page title, for use in the actual content.
+ * - title_suffix: Additional output populated by modules, intended to be
+ *   displayed after the main title tag that appears in the template.
+ */
+#}
+{% set title_attributes = title_attributes.addClass('title') %}
diff --git a/core/themes/bartik/templates/page.html.twig b/core/themes/bartik/templates/page.html.twig
index 8bcea09..7122143 100644
--- a/core/themes/bartik/templates/page.html.twig
+++ b/core/themes/bartik/templates/page.html.twig
@@ -27,11 +27,6 @@
  *   slogan has been disabled in theme settings.
 
  * Page content (in order of occurrence in the default page.html.twig):
- * - title_prefix: Additional output populated by modules, intended to be
- *   displayed in front of the main title tag that appears in the template.
- * - title: The page title, for use in the actual content.
- * - title_suffix: Additional output populated by modules, intended to be
- *   displayed after the main title tag that appears in the template.
  * - node: Fully loaded node, if there is an automatically-loaded node
  *   associated with the page and the node ID is the second argument in the
  *   page's path (e.g. node/12345 and node/12345/revisions, but not
@@ -90,13 +85,6 @@
         <main id="content" class="column main-content js-quickedit-main-content" role="main">
           <section class="section">
             <a id="main-content" tabindex="-1"></a>
-            {{ title_prefix }}
-            {% if title %}
-              <h1 class="title page-title">
-                {{ title }}
-              </h1>
-            {% endif %}
-            {{ title_suffix }}
             {{ page.content }}
           </section>
         </main>
diff --git a/core/themes/classy/templates/content/page-title.html.twig b/core/themes/classy/templates/content/page-title.html.twig
new file mode 100644
index 0000000..adec853
--- /dev/null
+++ b/core/themes/classy/templates/content/page-title.html.twig
@@ -0,0 +1,21 @@
+{#
+/**
+ * @file
+ * Theme override for page titles.
+ *
+ * Available variables:
+ * - title_attributes: HTML attributes for the page title element.
+ * - title_prefix: Additional output populated by modules, intended to be
+ *   displayed in front of the main title tag that appears in the template.
+ * - title: The page title, for use in the actual content.
+ * - title_suffix: Additional output populated by modules, intended to be
+ *   displayed after the main title tag that appears in the template.
+ *
+ * @see template_preprocess_page_title()
+ */
+#}
+{{ title_prefix }}
+{% if title %}
+  <h1{{ title_attributes.addClass('page-title') }}>{{ title }}</h1>
+{% endif %}
+{{ title_suffix }}
diff --git a/core/themes/classy/templates/layout/page.html.twig b/core/themes/classy/templates/layout/page.html.twig
index 0ac734b..f998a33 100644
--- a/core/themes/classy/templates/layout/page.html.twig
+++ b/core/themes/classy/templates/layout/page.html.twig
@@ -26,11 +26,6 @@
  *   slogan has been disabled in theme settings.
  *
  * Page content (in order of occurrence in the default page.html.twig):
- * - title_prefix: Additional output populated by modules, intended to be
- *   displayed in front of the main title tag that appears in the template.
- * - title: The page title, for use in the actual content.
- * - title_suffix: Additional output populated by modules, intended to be
- *   displayed after the main title tag that appears in the template.
  * - node: Fully loaded node, if there is an automatically-loaded node
  *   associated with the page and the node ID is the second argument in the
  *   page's path (e.g. node/12345 and node/12345/revisions, but not
@@ -72,12 +67,6 @@
     <a id="main-content" tabindex="-1"></a>{# link is in html.html.twig #}
 
     <div class="layout-content">
-
-      {{ title_prefix }}
-      {% if title %}
-        <h1>{{ title }}</h1>
-      {% endif %}
-      {{ title_suffix }}
       {{ page.content }}
     </div>{# /.layout-content #}
 
diff --git a/core/themes/seven/templates/page.html.twig b/core/themes/seven/templates/page.html.twig
index 5698bfa..25597a1 100644
--- a/core/themes/seven/templates/page.html.twig
+++ b/core/themes/seven/templates/page.html.twig
@@ -27,11 +27,6 @@
  *   slogan has been disabled in theme settings.
  *
  * Page content (in order of occurrence in the default page.html.twig):
- * - title_prefix: Additional output populated by modules, intended to be
- *   displayed in front of the main title tag that appears in the template.
- * - title: The page title, for use in the actual content.
- * - title_suffix: Additional output populated by modules, intended to be
- *   displayed after the main title tag that appears in the template.
  * - node: Fully loaded node, if there is an automatically-loaded node
  *   associated with the page and the node ID is the second argument in the
  *   page's path (e.g. node/12345 and node/12345/revisions, but not
@@ -52,11 +47,6 @@
 #}
   <header class="content-header clearfix">
     <div class="layout-container">
-      {{ title_prefix }}
-      {% if title %}
-        <h1 class="page-title">{{ title }}</h1>
-      {% endif %}
-      {{ title_suffix }}
       {{ page.header }}
     </div>
   </header>
