Delivering a generic, unconfigured WordPress dashboard to a client is like handing over the keys of an aircraft to someone who only needs to drive a car. The default administration area contains developer-facing options (such as Settings, Tools, and Plugins) that confuse non-technical users, increase the risk of accidental site breakages, and trigger unnecessary support tickets. A generic dashboard says you installed a theme. A customized one says you built a working tool for the roles that actually log in.
This guide is written for developers who hand sites over to editors, shop managers and client roles. It covers loading admin CSS and JS, menu cleanup, capability checks, custom roles, settings pages, color schemes, the login screen, isolation in a must-use plugin, and how to avoid breaking core screens. The official references for loading assets and building menus are admin_enqueue_scripts and admin_menu. Learn more about our professional WordPress development services to build custom admin architectures.
Why admin customization reduces support tickets
After the handover of a WooCommerce store built with a page builder, the client often sees dozens of menu items and uses a handful. The rest generates questions: “can I click Themes?”, “what is ACF?”, “why do I see WooCommerce > Status?”.
Removing Tools, Settings and the file editors for everyone without manage_options is not about prettier colors. It takes dangerous paths out of sight for people who do not have full access.
The second gain is onboarding. Instead of an hour of “do not click here”, you show one Support page with documentation, a contact and a short publishing checklist. That is white-label as a product, not just a logo swap.
Admin customization in a must-use plugin instead of functions.php
Do not put admin customization in the child theme’s functions.php. When the client switches themes, the menu rules, asset loading and capability checks disappear with it. Agency code that has to survive a theme change belongs in a must-use plugin under wp-content/mu-plugins/.
A typical structure:
wp-content/mu-plugins/
wppoland-admin-ux.php # bootstrap (loads the folder)
wppoland-admin-ux/
menu.php
enqueue.php
capabilities.php
dashboard.phpThe bootstrap file in the root of mu-plugins/ loads automatically. It only requires the files inside the folder. The advantage is that the code always runs, whatever regular plugins are active, and you can version the whole folder without anyone being able to deactivate it from the Plugins screen.
<?php
/**
* Plugin Name: WPPoland Admin UX
* Description: Menu cleanup, asset loading and support pages for client roles.
* Author: wppoland.com
*/
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$dir = __DIR__ . '/wppoland-admin-ux';
require_once $dir . '/menu.php';
require_once $dir . '/enqueue.php';
require_once $dir . '/capabilities.php';
require_once $dir . '/dashboard.php';Always test with a separate user account that has the client role (Editor, Shop Manager or a custom role). Logging in as Super Admin on staging hides the bugs you just “fixed” for the client.
If the project already has an agency plugin with Composer autoloading, the mu-plugin bootstrap can simply load that package. The point stays the same: admin UX must not die when an Elementor child theme gets replaced during a redesign.
Keep the files small. One file for the menu, one for assets, one for blocking. When a later developer needs to find out why the ACF menu disappeared, they should not have to dig through an 800-line functions.php.
How a must-use plugin differs from a regular plugin
The Advanced Administration handbook lists the differences, and several of them change how admin code should be written:
- Must-use plugins are enabled on every site in the installation and cannot be disabled from wp-admin.
- PHP loads them in alphabetical order, before normal plugins.
- Files in subdirectories are not included automatically. That is why the structure above needs a bootstrap file in the root.
- They do not show in the default plugin list on the Plugins screen, but in a separate Must-Use list that only users with access to Plugins can see.
- They do not appear in update notifications, so you have to track new versions yourself.
- Activation hooks are not executed for plugins in the must-use folder.
The last point is the one that most often surprises people. Code a regular plugin would put in register_activation_hook, such as creating roles, needs its own version check in a must-use plugin. The next sections show the pattern.
How to check capabilities before changing the admin menu
A common error when customizing the admin panel is relying solely on remove_menu_page() to restrict access. remove_menu_page and add_menu_page are visibility APIs. They do not replace access control. If a user types the direct URL into the address bar (for example wp-admin/options-general.php), they can still change settings if they have the manage_options capability.
Rules that hold up in production:
- Check
current_user_can( 'manage_options' )(or a more specific capability) before hiding menus for client roles. - Never hide menus from the person who has to debug the site. An administrator without a visible menu costs more time than it saves.
- When you really need to block a screen, use
admin_initormap_meta_capand returnwp_die()or a redirect with an explanation, not just CSSdisplay: none.
For editorial teams where freelance writers and staff editors share the site, it is worth creating a dedicated role with edit_posts and upload_files, without install_plugins and edit_themes. The menu cleanup then mirrors that role instead of becoming a long list of exceptions by user ID. get_role( 'editor' )->remove_cap( '...' ) belongs in a one-off migration routine or a CLI command, not in a request hook that runs on every admin page.
/**
* Block direct URL access to Tools for non-administrators.
*/
function wppoland_block_tools_screen(): void {
if ( ! is_admin() || wp_doing_ajax() ) {
return;
}
if ( current_user_can( 'manage_options' ) ) {
return;
}
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
if ( ! $screen || 'tools' !== $screen->id ) {
return;
}
wp_die(
esc_html__( 'You do not have access to this screen.', 'wppoland-admin-ux' ),
esc_html__( 'Access denied', 'wppoland-admin-ux' ),
[ 'response' => 403 ]
);
}
add_action( 'current_screen', 'wppoland_block_tools_screen' );Capability names are the contract. User IDs change, but “Shop Manager” keeps manage_woocommerce. Build rules around capabilities, not around user ID 12.
Restrict WordPress admin access with a custom role
To secure the site, register a custom user role with specific primitive capabilities. Roles are stored in the database, not in code. The add_role reference notes that the function stops and returns null if the role already exists, so a changed capability list has no effect until the role is removed and added again. Without a version check that would happen on every request, and since must-use plugins have no activation hook, a stored version value is the simplest guard.
function wppoland_register_client_role(): void {
if ( '2' === get_option( 'wppoland_roles_version' ) ) {
return;
}
remove_role( 'client_manager' );
add_role(
'client_manager',
'Client Manager',
[
'read' => true,
'edit_posts' => true,
'edit_published_posts' => true,
'publish_posts' => true,
'delete_posts' => true,
'edit_pages' => true,
'edit_published_pages' => true,
'edit_others_pages' => true,
'publish_pages' => true,
'upload_files' => true,
'moderate_comments' => true,
]
);
update_option( 'wppoland_roles_version', '2' );
}
add_action( 'init', 'wppoland_register_client_role' );Capabilities that are not in the list are simply not granted. That is safer than setting manage_options => false explicitly, because a forgotten key then opens nothing. When the list changes, bump the version value.
How to block edits to key pages with map_meta_cap
Some pages should stay locked even when the role otherwise has edit_others_pages, typically the front page. The map_meta_cap filter translates meta capabilities such as edit_post into primitive capabilities, and there you can add do_not_allow for one specific page:
function wppoland_protect_front_page( array $caps, string $cap, int $user_id, array $args ): array {
if ( ! in_array( $cap, [ 'edit_post', 'delete_post' ], true ) || empty( $args[0] ) ) {
return $caps;
}
$front_page = (int) get_option( 'page_on_front' );
if ( $front_page && (int) $args[0] === $front_page && ! user_can( $user_id, 'manage_options' ) ) {
$caps[] = 'do_not_allow';
}
return $caps;
}
add_filter( 'map_meta_cap', 'wppoland_protect_front_page', 10, 4 );The advantage over hiding the Edit link is that the rule applies everywhere: in the pages list, in the block editor, in the REST API and in Quick Edit.
How to hide admin menu items in WordPress
Once correct capabilities are configured, the first visible step is remove_menu_page on admin_menu with a late priority, so plugins have registered their menus before you remove them. The hook documentation is at admin_menu.
/**
* Clean up the admin menu for non-administrators.
*/
function wppoland_clean_admin_menu(): void {
if ( current_user_can( 'manage_options' ) ) {
return;
}
remove_menu_page( 'tools.php' );
remove_menu_page( 'options-general.php' );
remove_menu_page( 'edit-comments.php' );
remove_menu_page( 'edit.php?post_type=acf-field-group' );
remove_submenu_page( 'themes.php', 'theme-editor.php' );
remove_submenu_page( 'plugins.php', 'plugin-editor.php' );
}
add_action( 'admin_menu', 'wppoland_clean_admin_menu', 999 );Warning: remove_menu_page only hides the link. Always combine it with a capability block where the path is dangerous (file editors, ACF field groups, plugin installation).
For WooCommerce stores it is common to let the Shop Manager keep Products, Orders and Coupons but remove the Status, Extensions and system status submenus. That cuts “can I update this plugin?” questions without taking away what daily operations need.
Priority 999 is deliberate. Many plugins register menus on admin_menu at the default priority 10. If you run the cleanup too early, the slug does not exist yet and remove_menu_page does nothing. Temporarily log $GLOBALS['menu'] on staging if you are unsure which slug a plugin uses. ACF, Yoast and WooCommerce all have non-obvious paths.
Do not remove the Media Library for editors who actually upload images. That is classic over-cleanup: the panel looks tidy, and the next day a ticket arrives saying “Upload” is missing. Clean up technical tools first and content paths last.
Add custom admin menu pages
Avoid heavy options frameworks if you only need one support page. The core API is fast and predictable.
function wppoland_register_support_page(): void {
add_menu_page(
'Client support',
'Support',
'edit_posts',
'wppoland-support',
'wppoland_render_support',
'dashicons-sos',
90
);
}
add_action( 'admin_menu', 'wppoland_register_support_page' );
function wppoland_render_support(): void {
if ( ! current_user_can( 'edit_posts' ) ) {
wp_die( esc_html__( 'Access denied', 'wppoland-admin-ux' ) );
}
?>
<div class="wrap">
<h1><?php echo esc_html__( 'Need help?', 'wppoland-admin-ux' ); ?></h1>
<div class="card">
<h2><?php echo esc_html__( 'Contact your developer', 'wppoland-admin-ux' ); ?></h2>
<p>
<?php echo esc_html__( 'Email:', 'wppoland-admin-ux' ); ?>
<a href="mailto:[email protected]">dev@wppoland.com</a>
</p>
</div>
</div>
<?php
}Keep the callback simple: documentation links, a publishing checklist, backup status if you already have that data. Do not build a mini CMS inside the Support page.
Use add_submenu_page under an existing custom post type menu when the help concerns one content type (for example “Events > Help”). Reserve top-level menus for things the client opens every week. Too many top-level icons recreate the chaos you just cleaned up.
How to build a client settings page with the Settings API
To allow clients to configure specific site properties (like social links or support phone numbers) without giving them access to general WordPress options, build a custom settings page using the Settings API. According to the handbook, WordPress then handles storing the submitted form data, adds nonces, and sends the submission through wp-admin/options.php, which applies strict capability checking.
function wppoland_register_client_settings(): void {
register_setting(
'wppoland_client',
'wppoland_support_phone',
[
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'default' => '',
]
);
add_settings_section( 'wppoland_contact', 'Contact', '__return_false', 'wppoland-client-settings' );
add_settings_field(
'wppoland_support_phone',
'Support phone',
'wppoland_render_phone_field',
'wppoland-client-settings',
'wppoland_contact'
);
}
add_action( 'admin_init', 'wppoland_register_client_settings' );
function wppoland_render_phone_field(): void {
printf(
'<input type="text" name="wppoland_support_phone" value="%s" class="regular-text">',
esc_attr( (string) get_option( 'wppoland_support_phone', '' ) )
);
}
function wppoland_add_client_settings_page(): void {
add_menu_page(
'Site settings',
'Site',
'edit_pages',
'wppoland-client-settings',
'wppoland_render_client_settings',
'dashicons-admin-generic',
91
);
}
add_action( 'admin_menu', 'wppoland_add_client_settings_page' );
function wppoland_render_client_settings(): void {
?>
<div class="wrap">
<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
<?php settings_errors(); ?>
<form action="options.php" method="post">
<?php
settings_fields( 'wppoland_client' );
do_settings_sections( 'wppoland-client-settings' );
submit_button();
?>
</form>
</div>
<?php
}
add_filter( 'option_page_capability_wppoland_client', fn() => 'edit_pages' );The last line is the one that is usually missing. The core file wp-admin/options.php states that the options groups for all registered settings require manage_options by default, and that the option_page_capability_{$option_page} filter changes the requirement for one group. Without the filter, the editor sees the page, fills in the field and gets an error on save. The filter opens only the wppoland_client group, not the rest of Settings. For stricter input rules, a custom sanitize_callback can reject a value and report it with add_settings_error(), which settings_errors() then displays.
Load admin CSS and JS with admin_enqueue_scripts, not admin_head
admin_head with an inline <style> block works in demos but scales badly: no versioning, awkward cache busting, and presentation mixed into PHP. Use admin_enqueue_scripts, load your own files, and limit them to the screens that need them. Hook reference: admin_enqueue_scripts.
function wppoland_admin_assets( string $hook_suffix ): void {
// Limit to our own pages and the dashboard, not every core screen.
$allowed = [
'index.php',
'toplevel_page_wppoland-support',
];
if ( ! in_array( $hook_suffix, $allowed, true ) ) {
return;
}
$base = plugin_dir_url( __FILE__ );
$ver = '2026.09.21';
wp_enqueue_style(
'wppoland-admin-ux',
$base . 'assets/admin.css',
[],
$ver
);
wp_enqueue_script(
'wppoland-admin-ux',
$base . 'assets/admin.js',
[ 'jquery' ],
$ver,
true
);
wp_localize_script(
'wppoland-admin-ux',
'wppolandAdminUx',
[
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'wppoland_admin_ux' ),
]
);
}
add_action( 'admin_enqueue_scripts', 'wppoland_admin_assets' );$hook_suffix is the key to not breaking core screens. A global stylesheet that hides .notice on every admin page also removes core warnings about critical security updates. Scope selectors to your own wrappers (.wppoland-support-wrap) or your own hook suffixes.
For brand colors in the admin bar, override a few documented selectors and test both the classic and the block editor. Gutenberg ships its own UI: a broad #wpadminbar { background } is fine, while !important on .components-button is a recipe for a broken editor.
/* assets/admin.css: keep the scope narrow */
#wpadminbar {
background: #1f2937;
}
#toplevel_page_wppoland-support .wp-menu-image::before {
color: #059669;
}
.wppoland-support-wrap .card {
max-width: 40rem;
}When you need a script on one screen only, wp_enqueue_script inside admin_enqueue_scripts with an early return is the safest model. Avoid calling wp_print_scripts directly in the add_menu_page callback. It breaks dependency order and makes the script harder to disable later.
If admin.js talks to REST, use wp_create_nonce( 'wp_rest' ) and wpApiSettings where it already exists, or localize your own nonce as in the example above. Never assume “logged-in users only” is enough without a nonce check on the server.
Change WordPress admin colors with a custom color scheme
The colors of the sidebar and the toolbar come from the color scheme each user picks in their profile. Instead of overriding them with !important, you can register your own scheme with wp_admin_css_color. The function takes a unique key, a name, the URL of the CSS file containing the scheme, an array of colors used as a preview in the profile, and optional SVG icon colors (base, focus and current).
function wppoland_register_admin_scheme(): void {
wp_admin_css_color(
'wppoland',
'WPPoland',
plugin_dir_url( __FILE__ ) . 'assets/admin-colors.css',
[ '#1f2937', '#059669', '#d1fae5', '#ffffff' ],
[
'base' => '#d1d5db',
'focus' => '#ffffff',
'current' => '#ffffff',
]
);
}
add_action( 'admin_init', 'wppoland_register_admin_scheme' );Core registers its own schemes on the same hook: register_admin_color_schemes is attached to admin_init at priority 1 in wp-admin/includes/admin-filters.php. The choice is stored as the admin_color user option, and the admin gets the body class admin-color- plus the key, so you can target extra rules at your scheme only.
You can force the scheme for everyone with the get_user_option_admin_color filter, but that takes the choice away from users. Some pick a scheme because its contrast suits their eyesight. Your scheme as one of the options is a safer default than locking everyone into it. For the block editor, enqueue a separate stylesheet on enqueue_block_editor_assets so editor typography and widths match the front end.
Common WordPress admin customization mistakes
Three mistakes that keep showing up in production:
- Global notice hiding.
.notice { display: none }removes both plugin spam and critical core warnings. Filter by plugin class or useremove_actionon knownadmin_noticescallbacks instead of blanket hiding. - Removing Plugins or Updates for everyone. Client roles should not install plugins. Administrators must. If you hide Updates for
manage_options, you lose visibility of security releases. - Rewriting Settings API screens. Do not replace
options-general.phpwith your own “simplified” page unless you also mirror every required option and its validation. An incomplete copy breaks permalinks, the site URL and the language setting.
When you customize the block editor, use enqueue_block_editor_assets separately from admin_enqueue_scripts, and load only what the editor needs. Never pull large jQuery UI bundles into post.php without a reason.
A practical regression check: open the post list view, Gutenberg for a page, the Media Library, WooCommerce > Orders and your Support page, as both Administrator and Editor. If any of those screens loses buttons, notices or keyboard navigation, roll back the CSS before you hand over. “White-label” that breaks core UI is not finished work.
How to customize the WordPress admin bar
The admin bar at the top of the viewport is visible on the front end for logged-in users and displays generic WordPress links. Customizing it removes those links and makes room for quick actions such as “Clear cache”, protected by a nonce and a capability check, not an open GET URL.
function wppoland_customize_toolbar( WP_Admin_Bar $wp_admin_bar ): void {
$wp_admin_bar->remove_node( 'wp-logo' );
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$wp_admin_bar->add_node(
[
'id' => 'wppoland-clear-cache',
'title' => 'Clear cache',
'href' => wp_nonce_url(
admin_url( 'admin-post.php?action=wppoland_clear_cache' ),
'wppoland_clear_cache'
),
'meta' => [ 'title' => 'Flush the object cache' ],
]
);
}
add_action( 'admin_bar_menu', 'wppoland_customize_toolbar', 999 );Handle admin_post_wppoland_clear_cache with check_admin_referer and redirect back. Without a nonce, the quick button becomes a CSRF target.
How to add a custom dashboard widget in WordPress
When clients log in, they land on the Dashboard. The default WordPress dashboard displays widgets like “Quick Draft” and “WordPress Events and News”, which are irrelevant to most business owners. Replace them with the status the client actually asks about: backups, uptime, last published content.
function wppoland_dashboard_widgets(): void {
remove_meta_box( 'dashboard_primary', 'dashboard', 'side' );
remove_meta_box( 'dashboard_quick_press', 'dashboard', 'side' );
wp_add_dashboard_widget(
'wppoland_status_widget',
'Site status',
'wppoland_render_status_widget'
);
}
add_action( 'wp_dashboard_setup', 'wppoland_dashboard_widgets' );
function wppoland_render_status_widget(): void {
$published_pages = wp_count_posts( 'page' )->publish;
echo '<p><strong>WordPress</strong>: ' . esc_html( get_bloginfo( 'version' ) ) . '</p>';
echo '<p><strong>PHP</strong>: ' . esc_html( phpversion() ) . '</p>';
echo '<p><strong>Published pages</strong>: ' . esc_html( (string) $published_pages ) . '</p>';
echo '<p><strong>Backups</strong>: confirm the last job in the backup plugin</p>';
echo '<p><a href="' . esc_url( admin_url( 'admin.php?page=wppoland-support' ) ) . '">Open support</a></p>';
}Do not hardcode “Up to date” with a green check mark if you are not reading the real version. A status widget that lies is worse than no widget.
Widget callbacks should be cheap. Do not call external APIs synchronously while the dashboard renders. If you need traffic figures, cache the result in a transient and show when it was last fetched, otherwise the dashboard gets slow exactly when the client logs in to “just check quickly”. Core does the same thing itself.
What the default dashboard widgets actually do
A common reason given for removing the default widgets is speed. The source of wp-admin/includes/dashboard.php shows the gain is small:
- WordPress Events and News (
dashboard_primary) loads its feed through a cached widget. The rendered HTML is stored in a transient per locale, and when that cache is empty the widget shows a loading placeholder and fetches the content over AJAX instead of holding up the page. - Quick Draft (
dashboard_quick_press) reuses one auto-draft per user, with its ID stored in the user optiondashboard_quick_press_last_post_id. Auto-drafts older than seven days are deleted bywp_delete_auto_drafts, which runs on the scheduledwp_scheduled_auto_draft_deleteevent. - Site Health Status (
dashboard_site_health) reads a stored result from thehealth-check-site-status-resulttransient instead of running every test on each view.
The case for removing them is focus, not speed. Consider keeping Site Health for administrators, because it surfaces warnings about the PHP version and missing modules that are otherwise easy to miss.
Custom WordPress login logo and failed login logging
The login screen is the primary entry point to your admin panel and the first thing the client sees every day. Three hooks in wp-login.php cover most of it:
login_enqueue_scripts(since 3.1) is the hook for stylesheets and scripts on the login page, the counterpart ofadmin_enqueue_scriptsin wp-admin.login_headerurlcontrols where the logo links. The default is wordpress.org.login_headertext(since 5.2) controls the link text of the logo. The olderlogin_headertitlefilter is deprecated since 5.2, with the note that a title attribute on the login logo is not recommended for accessibility reasons.
function wppoland_login_assets(): void {
wp_enqueue_style(
'wppoland-login',
plugin_dir_url( __FILE__ ) . 'assets/login.css',
[],
'2026.10.14'
);
}
add_action( 'login_enqueue_scripts', 'wppoland_login_assets' );
add_filter( 'login_headerurl', fn() => home_url( '/' ) );
add_filter( 'login_headertext', fn() => get_bloginfo( 'name' ) );
/**
* Logs failed login attempts so a security tool or a person can review them.
*/
function wppoland_log_failed_logins( $user, string $username, string $password ) {
if ( is_wp_error( $user ) && '' !== $username ) {
error_log(
sprintf(
'WPPoland Security: failed login for user "%s" from IP %s',
sanitize_text_field( $username ),
sanitize_text_field( $_SERVER['REMOTE_ADDR'] ?? 'UNKNOWN' )
)
);
}
return $user;
}
add_filter( 'authenticate', 'wppoland_log_failed_logins', 30, 3 );/* assets/login.css */
.login h1 a {
background-image: url("../img/client-logo.svg");
background-size: contain;
width: 100%;
height: 80px;
}Screen readers read the link text, so the site name is a better choice than an empty string. The logging filter only records failures; it does not block anything, so pair it with rate limiting at the server or in a security plugin. Test the page with a two-factor or SSO plugin active, because those add their own fields that overly tight CSS can push off screen.
What to check before handing over a customized WordPress admin
Run through this with a client-role account on staging:
- The menu shows only what the role needs; the administrator still sees everything.
- Direct URLs to Tools, Settings or the file editor are blocked where intended.
- Admin CSS and JS load only on the allowed
$hook_suffixvalues. - The block editor and the Media Library work without broken buttons or hidden notices.
- Toolbar actions require a nonce and the right capability.
- The code lives in a mu-plugin (or a dedicated agency plugin), not in the theme.
- The settings page saves with the client role, not only as administrator.
- The login logo links to the site and has readable link text.
Document in the project README which menus are hidden per role and which mu-plugin owns the rules. The next developer (or you in six months) should not have to reverse-engineer admin_menu callbacks to find out why Settings is missing for the Shop Manager.
How to white-label the WordPress admin in 90 days
Follow this timeline to brand and secure the site’s administration area:
- Days 1-30 (access control): Register client roles with minimal capabilities behind a version check. Add menu cleanup hooks in a must-use plugin to hide unused options from non-administrators. Use
map_meta_capto protect key pages (such as the homepage or contact page) from being edited or deleted by restricted roles. - Days 31-60 (dashboard and settings): Build dashboard widgets that show real status and support links. Use the Settings API for a dedicated settings page with validation, and open its options group to the client role with the capability filter. Add toolbar shortcuts protected by nonces.
- Days 61-90 (branding and login): Load admin styles through
admin_enqueue_scripts, register a color scheme, brand the login screen, and log failed login attempts. Run the handover checklist above with every client role.
Need help building a custom, white-label admin experience or securing user access controls? Our WordPress development team can audit user roles, build custom settings pages, and tidy up your dashboard layout. Contact us to discuss your project requirements.







