Opens in a new tab
  1. Home
  2. Guides
  3. Backend
Backend guide

How to Add a Custom Admin Page in WordPress

Learn how to create custom WordPress admin pages using native menu APIs, capabilities, secure forms, nonces, scoped CSS and JavaScript, AJAX, REST endpoints and role-based access controls.

  • Updated September 17, 2026
  • 25 min read
  • WordPress guide

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

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.

Simplify your WordPress stack

A modular WordPress toolkit. 104 focused tools.

Ultimately, you can build cleaner workflows, maintain fewer plugins and enable only the features each website actually needs.