---
title: Admin UI
---

The Mantle admin interface is a single-page React application mounted on one WordPress admin page. Routing is query-parameter based and driven entirely by a filtered array of tab objects, so adding a view never means editing a switch statement.

## Rendering pipeline

<!-- wp:docspress/diagram {"title":"From URL to rendered view","type":"flow","source":"admin.php?page=mantle -> Layouts: mount\nLayouts -> settings + setup data: load\nLayouts -> DashboardLayout: when setup complete\nLayouts -> SetupLayout: when setup incomplete\nDashboardLayout -> ViewWrapper: render\nViewWrapper -> view query param: read\nViewWrapper -> getTabs: resolve tab by id\nViewWrapper -> Card: wrap component with tab title","caption":"Layouts decides the shell; ViewWrapper resolves and renders the view."} /-->

<!-- wp:docspress/fields {"title":"The four moving parts","description":"","fields":[{"name":"src/constants/routes.js","type":"string","required":false,"defaultValue":"","description":"Declares the core TABS object and the helper functions that resolve tabs from an ID, a path, or the query string. Applies the <code>mantle.navigation_tabs</code> filter.","values":"","deprecated":false},{"name":"src/components/ViewWrapper.js","type":"string","required":false,"defaultValue":"","description":"Reads the <code>view</code> query parameter, resolves the tab, and renders its component inside a Card whose heading is the tab title.","values":"","deprecated":false},{"name":"src/layouts/index.js","type":"string","required":false,"defaultValue":"","description":"Loads settings and setup data, decides between DashboardLayout and SetupLayout, and shows a loading state while fetching.","values":"","deprecated":false},{"name":"src/modules/*/register.js","type":"string","required":false,"defaultValue":"","description":"Where each module adds its tab via the filter and its panel via a SlotFill.","values":"","deprecated":false}],"searchable":false,"compact":false} /-->

## Core tabs

`TABS` in `src/constants/routes.js` declares only two entries. Everything else arrives through the filter.

<!-- wp:docspress/colorful-code {"language":"javascript","filename":"src/constants/routes.js","code":"export const TABS = {\n\tDASHBOARD: {\n\t\tid: 'dashboard',\n\t\tpath: '/dashboard',\n\t\ttitle: __( 'Dashboard', 'mantle' ),\n\t\tcomponent: Dashboard,\n\t\ticon: DashboardIcon,\n\t\torder: 10,\n\t\trequiredCapability: 'mantle_view_dashboard',\n\t},\n\tSETTINGS: {\n\t\tid: 'settings',\n\t\tpath: '/settings',\n\t\ttitle: __( 'Settings', 'mantle' ),\n\t\tcomponent: Settings,\n\t\ticon: SettingsIcon,\n\t\torder: 100,\n\t\trequiredCapability: 'mantle_edit_settings',\n\t},\n};\n\nexport const getTabs = () => {\n\tconst filteredTabs = Object.values( TABS );\n\n\treturn applyFilters( 'mantle.navigation_tabs', filteredTabs );\n};","highlightedLines":"8,17,23,25","showLineNumbers":true,"caption":"Verbatim structure from src/constants/routes.js.","copyMode":"all","diffMode":"none","annotations":[{"line":8,"content":"Dashboard is order 10 — the lower bound for module tabs."},{"line":17,"content":"Settings is order 100 — the upper bound. Module tabs sit between."},{"line":23,"content":"<code>Object.values()</code> converts to an array before filtering."},{"line":25,"content":"So filter callbacks receive an array and must return one."}]} /-->

<!-- wp:docspress/callout {"tone":"warning","title":"The filter receives an array, not the TABS object","content":"<p>This is the single most common extension mistake. <code>getTabs()</code> calls <code>Object.values( TABS )</code> first, so a callback that spreads an object — <code>{ ...tabs, MY_TAB: {…} }</code> — returns something the resolvers cannot iterate as expected, and the tab silently never renders. Always spread into an array: <code>[ ...tabs, newTab ]</code>.</p>","collapsible":false} /-->

## Route resolution helpers

`routes.js` exports several resolvers. All of them call `getTabs()` internally, so they see filtered tabs including third-party ones.

<!-- wp:docspress/fields {"title":"Exported helpers","description":"From <code>src/constants/routes.js</code>.","fields":[{"name":"getTabs()","type":"array","required":false,"defaultValue":"","description":"Returns the filtered array of tab objects.","values":"","deprecated":false},{"name":"getTabIdFromQuery(url?)","type":"string","required":false,"defaultValue":"dashboard","description":"Reads the <code>view</code> query parameter and validates it against the tabs. Falls back to the Dashboard ID when absent or unrecognised.","values":"","deprecated":false},{"name":"getTabIdFromLocation(location)","type":"string","required":false,"defaultValue":"dashboard","description":"Resolves a tab from a React Router location's first path segment.","values":"","deprecated":false},{"name":"getTabIdFromPath(path)","type":"string","required":false,"defaultValue":"dashboard","description":"Resolves a tab ID from a path such as <code>/settings</code>.","values":"","deprecated":false},{"name":"getPathFromTabId(tabId)","type":"string","required":false,"defaultValue":"/dashboard","description":"The inverse: path for a given tab ID.","values":"","deprecated":false},{"name":"updateRouteQuery(tabId, replace?)","type":"any","required":false,"defaultValue":"","description":"Pushes or replaces history state. Deletes the <code>view</code> parameter entirely for the Dashboard tab, since it is the default.","values":"","deprecated":false},{"name":"getViewComponent(viewId)","type":"any","required":false,"defaultValue":"null","description":"Returns the component for a tab ID, or null.","values":"","deprecated":false},{"name":"getDefaultViewComponent()","type":"any","required":false,"defaultValue":"Dashboard","description":"Always returns the Dashboard component.","values":"","deprecated":false}],"searchable":true,"compact":false} /-->

