How to add a custom admin page in WordPress is one of the most useful skills when building plugins, internal tools, client dashboards or administration workflows that do not fit naturally inside an existing WordPress screen.
WordPress provides native APIs for registering pages inside wp-admin. A custom page can appear as its own top-level menu item, live underneath an existing menu or remain accessible without occupying permanent navigation space.
The basic architecture looks like this:
Plugin or theme code
↓
admin_menu hook
↓
register admin page
↓
assign capability
↓
render callback
↓
custom wp-admin screen
Creating the page itself is relatively straightforward.
Creating it correctly requires understanding several separate concerns:
- menu registration;
- page slugs;
- capabilities;
- render callbacks;
- forms and nonces;
- CSS and JavaScript loading;
- AJAX and REST requests;
- role-based access;
- direct URL access;
- maintainable plugin architecture.
This guide walks through each layer and shows how to build custom WordPress admin pages without confusing menu visibility with real authorization.
What is a custom WordPress admin page?
A custom admin page is a screen registered inside the WordPress administration area by a plugin, theme or custom integration.
It normally uses a URL similar to:
https://example.com/wp-admin/admin.php?page=my-custom-page
The page query parameter identifies the registered admin page.
A custom page can contain almost anything appropriate for an administrative interface:
- plugin settings;
- reports;
- analytics;
- import and export tools;
- content-management interfaces;
- internal documentation;
- workflow controls;
- API configuration;
- database utilities;
- client-specific dashboards.
The important distinction is that an admin page is not simply an arbitrary PHP file placed somewhere inside a plugin.
WordPress should register and route the page through its administration system.
How WordPress admin pages are registered
The usual registration process happens on the admin_menu hook:
add_action( 'admin_menu', 'example_register_admin_page' );
function example_register_admin_page() {
// Register the page here.
}
The admin_menu hook runs while WordPress builds the administration menu.
The official admin_menu documentation describes the hook used to add administrative menu pages.
Conceptually:
WordPress loads wp-admin
↓
admin_menu fires
↓
plugins register menu pages
↓
WordPress builds navigation
↓
authorized page becomes available
This is also why menu architecture matters when several plugins register their own screens.
For broader menu organization, see How to Reorganize the WordPress Admin Menu.
Creating a top-level admin page
WordPress provides add_menu_page() for creating a top-level administration page.
A minimal example is:
add_action( 'admin_menu', 'example_register_admin_page' );
function example_register_admin_page() {
add_menu_page(
'Example Settings',
'Example',
'manage_options',
'example-settings',
'example_render_admin_page'
);
}
function example_render_admin_page() {
echo '<div class="wrap">';
echo '<h1>' . esc_html__( 'Example Settings', 'example-plugin' ) . '</h1>';
echo '</div>';
}
The official add_menu_page() documentation defines the parameters and behavior of the function.
The page now has several important properties:
Page title:
Example Settings
Menu title:
Example
Required capability:
manage_options
Menu slug:
example-settings
Render callback:
example_render_admin_page
Understanding the add_menu_page() parameters
A more complete call looks like:
add_menu_page(
$page_title,
$menu_title,
$capability,
$menu_slug,
$callback,
$icon_url,
$position
);
Each argument controls a different part of the page.
Page title
The first parameter is the title associated with the administration page:
'Example Settings'
It is not necessarily identical to the label shown in the navigation.
Menu title
The second parameter controls the visible menu label:
'Example'
Keeping menu labels concise usually produces cleaner navigation.
Capability
The third parameter controls which users are allowed to access the page:
'manage_options'
This is one of the most important parameters in the entire registration process.
Menu slug
The fourth parameter identifies the page:
'example-settings'
The resulting URL normally resembles:
/wp-admin/admin.php?page=example-settings
Callback
The callback generates the page output:
'example_render_admin_page'
Icon
A Dashicon can be supplied:
'dashicons-admin-generic'
Position
The final parameter can influence where the menu appears.
Avoid relying too heavily on precise menu positions because many plugins can register items into the same administration menu.
A more complete top-level page example
A practical registration might look like:
add_action( 'admin_menu', 'example_register_admin_page' );
function example_register_admin_page() {
add_menu_page(
__( 'Example Dashboard', 'example-plugin' ),
__( 'Example', 'example-plugin' ),
'manage_options',
'example-dashboard',
'example_render_admin_page',
'dashicons-chart-area',
65
);
}
function example_render_admin_page() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die(
esc_html__(
'You do not have permission to access this page.',
'example-plugin'
)
);
}
?>
<div class="wrap">
<h1>
<?php
echo esc_html(
get_admin_page_title()
);
?>
</h1>
<p>
<?php
esc_html_e(
'Welcome to the custom administration page.',
'example-plugin'
);
?>
</p>
</div>
<?php
}
The registration capability and the callback capability check reinforce the same authorization requirement.
Capabilities are more important than role names
A common mistake is to write:
if ( in_array( 'administrator', $user->roles, true ) ) {
// Allow access.
}
WordPress authorization is designed around capabilities.
Prefer:
if ( current_user_can( 'manage_options' ) ) {
// Allow access.
}
The official current_user_can() documentation describes how to check whether the current user possesses a capability.
Roles answer:
What collection of permissions
does this user normally belong to?
Capabilities answer:
May this user perform
this particular operation?
For the underlying permission architecture, see WordPress User Roles and Capabilities Explained.
Do not automatically use manage_options for every page
Many examples use:
manage_options
because it is convenient for administrator-oriented settings pages.
That does not mean every custom admin page should require it.
Imagine a custom editorial page:
Editorial Dashboard
If editors legitimately need access, forcing manage_options may be unnecessarily broad.
The correct capability should reflect what the page actually allows the user to do.
For example:
edit_posts
upload_files
edit_others_posts
manage_categories
or a dedicated custom capability:
manage_example_reports
The principle is:
Page capability
=
minimum authority required
for the page's actual operations
Custom capabilities can provide cleaner access control
For a specialized administration feature, a dedicated capability can be clearer than reusing a broad core capability.
For example:
manage_example_dashboard
A role can then receive that capability without receiving unrelated administrative powers.
This produces:
Role
↓
dedicated capability
↓
custom admin page
instead of:
Role
↓
large generic capability
↓
many unrelated privileges
TheOneWP’s Role Manager can help manage custom role and capability structures when a site needs more granular administrative access.
For a practical role design workflow, see Creating a Custom WordPress Role Safely.
Creating a submenu admin page
Not every page deserves a top-level menu item.
WordPress provides add_submenu_page() for registering pages underneath existing administration menus.
The general form is:
add_submenu_page(
$parent_slug,
$page_title,
$menu_title,
$capability,
$menu_slug,
$callback
);
The official add_submenu_page() documentation describes its parameters.
For example:
add_action( 'admin_menu', 'example_register_submenu_page' );
function example_register_submenu_page() {
add_submenu_page(
'options-general.php',
__( 'Example Settings', 'example-plugin' ),
__( 'Example Settings', 'example-plugin' ),
'manage_options',
'example-settings',
'example_render_settings_page'
);
}
This adds the page underneath the Settings menu.
Common WordPress parent slugs
Several core menus can act as parents.
Examples include:
Dashboard
index.php
Posts
edit.php
Media
upload.php
Pages
edit.php?post_type=page
Comments
edit-comments.php
Appearance
themes.php
Plugins
plugins.php
Users
users.php
Tools
tools.php
Settings
options-general.php
Choose the parent that best matches the purpose of the page.
Do not place a reporting screen under Settings merely because registering it there is convenient.
Information architecture still matters.
Top-level page or submenu?
A useful decision is:
Is this a major product area?
→ top-level page may make sense
Is this configuration for an existing area?
→ submenu may make sense
Is it rarely used?
→ avoid permanent top-level clutter
Every plugin believing it deserves a top-level menu is how wp-admin eventually starts resembling a drawer full of mystery cables.
For custom navigation patterns, see How to Add Custom Admin Menu Items in WordPress.
Use a stable and unique menu slug
A page slug should be:
- unique;
- predictable;
- stable across releases;
- appropriately prefixed.
Instead of:
settings
prefer something specific:
example-plugin-settings
Generic slugs increase the possibility of collisions with another plugin.
Changing the slug later also changes the page URL:
admin.php?page=old-slug
becomes
admin.php?page=new-slug
That can break:
- bookmarks;
- internal links;
- redirects;
- documentation;
- JavaScript assumptions;
- support instructions.
Treat the slug as part of the page’s interface contract.
Use admin_url() when generating admin links
Do not hard-code:
/wp-admin/admin.php?page=example-settings
when generating links programmatically.
Use:
$url = admin_url(
'admin.php?page=example-settings'
);
The official admin_url() documentation explains how WordPress generates administration URLs.
Then escape the URL when rendering:
<a href="<?php echo esc_url( $url ); ?>">
<?php esc_html_e( 'Open settings', 'example-plugin' ); ?>
</a>
The render callback should remain focused
It is tempting to place everything inside:
example_render_admin_page()
including:
- authorization;
- form processing;
- database queries;
- API requests;
- HTML;
- JavaScript;
- CSS.
That works for tiny experiments.
It becomes painful quickly.
A healthier architecture might be:
registration
↓
controller
↓
service / data layer
↓
view template
The rendering function can then concentrate on producing the interface.
Separate page registration from rendering
For example:
add_action(
'admin_menu',
'example_register_admin_page'
);
function example_register_admin_page() {
add_menu_page(
__( 'Example Reports', 'example-plugin' ),
__( 'Reports', 'example-plugin' ),
'manage_example_reports',
'example-reports',
'example_render_reports_page',
'dashicons-chart-bar'
);
}
function example_render_reports_page() {
if ( ! current_user_can( 'manage_example_reports' ) ) {
wp_die(
esc_html__(
'You are not allowed to view this page.',
'example-plugin'
)
);
}
$report_data = example_get_report_data();
require plugin_dir_path( __FILE__ )
. 'views/admin-reports.php';
}
The page callback now:
checks access
↓
gets required data
↓
loads view
instead of becoming a thousand-line function.
Keep view files out of direct execution paths
A template file might begin with:
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
This prevents the file from being used as a standalone PHP endpoint when accessed directly.
The template should be loaded through WordPress:
require plugin_dir_path( __FILE__ )
. 'views/admin-reports.php';
not treated as an independently addressable application page.
Use the WordPress wrap class
Traditional WordPress administration pages normally use:
<div class="wrap">
<h1>Example Settings</h1>
</div>
This gives the page a familiar base structure inside wp-admin.
You can build a completely custom interface inside that container, but preserving appropriate WordPress administration conventions often improves compatibility and usability.
Escape page output correctly
Values rendered into HTML should be escaped for their output context.
Examples:
echo esc_html( $title );
echo esc_attr( $value );
echo esc_url( $url );
The official WordPress escaping documentation explains the principle of escaping data as late as possible.
For example:
<input
type="text"
name="example_name"
value="<?php echo esc_attr( $name ); ?>"
>
The input value is escaped for an HTML attribute context.
Sanitize incoming values according to their type
Suppose the page contains:
<input
type="text"
name="example_title"
>
When processing the value:
$title = isset( $_POST['example_title'] )
? sanitize_text_field(
wp_unslash( $_POST['example_title'] )
)
: '';
Other data types require different validation or sanitization strategies.
Examples:
$count = absint( $raw_count );
$email = sanitize_email( $raw_email );
$key = sanitize_key( $raw_key );
$url = esc_url_raw( $raw_url );
The official WordPress sanitization documentation covers the distinction between common sanitization functions.
Admin forms need authorization and request protection
A form displayed on an admin page should not assume that reaching the page automatically makes every submitted request valid.
A secure state-changing operation usually needs:
capability check
+
nonce verification
+
input validation
+
safe processing
For example:
if ( ! current_user_can( 'manage_options' ) ) {
wp_die(
esc_html__(
'You are not allowed to perform this action.',
'example-plugin'
)
);
}
check_admin_referer(
'example_save_settings',
'example_nonce'
);
These controls solve different problems.
Add a nonce to custom admin forms
Inside the form:
<form method="post">
<?php
wp_nonce_field(
'example_save_settings',
'example_nonce'
);
?>
<input
type="text"
name="example_title"
value="<?php echo esc_attr( $title ); ?>"
>
<?php
submit_button(
__( 'Save Settings', 'example-plugin' )
);
?>
</form>
Then verify it during processing:
check_admin_referer(
'example_save_settings',
'example_nonce'
);
The official WordPress nonce documentation explains how nonces help protect requests.
A nonce is not a replacement for authorization.
You still need the capability check.
A complete basic settings page
Here is a compact implementation combining registration, authorization, nonce verification, sanitization and rendering:
add_action(
'admin_menu',
'example_register_settings_page'
);
function example_register_settings_page() {
add_menu_page(
__( 'Example Settings', 'example-plugin' ),
__( 'Example', 'example-plugin' ),
'manage_options',
'example-settings',
'example_render_settings_page',
'dashicons-admin-settings'
);
}
function example_render_settings_page() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die(
esc_html__(
'You are not allowed to access this page.',
'example-plugin'
)
);
}
if (
isset( $_SERVER['REQUEST_METHOD'] )
&&
'POST' === $_SERVER['REQUEST_METHOD']
) {
check_admin_referer(
'example_save_settings',
'example_nonce'
);
$title = isset( $_POST['example_title'] )
? sanitize_text_field(
wp_unslash(
$_POST['example_title']
)
)
: '';
update_option(
'example_title',
$title
);
add_settings_error(
'example_messages',
'example_saved',
__(
'Settings saved.',
'example-plugin'
),
'updated'
);
}
$title = get_option(
'example_title',
''
);
settings_errors(
'example_messages'
);
?>
<div class="wrap">
<h1>
<?php
echo esc_html(
get_admin_page_title()
);
?>
</h1>
<form method="post">
<?php
wp_nonce_field(
'example_save_settings',
'example_nonce'
);
?>
<table class="form-table">
<tbody>
<tr>
<th scope="row">
<label for="example_title">
<?php
esc_html_e(
'Title',
'example-plugin'
);
?>
</label>
</th>
<td>
<input
type="text"
id="example_title"
name="example_title"
value="<?php echo esc_attr( $title ); ?>"
class="regular-text"
>
</td>
</tr>
</tbody>
</table>
<?php
submit_button(
__( 'Save Settings', 'example-plugin' )
);
?>
</form>
</div>
<?php
}
For a small plugin, this can be enough.
For larger configuration systems, the WordPress Settings API may provide a cleaner architecture.
Consider the Settings API for conventional settings
If your custom page primarily manages WordPress options, consider using the Settings API rather than manually processing every field.
The architecture becomes:
register_setting()
↓
add_settings_section()
↓
add_settings_field()
↓
settings_fields()
↓
do_settings_sections()
This is particularly useful when the page behaves like a conventional settings screen.
A completely custom dashboard, reporting interface or workflow application may need a different architecture.
Not every custom admin page is a settings page
This distinction matters.
A custom admin page may be:
Settings interface
→ modifies configuration
Report
→ displays information
Workflow
→ performs business operations
Dashboard
→ aggregates information
Management interface
→ manipulates custom records
Do not force every page into a settings architecture simply because it lives in wp-admin.
Choose the backend architecture based on what the page actually does.
Load CSS only on your custom admin page
A common plugin mistake is:
add_action(
'admin_enqueue_scripts',
'example_enqueue_admin_assets'
);
function example_enqueue_admin_assets() {
wp_enqueue_style(
'example-admin',
plugins_url(
'admin.css',
__FILE__
)
);
}
This loads the stylesheet across the entire WordPress administration area.
That can:
- waste resources;
- create CSS conflicts;
- change unrelated WordPress screens;
- interfere with other plugins.
Load assets only where they are required.
Capture the page hook suffix
add_menu_page() returns a hook suffix:
$hook_suffix = add_menu_page(
__( 'Example Dashboard', 'example-plugin' ),
__( 'Example', 'example-plugin' ),
'manage_options',
'example-dashboard',
'example_render_admin_page'
);
You can use it to load assets selectively:
add_action(
'admin_menu',
'example_register_admin_page'
);
function example_register_admin_page() {
$hook_suffix = add_menu_page(
__( 'Example Dashboard', 'example-plugin' ),
__( 'Example', 'example-plugin' ),
'manage_options',
'example-dashboard',
'example_render_admin_page',
'dashicons-chart-area'
);
add_action(
'load-' . $hook_suffix,
'example_prepare_admin_page'
);
}
This creates a page-specific lifecycle hook.
Use admin_enqueue_scripts selectively
Another common approach is:
add_action(
'admin_enqueue_scripts',
'example_enqueue_admin_assets'
);
function example_enqueue_admin_assets(
$hook_suffix
) {
if (
'toplevel_page_example-dashboard'
!==
$hook_suffix
) {
return;
}
wp_enqueue_style(
'example-admin',
plugins_url(
'assets/admin.css',
__FILE__
),
array(),
'1.0.0'
);
wp_enqueue_script(
'example-admin',
plugins_url(
'assets/admin.js',
__FILE__
),
array(),
'1.0.0',
true
);
}
The exact hook suffix depends on where and how the page was registered.
Inspect it during development rather than assuming its value in a complex plugin.
Keep admin CSS scoped
Even when CSS loads only on your page, scope selectors around a unique wrapper.
Instead of:
h2 {
font-size: 32px;
}
button {
border-radius: 20px;
}
use:
.example-admin-page h2 {
font-size: 32px;
}
.example-admin-page button {
border-radius: 20px;
}
Then render:
<div class="wrap example-admin-page">
...
</div>
This reduces accidental collisions with WordPress core and third-party components.
JavaScript should be scoped too
Avoid generic selectors such as:
document.querySelectorAll(
'.button'
);
if your script only intends to modify your plugin’s controls.
Prefer:
const root = document.querySelector(
'.example-admin-page'
);
if ( root ) {
const buttons = root.querySelectorAll(
'.example-action'
);
}
The principle is the same:
Custom page
→ custom root
→ scoped assets
→ fewer collisions
Do not place sensitive values directly into JavaScript
If the page requires server-generated configuration, expose only what the browser genuinely needs.
Do not send:
- API secrets;
- database credentials;
- private tokens;
- server-only configuration.
Anything delivered to browser JavaScript should be considered visible to the user who can load the page.
Custom admin pages can use AJAX
A richer interface may submit actions without reloading the page.
The architecture becomes:
Admin page
↓
JavaScript
↓
AJAX request
↓
WordPress handler
↓
capability + nonce
↓
operation
↓
JSON response
An authenticated AJAX handler might look like:
add_action(
'wp_ajax_example_refresh_report',
'example_refresh_report'
);
function example_refresh_report() {
if (
! current_user_can(
'manage_example_reports'
)
) {
wp_send_json_error(
array(
'message' =>
'Permission denied.',
),
403
);
}
check_ajax_referer(
'example_admin',
'nonce'
);
$data = example_get_report_data();
wp_send_json_success(
array(
'data' => $data,
)
);
}
The official WordPress AJAX documentation covers the basic request architecture.
Being inside wp-admin does not secure an AJAX handler
The page may require:
manage_example_reports
but the AJAX endpoint is a separate request.
Therefore the handler should check:
current_user_can(
'manage_example_reports'
)
again.
Never rely on:
The button only appears
on an administrator page
as authorization.
Requests can be sent directly.
Custom admin pages can use the REST API
Modern administration interfaces may communicate with custom REST endpoints.
For example:
register_rest_route(
'example/v1',
'/reports',
array(
'methods' => 'GET',
'callback' =>
'example_rest_get_reports',
'permission_callback' =>
function () {
return current_user_can(
'manage_example_reports'
);
},
)
);
The official WordPress documentation for adding custom REST API endpoints explains the endpoint registration model.
For the broader security implications, see WordPress REST API Security Basics.
Always define a permission_callback
A custom REST route performing privileged administration should not expose its data or operations without explicit authorization.
Think of the route independently:
Can someone discover this endpoint?
→ assume yes
Can someone send requests directly?
→ yes
What actually protects it?
→ permission_callback
The visible admin page is merely one client of the endpoint.
It is not the security boundary.
Menu visibility is not the same as access control
Suppose a page is registered for:
manage_options
and then hidden from the menu using CSS:
#toplevel_page_example {
display: none;
}
The page URL may still exist.
CSS changes:
what the user sees
It does not change:
what the server authorizes
This distinction is fundamental when building role-specific admin experiences.
See Controlling WordPress Admin Page Visibility by Role.
Do not rely on remove_menu_page() as authorization
Likewise:
remove_menu_page(
'example-dashboard'
);
is primarily a navigation operation.
Your sensitive callbacks, AJAX handlers and REST routes still need their own capability checks.
For role-specific navigation, see How to Hide WordPress Admin Menu Items by Role.
Test direct URL access
When testing a custom page, do not stop at:
Menu item hidden
✓
Test:
/wp-admin/admin.php?page=example-dashboard
as every relevant user role.
Your test matrix might be:
Administrator
→ allowed
Editor
→ allowed or denied according to design
Author
→ denied
Contributor
→ denied
Subscriber
→ denied
Then repeat the same authorization tests for:
- forms;
- AJAX handlers;
- REST endpoints;
- exports;
- downloads;
- destructive operations.
Registering a page for Editors
Suppose a reporting screen should be available to Editors.
A simple implementation could use:
edit_others_posts
For example:
add_menu_page(
__( 'Editorial Reports', 'example-plugin' ),
__( 'Editorial Reports', 'example-plugin' ),
'edit_others_posts',
'example-editorial-reports',
'example_render_editorial_reports',
'dashicons-chart-line'
);
This may be appropriate if the page’s authority genuinely aligns with that capability.
If not, create a dedicated capability.
Why dedicated capabilities scale better
Suppose three custom pages exist:
Reports
Imports
API Settings
Using:
manage_options
for all three means they share the same authorization boundary.
A more granular design could use:
view_example_reports
run_example_imports
manage_example_api
Now permissions can evolve independently.
For more advanced role architectures, see Custom WordPress Roles vs. Combining Existing Ones.
Be careful when a page performs multiple operations
The page capability controls access to the screen.
Individual operations may require stronger permissions.
For example:
view report
→ view_example_reports
export report
→ export_example_reports
delete report data
→ delete_example_reports
Do not assume:
Can open page
=
can perform every action on page
Operation-level capability checks can provide more precise security.
Object-level permissions matter too
Suppose your custom page edits a specific post.
Do not merely check:
current_user_can( 'edit_posts' )
when the action targets one specific object.
Prefer:
current_user_can(
'edit_post',
$post_id
)
WordPress can map the meta capability according to:
- post author;
- post status;
- post type;
- user capabilities.
This keeps the custom interface aligned with WordPress’s existing permission model.
Custom admin pages for custom post types
Sometimes you do not need a completely separate admin page.
If the feature manages a content entity, a custom post type may already provide:
- list tables;
- editing screens;
- permissions;
- URLs;
- metadata;
- REST support;
- taxonomies.
Before creating:
My Products Admin Page
ask whether:
Product
is actually a content type.
For the distinction, see WordPress Post Types vs. Custom Post Types.
Use a custom page when the interface represents a workflow
A custom admin page makes more sense for things such as:
System health dashboard
Import wizard
Deployment tool
API configuration
Analytics dashboard
Bulk processing interface
Internal instructions
Custom report builder
These are workflows or tools rather than content entities.
Choosing the correct abstraction prevents the plugin from rebuilding functionality WordPress already provides.
Admin notices can provide feedback
After saving or processing an operation, users need clear feedback.
WordPress provides administration notice patterns.
For example:
add_settings_error(
'example_messages',
'example_saved',
__(
'Settings saved successfully.',
'example-plugin'
),
'updated'
);
Then:
settings_errors(
'example_messages'
);
For the wider administration-notice system, see WordPress Admin Notices Explained.
Avoid displaying raw exception messages to users
Suppose an external API request fails.
Do not automatically output:
$exception->getMessage()
into the page if the message may contain:
- paths;
- credentials;
- internal URLs;
- SQL fragments;
- implementation details.
Log diagnostic information appropriately and show the administrator a useful but controlled message.
Use POST for state-changing operations
Avoid destructive URLs such as:
admin.php?page=example&delete_everything=1
State-changing operations should generally use:
POST
+
authorization
+
nonce
+
validation
A GET request should not casually trigger destructive application state changes.
Redirect after successful POST requests
A useful pattern is:
POST
↓
process
↓
redirect
↓
GET
This avoids accidental form resubmission when the administrator refreshes the browser.
For example:
$redirect_url = add_query_arg(
array(
'page' => 'example-settings',
'updated' => '1',
),
admin_url( 'admin.php' )
);
wp_safe_redirect(
$redirect_url
);
exit;
The resulting GET request can display a success notice.
Use wp_safe_redirect() for local admin redirects
When redirecting to a local administration URL:
wp_safe_redirect(
admin_url(
'admin.php?page=example-settings'
)
);
exit;
The official wp_safe_redirect() documentation explains the safe local redirect behavior.
Always terminate execution after the redirect.
Custom admin pages can become large applications
A page may begin as:
three settings
+
one button
and eventually become:
filters
+
tables
+
charts
+
modals
+
AJAX
+
REST
+
imports
+
exports
+
background jobs
At that point, treat it as an application rather than a large PHP template.
Separate:
- routing;
- permissions;
- data access;
- business logic;
- views;
- assets;
- API handlers.
A possible plugin structure
For example:
example-plugin/
│
├── example-plugin.php
│
├── includes/
│ ├── class-admin-page.php
│ ├── class-report-service.php
│ └── class-permissions.php
│
├── views/
│ └── admin-report.php
│
└── assets/
├── css/
│ └── admin-report.css
│
└── js/
└── admin-report.js
The exact architecture is flexible.
The important point is that the custom page should not gradually turn the main plugin file into an archaeological site.
An object-oriented registration example
A class-based implementation might look like:
class Example_Admin_Page {
private $hook_suffix = '';
public function register() {
add_action(
'admin_menu',
array(
$this,
'register_page',
)
);
add_action(
'admin_enqueue_scripts',
array(
$this,
'enqueue_assets',
)
);
}
public function register_page() {
$this->hook_suffix = add_menu_page(
__( 'Example Reports', 'example-plugin' ),
__( 'Example Reports', 'example-plugin' ),
'manage_example_reports',
'example-reports',
array(
$this,
'render',
),
'dashicons-chart-bar'
);
}
public function render() {
if (
! current_user_can(
'manage_example_reports'
)
) {
wp_die(
esc_html__(
'Permission denied.',
'example-plugin'
)
);
}
require plugin_dir_path( __FILE__ )
. '../views/admin-report.php';
}
public function enqueue_assets(
$hook_suffix
) {
if (
$hook_suffix
!==
$this->hook_suffix
) {
return;
}
wp_enqueue_style(
'example-admin-report',
plugins_url(
'../assets/css/admin-report.css',
__FILE__
),
array(),
'1.0.0'
);
}
}
Then initialize it:
$admin_page = new Example_Admin_Page();
$admin_page->register();
This keeps the page lifecycle contained in one component.
Do not generate arbitrary PHP for admin pages
Some systems attempt to make admin pages dynamic by storing PHP strings and evaluating them at runtime.
For ordinary custom admin development, that is rarely necessary.
Store:
configuration
layout choices
content
identifiers
structured rules
and keep executable application behavior in:
functions
classes
callbacks
version-controlled PHP
For the security implications of runtime PHP execution, see Is eval() Safe in WordPress Plugins?.
Custom HTML does not require eval()
A page can render HTML normally:
?>
<div class="example-dashboard">
<h2>Dashboard</h2>
</div>
<?php
or use a PHP view template.
There is no reason to build a PHP string merely to produce a dynamic interface.
JavaScript can provide dynamic UI behavior
Use JavaScript for:
- tabs;
- modals;
- filters;
- dynamic forms;
- AJAX requests;
- client-side interactions.
Use PHP for:
- authorization;
- server-side data;
- persistent changes;
- business logic;
- secure operations.
Keeping those responsibilities clear makes the page easier to reason about.
Be careful with large inline scripts and styles
A proof of concept may use:
<style>
...
</style>
<script>
...
</script>
inside the page callback.
For a mature plugin, separate assets are usually easier to:
- cache;
- maintain;
- lint;
- debug;
- version;
- review.
Reusable custom code can also be centralized rather than duplicated across multiple pages. TheOneWP’s Snippet Manager is designed for managing reusable HTML, CSS, JavaScript and PHP snippets.
Accessibility still matters inside wp-admin
A custom interface should not abandon accessible markup merely because only administrators use it.
Use:
- proper labels;
- semantic headings;
- keyboard-accessible controls;
- visible focus states;
- meaningful button text;
- appropriate ARIA only where necessary;
- sufficient contrast.
The official WordPress accessibility coding standards provide broader guidance.
Do not make divs behave like buttons unnecessarily
Instead of:
<div
class="save-button"
onclick="saveSettings()"
>
Save
</div>
prefer:
<button
type="button"
class="save-button"
>
Save
</button>
Native elements already provide expected keyboard and semantic behavior.
Tables need appropriate markup
If the page displays tabular data, use a real table when the content is actually tabular:
<table class="widefat striped">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
...
</tbody>
</table>
Do not rebuild every table as a collection of anonymous div elements solely for visual styling.
Large data sets need pagination
Suppose the page displays:
50,000 records
Do not retrieve all 50,000 rows just to show the first screen.
Use:
pagination
+
bounded queries
+
filters
+
search
A custom admin page is still a web application.
Database and rendering costs still exist.
Avoid expensive work during every admin request
Do not place heavy operations directly inside:
admin_menu
just because that hook registers the page.
Registration should be lightweight.
Avoid:
admin_menu
↓
remote API call
↓
large database query
↓
filesystem scan
↓
register one menu item
Perform expensive work only when the relevant page or operation actually requires it.
The load-$hook_suffix hook is useful for page-specific setup
Because add_menu_page() returns a hook suffix, you can attach logic specifically to that page:
add_action(
'load-' . $hook_suffix,
'example_load_admin_page'
);
This is useful for:
- screen options;
- page-specific initialization;
- conditional processing;
- help tabs;
- asset preparation.
It avoids executing page-specific logic throughout wp-admin.
Consider Screen Options for configurable tables
Complex administrative list screens may benefit from WordPress Screen Options.
For example, administrators may choose:
20 rows
50 rows
100 rows
or control visible columns.
When a custom page begins to behave like a WordPress list table, following familiar administration patterns can make the interface easier to use.
Use contextual help for complex administration tools
If the page has complicated behavior, consider contextual help rather than filling the main interface with permanent explanatory text.
The objective is:
primary workflow
→ clean
advanced explanation
→ available when needed
This becomes especially useful for internal tools with several roles and responsibilities.
Custom pages should survive plugin updates
Do not customize a third-party plugin by editing its files directly to insert your page.
Those changes can disappear during updates.
Instead:
custom plugin
or
supported extension hook
or
dedicated administration module
keeps the customization independent.
This principle is part of maintaining WordPress safely over time.
Test your custom page after WordPress updates
A custom administration page interacts with:
- WordPress hooks;
- admin markup;
- scripts;
- styles;
- capabilities;
- REST APIs;
- AJAX;
- browser behavior.
After major WordPress updates, test the page on staging before assuming everything remains identical.
See WordPress Staging Site Best Practices.
Test with multiple roles, not only Administrator
Developers frequently test custom admin pages while logged in as Administrator.
That hides many authorization problems.
Create test accounts representing:
- Administrator;
- Editor;
- Author;
- Contributor;
- Subscriber;
- important custom roles.
Then test:
Can they see the menu?
Can they open the URL directly?
Can they submit the form?
Can they call the AJAX action?
Can they call the REST endpoint?
Can they perform each sensitive operation?
For a broader permissions review, see How to Audit User Roles on a WordPress Site.
Custom pages and role-specific admin design
A custom page can improve a role-specific dashboard when it provides information that users genuinely need.
For example:
Editor
→ editorial overview
Support agent
→ customer support dashboard
Warehouse user
→ inventory interface
Client
→ simplified reporting screen
But adding a custom page is only one layer.
A coherent administration experience may also require:
- menu organization;
- irrelevant menu removal;
- role-specific capabilities;
- login redirects;
- reduced notices;
- custom dashboard content.
For the larger design problem, see Building a Focused WordPress Dashboard for Teams.
Custom page registration and menu organization are separate layers
Registering:
Example Reports
determines that the page exists.
Organizing:
where Example Reports appears
what it is called
which roles see it in navigation
is another concern.
TheOneWP’s Admin Menu Organizer can reorder, rename and organize administration menu entries, including custom pages.
That separation is useful:
Page code
→ defines functionality
Capabilities
→ define authorization
Menu organization
→ defines navigation
Creating admin pages without writing the registration boilerplate
Some WordPress projects need internal administration pages without requiring a dedicated plugin class and registration callback for every screen.
TheOneWP’s Backend Pages Generator provides a dedicated interface for creating custom wp-admin pages.
It can be useful for pages such as:
- client instructions;
- internal dashboards;
- support information;
- custom utilities;
- administrative interfaces;
- project-specific tools.
The module can place a page as:
standalone top-level page
or
submenu page
and supports HTML, CSS, JavaScript and optional PHP for the page implementation.
Backend Pages Generator and access rules
A generated page may need to be visible to:
all logged-in users
specific WordPress roles
specific individual users
That makes visibility configuration particularly useful for internal dashboards.
However, page visibility and WordPress capabilities should still be understood as separate concepts.
For the distinction, see Controlling WordPress Admin Page Visibility by Role.
Backend Pages Generator and PHP execution
Allowing PHP inside a generated page creates substantially more power than HTML or CSS.
Treat PHP execution as privileged development functionality.
The architectural question is:
Does this page actually require PHP?
If no:
→ leave PHP execution disabled
If yes:
→ restrict who can configure the page
→ review the PHP carefully
→ test on staging
→ maintain a recovery path
For the broader security discussion, see Is eval() Safe in WordPress Plugins?.
When manual development is the better choice
Manual registration with add_menu_page() or add_submenu_page() is usually appropriate when:
- the page is part of a distributable plugin;
- the page needs complex application logic;
- the functionality belongs in version-controlled source;
- the page requires automated tests;
- the plugin needs strict deployment control;
- the page architecture is maintained by developers.
In that situation:
source code
+
native WordPress APIs
+
version control
=
predictable plugin architecture
When a page generator can be useful
A managed page generator can make sense when:
- the page is site-specific;
- the interface is primarily internal;
- administrators need to change content without a deployment;
- the page contains instructions or operational information;
- the project benefits from rapid creation of internal tools.
The decision should be based on maintainability and security, not merely on which method requires fewer lines of PHP.
Avoid creating admin pages for information that belongs elsewhere
Before adding another menu entry, ask:
Does this information belong on:
an existing settings page?
a dashboard widget?
a custom post type?
an existing plugin screen?
a contextual help tab?
an admin notice?
Creating a new page is easy.
Creating a coherent administration interface is harder.
For dashboard-specific information, see Building a Focused WordPress Dashboard for Teams.
Common custom admin page mistakes
Several mistakes appear repeatedly.
Using a generic slug
Avoid:
settings
Prefer:
example-plugin-settings
Using role-name checks instead of capabilities
Avoid:
in_array(
'administrator',
$user->roles,
true
)
Prefer:
current_user_can(
'required_capability'
)
Loading assets everywhere
Avoid loading page-specific CSS and JavaScript on every administration screen.
Trusting hidden menu items
A hidden navigation entry is not authorization.
Processing POST requests without a nonce
State-changing requests need appropriate request protection.
Checking a nonce but not a capability
A valid nonce does not prove the user is authorized.
Using unescaped output
Database content is not automatically safe merely because it came from your own option.
Performing heavy work on admin_menu
Menu registration should remain lightweight.
Putting everything in one callback
Large admin applications need separation of concerns.
Hard-coding wp-admin URLs
Use WordPress URL functions.
Testing only as Administrator
Permission bugs often remain invisible until a lower-privileged account is used.
A custom admin page security checklist
Before releasing the page, verify:
- The page uses a unique slug.
- The registration uses an appropriate capability.
- Sensitive callbacks check capabilities server-side.
- Forms use nonces where appropriate.
- Incoming values are validated and sanitized.
- Output is escaped for its context.
- AJAX actions check authorization independently.
- REST routes use a proper
permission_callback. - Destructive operations do not rely on GET requests.
- Direct page URLs are tested with lower-privileged users.
- CSS is scoped to the custom page.
- JavaScript is scoped to the custom page.
- Assets load only where needed.
- API secrets are not exposed to the browser.
- Database queries are bounded and safe.
- Large result sets use pagination.
- Expensive work does not run on every admin request.
- Accessibility is tested.
- Errors do not expose sensitive implementation details.
- The page is tested on staging.
A development checklist
A practical implementation sequence is:
1. Define the page's purpose
2. Decide whether it needs
a new page at all
3. Choose top-level or submenu
4. Define the required capability
5. Choose a unique slug
6. Register with admin_menu
7. Build the render callback
8. Separate business logic
9. Add forms if required
10. Add capability checks
11. Add nonces
12. Validate input
13. Escape output
14. Add page-specific assets
15. Add AJAX or REST only if needed
16. Secure those endpoints independently
17. Test direct URLs
18. Test multiple roles
19. Test on staging
20. Document the page architecture
A practical architecture for a mature custom page
A larger custom page might use:
WordPress admin menu
↓
page controller
↓
capability layer
↓
application service
↓
repository / WordPress APIs
↓
view
JavaScript
↓
AJAX or REST endpoint
↓
capability check
↓
nonce / authentication
↓
validation
↓
application service
The browser interface and server endpoint eventually reach the same application logic.
That reduces duplicated behavior.
Keep authorization close to sensitive operations
Suppose:
example_delete_records()
permanently deletes data.
Do not rely exclusively on the fact that its caller already checked a capability several layers earlier.
For high-impact operations, keeping authorization requirements explicit makes the code easier to audit.
The same principle applies to:
- exports;
- imports;
- user changes;
- database modifications;
- filesystem operations;
- API credential changes.
Do not confuse UI simplification with security
You may intentionally hide:
Settings
Plugins
Tools
Users
from a custom role to create a cleaner interface.
That can be excellent UX.
But:
hidden menu
≠
revoked capability
The authorization model still needs to be correct.
TheOneWP’s Admin Menu Organizer handles administration navigation, while Role Manager addresses the role and capability layer.
Those are complementary concerns.
Custom pages should fit the rest of wp-admin
A page can have a distinctive interface without behaving like an unrelated website embedded inside WordPress.
Maintain predictable:
- navigation;
- button behavior;
- form feedback;
- keyboard interaction;
- loading states;
- error states;
- permissions.
Users should not need to relearn basic interaction patterns every time they open a different plugin page.
Document custom administration architecture
For a long-lived project, document:
Page slug
Required capability
Registration callback
Render callback
AJAX actions
REST routes
Stored options
Custom database tables
Asset handles
Custom capabilities
Important hooks
This becomes especially valuable when another developer needs to maintain the plugin a year later, after everyone involved has naturally forgotten why a seemingly bizarre decision was made.
Related guides
- How to Add Custom Admin Menu Items in WordPress
- Controlling WordPress Admin Page Visibility by Role
- How to Hide WordPress Admin Menu Items by Role
- WordPress User Roles and Capabilities Explained
- Is eval() Safe in WordPress Plugins?
Final recommendation
Adding a custom admin page in WordPress begins with add_menu_page() or add_submenu_page(), but registration is only the first layer.
Start by deciding whether the feature genuinely needs its own administration screen. If it does, give the page a stable unique slug, place it in a logical part of the menu and assign the minimum capability required for its actual purpose.
Treat authorization independently from navigation. A hidden menu item does not block a direct URL, and a visible button does not authorize the request behind it. Forms, AJAX handlers, REST endpoints, exports and destructive operations should perform their own server-side permission checks.
Protect state-changing requests with nonces where appropriate, validate incoming data according to its expected type and escape values according to their output context.
Keep page-specific CSS and JavaScript scoped to the page rather than loading them throughout wp-admin. As the interface grows, separate registration, permissions, business logic, data access and rendering instead of allowing one callback to become the entire application.
For site-specific administration interfaces that do not justify a dedicated plugin implementation, TheOneWP’s Backend Pages Generator can create custom WordPress admin pages with HTML, CSS, JavaScript and optional PHP, then place them as top-level or submenu entries.
Use Admin Menu Organizer when the navigation around those pages needs to be reorganized, Role Manager when the underlying role and capability architecture needs adjustment and Snippet Manager when reusable custom code belongs outside one specific page.
The cleanest architecture keeps the responsibilities separate:
Admin page
→ presents the interface
Capability
→ controls who may access it
Nonce
→ protects the intended request
Validation
→ controls acceptable input
Business logic
→ performs the operation
Escaping
→ protects the output context
Menu organization
→ controls where users find it
A custom WordPress admin page should therefore be treated as an application endpoint, not merely as another item in the sidebar.

