diff --git a/admin/class-admin-asset-manager.php b/admin/class-admin-asset-manager.php
index ec0df47a1a3..09d269b7737 100644
--- a/admin/class-admin-asset-manager.php
+++ b/admin/class-admin-asset-manager.php
@@ -660,6 +660,11 @@ protected function styles_to_be_registered() {
'src' => 'academy-' . $flat_version,
'deps' => [ self::PREFIX . 'tailwind' ],
],
+ [
+ 'name' => 'new-dashboard',
+ 'src' => 'new-dashboard-' . $flat_version,
+ 'deps' => [ self::PREFIX . 'tailwind' ],
+ ],
[
'name' => 'support',
'src' => 'support-' . $flat_version,
diff --git a/config/webpack/paths.js b/config/webpack/paths.js
index 28132d9f0bd..8633e3f9496 100644
--- a/config/webpack/paths.js
+++ b/config/webpack/paths.js
@@ -44,6 +44,7 @@ const getEntries = ( sourceDirectory = "./packages/js/src" ) => ( {
settings: `${ sourceDirectory }/settings.js`,
"new-settings": `${ sourceDirectory }/settings/initialize.js`,
academy: `${ sourceDirectory }/academy/initialize.js`,
+ "new-dashboard": `${ sourceDirectory }/dashboard/initialize.js`,
support: `${ sourceDirectory }/support/initialize.js`,
"how-to-block": `${ sourceDirectory }/structured-data-blocks/how-to/block.js`,
"faq-block": `${ sourceDirectory }/structured-data-blocks/faq/block.js`,
diff --git a/packages/js/src/dashboard/app.js b/packages/js/src/dashboard/app.js
new file mode 100644
index 00000000000..1544f134d07
--- /dev/null
+++ b/packages/js/src/dashboard/app.js
@@ -0,0 +1,31 @@
+/* eslint-disable complexity */
+
+import { __ } from "@wordpress/i18n";
+import { Paper, Title } from "@yoast/ui-library";
+
+/**
+ * @returns {JSX.Element} The app component.
+ */
+const App = () => {
+ return (
+
+ );
+};
+
+export default App;
diff --git a/packages/js/src/dashboard/constants/index.js b/packages/js/src/dashboard/constants/index.js
new file mode 100644
index 00000000000..fa08f25daa4
--- /dev/null
+++ b/packages/js/src/dashboard/constants/index.js
@@ -0,0 +1,5 @@
+/**
+ * Keep constants centralized to avoid circular dependency problems.
+ */
+export const STORE_NAME = "@yoast/dashboard";
+
diff --git a/packages/js/src/dashboard/initialize.js b/packages/js/src/dashboard/initialize.js
new file mode 100644
index 00000000000..7045b79d2bb
--- /dev/null
+++ b/packages/js/src/dashboard/initialize.js
@@ -0,0 +1,32 @@
+import { SlotFillProvider } from "@wordpress/components";
+import { select } from "@wordpress/data";
+import domReady from "@wordpress/dom-ready";
+import { render } from "@wordpress/element";
+import { Root } from "@yoast/ui-library";
+import { get } from "lodash";
+import { LINK_PARAMS_NAME } from "../shared-admin/store";
+import App from "./app";
+import { STORE_NAME } from "./constants";
+import registerStore from "./store";
+
+domReady( () => {
+ const root = document.getElementById( "yoast-seo-dashboard" );
+ if ( ! root ) {
+ return;
+ }
+ registerStore( {
+ initialState: {
+ [ LINK_PARAMS_NAME ]: get( window, "wpseoScriptData.linkParams", {} ),
+ },
+ } );
+ const isRtl = select( STORE_NAME ).selectPreference( "isRtl", false );
+
+ render(
+
+
+
+
+ ,
+ root
+ );
+} );
diff --git a/packages/js/src/dashboard/store/index.js b/packages/js/src/dashboard/store/index.js
new file mode 100644
index 00000000000..f16cf812f3e
--- /dev/null
+++ b/packages/js/src/dashboard/store/index.js
@@ -0,0 +1,49 @@
+// eslint-disable-next-line import/named
+import { combineReducers, createReduxStore, register } from "@wordpress/data";
+import { merge } from "lodash";
+import { getInitialLinkParamsState, LINK_PARAMS_NAME, linkParamsActions, linkParamsReducer, linkParamsSelectors } from "../../shared-admin/store";
+import { STORE_NAME } from "../constants";
+import preferences, { createInitialPreferencesState, preferencesActions, preferencesSelectors } from "./preferences";
+
+/** @typedef {import("@wordpress/data/src/types").WPDataStore} WPDataStore */
+
+/**
+ * @param {Object} initialState Initial state.
+ * @returns {WPDataStore} The WP data store.
+ */
+const createStore = ( { initialState } ) => {
+ return createReduxStore( STORE_NAME, {
+ actions: {
+ ...linkParamsActions,
+ ...preferencesActions,
+ },
+ selectors: {
+ ...linkParamsSelectors,
+ ...preferencesSelectors,
+ },
+ initialState: merge(
+ {},
+ {
+ [ LINK_PARAMS_NAME ]: getInitialLinkParamsState(),
+ preferences: createInitialPreferencesState(),
+ },
+ initialState
+ ),
+ reducer: combineReducers( {
+ [ LINK_PARAMS_NAME ]: linkParamsReducer,
+ preferences,
+ } ),
+
+ } );
+};
+
+/**
+ * Registers the store to WP data's default registry.
+ * @param {Object} [initialState] Initial state.
+ * @returns {void}
+ */
+const registerStore = ( { initialState = {} } = {} ) => {
+ register( createStore( { initialState } ) );
+};
+
+export default registerStore;
diff --git a/packages/js/src/dashboard/store/preferences.js b/packages/js/src/dashboard/store/preferences.js
new file mode 100644
index 00000000000..08dec22f809
--- /dev/null
+++ b/packages/js/src/dashboard/store/preferences.js
@@ -0,0 +1,34 @@
+import { createSelector, createSlice } from "@reduxjs/toolkit";
+import { get } from "lodash";
+
+/**
+ * @returns {Object} The initial state.
+ */
+export const createInitialPreferencesState = () => ( {
+ ...get( window, "wpseoScriptData.preferences", {} ),
+} );
+
+const slice = createSlice( {
+ name: "preferences",
+ initialState: createInitialPreferencesState(),
+ reducers: {},
+} );
+
+export const preferencesSelectors = {
+ selectPreference: ( state, preference, defaultValue = {} ) => get( state, `preferences.${ preference }`, defaultValue ),
+ selectPreferences: state => get( state, "preferences", {} ),
+};
+preferencesSelectors.selectUpsellSettingsAsProps = createSelector(
+ [
+ state => preferencesSelectors.selectPreference( state, "upsellSettings", {} ),
+ ( state, ctbName = "premiumCtbId" ) => ctbName,
+ ],
+ ( upsellSettings, ctbName ) => ( {
+ "data-action": upsellSettings?.actionId,
+ "data-ctb-id": upsellSettings?.[ ctbName ],
+ } )
+);
+
+export const preferencesActions = slice.actions;
+
+export default slice.reducer;
diff --git a/src/dashboard/user-interface/general-page-integration.php b/src/dashboard/user-interface/general-page-integration.php
new file mode 100644
index 00000000000..4e20ed59055
--- /dev/null
+++ b/src/dashboard/user-interface/general-page-integration.php
@@ -0,0 +1,169 @@
+asset_manager = $asset_manager;
+ $this->current_page_helper = $current_page_helper;
+ $this->product_helper = $product_helper;
+ $this->shortlink_helper = $shortlink_helper;
+ }
+
+ /**
+ * Returns the conditionals based on which this loadable should be active.
+ *
+ * @return array
+ */
+ public static function get_conditionals() {
+ return [ Admin_Conditional::class ];
+ }
+
+ /**
+ * Initializes the integration.
+ *
+ * This is the place to register hooks and filters.
+ *
+ * @return void
+ */
+ public function register_hooks() {
+ if ( \apply_filters( 'wpseo_new_dashboard', false ) ) {
+ // Add page.
+ \add_filter( 'wpseo_submenu_pages', [ $this, 'add_page' ] );
+
+ // Are we on the dashboard page?
+ if ( $this->current_page_helper->get_current_yoast_seo_page() === self::PAGE ) {
+ \add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ] );
+ }
+ }
+ }
+
+ /**
+ * Adds the page.
+ *
+ * @param array> $pages The pages.
+ *
+ * @return array> The pages.
+ */
+ public function add_page( $pages ) {
+ \array_splice(
+ $pages,
+ 3,
+ 0,
+ [
+ [
+ 'wpseo_dashboard',
+ '',
+ \__( 'Dashboard New', 'wordpress-seo' ),
+ 'wpseo_manage_options',
+ self::PAGE,
+ [ $this, 'display_page' ],
+ ],
+ ]
+ );
+
+ return $pages;
+ }
+
+ /**
+ * Displays the page.
+ *
+ * @return void
+ */
+ public function display_page() {
+ echo '';
+ }
+
+ /**
+ * Enqueues the assets.
+ *
+ * @return void
+ */
+ public function enqueue_assets() {
+ // Remove the emoji script as it is incompatible with both React and any contenteditable fields.
+ \remove_action( 'admin_print_scripts', 'print_emoji_detection_script' );
+ \wp_enqueue_media();
+ $this->asset_manager->enqueue_script( 'new-dashboard' );
+ $this->asset_manager->enqueue_style( 'new-dashboard' );
+ $this->asset_manager->localize_script( 'dashboard', 'wpseoScriptData', $this->get_script_data() );
+ }
+
+ /**
+ * Creates the script data.
+ *
+ * @return array>> The script data.
+ */
+ public function get_script_data() {
+ return [
+ 'preferences' => [
+ 'isPremium' => $this->product_helper->is_premium(),
+ 'isRtl' => \is_rtl(),
+ 'pluginUrl' => \plugins_url( '', \WPSEO_FILE ),
+ 'upsellSettings' => [
+ 'actionId' => 'load-nfd-ctb',
+ 'premiumCtbId' => 'f6a84663-465f-4cb5-8ba5-f7a6d72224b2',
+ ],
+ ],
+ 'linkParams' => $this->shortlink_helper->get_query_params(),
+ ];
+ }
+}
diff --git a/tests/Unit/Dashboard/User_Interface/General_Page_Integration_Test.php b/tests/Unit/Dashboard/User_Interface/General_Page_Integration_Test.php
new file mode 100644
index 00000000000..b2e0789068a
--- /dev/null
+++ b/tests/Unit/Dashboard/User_Interface/General_Page_Integration_Test.php
@@ -0,0 +1,296 @@
+stubTranslationFunctions();
+
+ $this->asset_manager = Mockery::mock( WPSEO_Admin_Asset_Manager::class );
+ $this->current_page_helper = Mockery::mock( Current_Page_Helper::class );
+ $this->product_helper = Mockery::mock( Product_Helper::class );
+ $this->shortlink_helper = Mockery::mock( Short_Link_Helper::class );
+
+ $this->instance = new General_Page_Integration(
+ $this->asset_manager,
+ $this->current_page_helper,
+ $this->product_helper,
+ $this->shortlink_helper
+ );
+ }
+
+ /**
+ * Tests __construct method.
+ *
+ * @covers ::__construct
+ *
+ * @return void
+ */
+ public function test_construct() {
+ $this->assertInstanceOf(
+ Academy_Integration::class,
+ new Academy_Integration(
+ $this->asset_manager,
+ $this->current_page_helper,
+ $this->product_helper,
+ $this->shortlink_helper
+ )
+ );
+ }
+
+ /**
+ * Tests the retrieval of the conditionals.
+ *
+ * @covers ::get_conditionals
+ *
+ * @return void
+ */
+ public function test_get_conditionals() {
+ $this->assertEquals(
+ [
+ Admin_Conditional::class,
+ ],
+ General_Page_Integration::get_conditionals()
+ );
+ }
+
+ /**
+ * Provider for test_register_hooks
+ *
+ * @return array>
+ */
+ public static function register_hooks_provider() {
+ return [
+ 'Not on dashboard' => [
+ 'current_page' => 'not dashboard',
+ 'action_times' => 0,
+ ],
+ 'On dashboard page' => [
+ 'current_page' => 'wpseo_page_dashboard_new',
+ 'action_times' => 1,
+ ],
+ ];
+ }
+
+ /**
+ * Tests the registration of the hooks.
+ *
+ * @covers ::register_hooks
+ *
+ * @dataProvider register_hooks_provider
+ *
+ * @param string $current_page The current page.
+ * @param int $action_times The number of times the action should be called.
+ *
+ * @return void
+ */
+ public function test_register_hooks_on_dashboard_page( $current_page, $action_times ) {
+
+ Monkey\Functions\expect( 'add_filter' )
+ ->with( 'wpseo_submenu_page', [ $this->instance, 'add_page' ] )
+ ->once();
+
+ Monkey\Functions\expect( 'apply_filters' )
+ ->with( 'wpseo_new_dashboard', false )
+ ->once()->andReturnTrue();
+
+ $this->current_page_helper
+ ->expects( 'get_current_yoast_seo_page' )
+ ->once()
+ ->andReturn( $current_page );
+
+ Monkey\Functions\expect( 'add_action' )
+ ->with( 'admin_enqueue_scripts', [ $this->instance, 'enqueue_assets' ] )
+ ->times( $action_times );
+
+ $this->instance->register_hooks();
+ }
+
+ /**
+ * Tests the addition of the page to the submenu.
+ *
+ * @covers ::add_page
+ *
+ * @return void
+ */
+ public function test_add_page() {
+ $pages = $this->instance->add_page(
+ [
+ [ 'page1', '', 'Page 1', 'manage_options', 'page1', [ $this, 'display_page' ] ],
+ [ 'page2', '', 'Page 2', 'manage_options', 'page2', [ $this, 'display_page' ] ],
+ [ 'page3', '', 'Page 3', 'manage_options', 'page3', [ $this, 'display_page' ] ],
+ ]
+ );
+
+ // Assert that the new page was added at index 3.
+ $this->assertEquals( 'wpseo_dashboard', $pages[3][0] );
+ $this->assertEquals( '', $pages[3][1] );
+ $this->assertEquals( 'Dashboard New', $pages[3][2] );
+ $this->assertEquals( 'wpseo_manage_options', $pages[3][3] );
+ $this->assertEquals( 'wpseo_page_dashboard_new', $pages[3][4] );
+ $this->assertEquals( [ $this->instance, 'display_page' ], $pages[3][5] );
+ }
+
+ /**
+ * Test display_page
+ *
+ * @covers ::display_page
+ *
+ * @return void
+ */
+ public function test_display_page() {
+ $this->expectOutputString( '' );
+ $this->instance->display_page();
+ }
+
+ /**
+ * Test enqueue_assets
+ *
+ * @covers ::enqueue_assets
+ * @covers ::get_script_data
+ *
+ * @return void
+ */
+ public function test_enqueue_assets() {
+ Monkey\Functions\expect( 'remove_action' )
+ ->with( 'admin_print_scripts', 'print_emoji_detection_script' )
+ ->once();
+
+ Monkey\Functions\expect( 'wp_enqueue_media' )->once();
+
+ $this->asset_manager
+ ->expects( 'enqueue_script' )
+ ->with( 'new-dashboard' )
+ ->once();
+
+ $this->asset_manager
+ ->expects( 'enqueue_style' )
+ ->with( 'new-dashboard' )
+ ->once();
+
+ $this->asset_manager
+ ->expects( 'localize_script' )
+ ->once();
+
+ $this->expect_get_script_data();
+
+ $this->instance->enqueue_assets();
+ }
+
+ /**
+ * Expectations for get_script_data.
+ *
+ * @return array> The expected data.
+ */
+ public function expect_get_script_data() {
+ $link_params = [
+ 'php_version' => '8.1',
+ 'platform' => 'wordpress',
+ 'platform_version' => '6.2',
+ 'software' => 'free',
+ 'software_version' => '20.6-RC2',
+ 'days_active' => '6-30',
+ 'user_language' => 'en_US',
+ ];
+
+ $this->product_helper
+ ->expects( 'is_premium' )
+ ->once()
+ ->andReturn( false );
+
+ Monkey\Functions\expect( 'is_rtl' )->once()->andReturn( false );
+
+ Monkey\Functions\expect( 'plugins_url' )->once()->andReturn( 'http://basic.wordpress.test/wp-content/worspress-seo' );
+
+ $this->shortlink_helper
+ ->expects( 'get_query_params' )
+ ->once()
+ ->andReturn( $link_params );
+
+ return $link_params;
+ }
+
+ /**
+ * Test for get_script_data that is used in enqueue_assets.
+ *
+ * @covers ::get_script_data
+ *
+ * @return void
+ */
+ public function test_get_script_data() {
+ $link_params = $this->expect_get_script_data();
+ $expected = [
+ 'preferences' => [
+ 'isPremium' => false,
+ 'isRtl' => false,
+ 'pluginUrl' => 'http://basic.wordpress.test/wp-content/worspress-seo',
+ 'upsellSettings' => [
+ 'actionId' => 'load-nfd-ctb',
+ 'premiumCtbId' => 'f6a84663-465f-4cb5-8ba5-f7a6d72224b2',
+ ],
+ ],
+ 'linkParams' => $link_params,
+ ];
+
+ $this->assertSame( $expected, $this->instance->get_script_data() );
+ }
+}