<!-- wp:docspress/callout {"tone":"note","title":"An unrecognised view falls back rather than 404s","content":"<p><code>getTabIdFromQuery()</code> validates the query value against the resolved tabs and returns the Dashboard ID when there is no match. A mistyped or capability-hidden <code>?view=</code> therefore renders the Dashboard rather than an error — convenient for users, occasionally confusing when debugging a tab that is not registering.</p>","collapsible":false} /-->

Dashboard is special-cased in `updateRouteQuery()`: navigating to it removes the `view` parameter instead of setting `view=dashboard`, keeping the canonical URL clean.

## Settings subtabs

The Settings view has its own nested tab system, filtered separately and gated by module state.

<!-- wp:docspress/diagram {"title":"Settings subtab resolution","type":"flow","source":"Settings view -> defaultTabs: general only\ndefaultTabs -> mantle.settings_tabs: apply filter\nmantle.settings_tabs -> rawTabs: filtered list\nrawTabs -> module match: compare name to module slug\nmodule match -> filteredTabs: drop inactive modules\nfilteredTabs -> MantleSettingsTabs: render matching fill","caption":"A subtab named after a module ID is hidden automatically when that module is inactive."} /-->

The filter receives a second argument of `{ settings, modules }`, so a callback can decide what to add based on current state rather than relying on the automatic gating.

<!-- wp:docspress/callout {"tone":"tip","title":"Name a Settings subtab after its module ID","content":"<p>After the filter runs, the Settings view drops any subtab whose <code>name</code> matches the <code>slug</code>, <code>id</code>, or <code>name</code> of an inactive module. Naming your subtab after your module ID gives you module-aware visibility with no extra code. The built-in Modules subtab is named <code>modules-list</code> precisely so it is never gated away.</p>","collapsible":false} /-->

## Capability gating is presentational

`requiredCapability` controls whether a tab is shown. It does not protect anything.

<!-- wp:docspress/callout {"tone":"danger","title":"Never rely on requiredCapability for authorisation","content":"<p>Hiding a tab removes an affordance from the navigation; the underlying REST routes remain reachable by anyone who can construct the request. Every route must declare its own <code>permission_callback</code>. Treat the JavaScript capability check as UI polish and the PHP check as the security boundary.</p>","collapsible":false} /-->

## Data layer

The admin app reads and writes through WordPress data stores in `src/stores/`, and the Settings view consumes a `useMantleSettings()` hook that exposes `settings`, `modules`, `invalidate`, and `saveSite`. Preloaded REST responses are supplied from PHP through the `mantle_admin_preload_paths` filter, so the first render does not wait on network requests.

<!-- wp:docspress/fields {"title":"PHP filters that shape the admin app","description":"Applied in <code>Controller\\Admin</code>.","fields":[{"name":"mantle_admin_data","type":"array","required":false,"defaultValue":"","description":"The data object handed to the JavaScript application, including REST paths.","values":"","deprecated":false},{"name":"mantle_admin_preload_paths","type":"array","required":false,"defaultValue":"","description":"REST paths preloaded into the page so the first render is instant. Add your module's route here.","values":"","deprecated":false},{"name":"mantle_admin_strings","type":"array","required":false,"defaultValue":"","description":"Localised strings passed to the application.","values":"","deprecated":false},{"name":"mantle.admin_submenus","type":"array","required":false,"defaultValue":"","description":"WordPress admin submenu entries. <code>Abstract_Module::init()</code> hooks each enabled module's <code>add_menu_item()</code> into this.","values":"","deprecated":false}],"searchable":false,"compact":false} /-->

## Stale documentation warning

<!-- wp:docspress/callout {"tone":"note","title":"Older notes reference src/js/admin/","content":"<p>Documentation predating the 2.0 restructure describes paths under <code>src/js/admin/</code>. That directory does not exist. The current locations are <code>src/constants/routes.js</code>, <code>src/components/ViewWrapper.js</code>, <code>src/layouts/index.js</code>, and <code>src/modules/</code>. This page reflects the tree as of version 2.1.23.</p>","collapsible":false} /-->

## See also

- [Extending the admin UI](../guides/extending-the-admin-ui.md)
- [SlotFills reference](../reference/slotfills.md)
- [Hooks reference](../reference/hooks.md)
