
How to Create a Custom Admin Dashboard Widget in WordPress?
A custom dashboard widget puts exactly the information your editors need, like recent orders, a content checklist, or a feed of internal announcements, on the very first screen they see after logging in. The default WordPress dashboard ships with "At a Glance," "Activity," and "Quick Draft" widgets that are rarely what a specific site's editorial team actually cares about, and wp_add_dashboard_widget() is the single function that lets you replace or supplement them with your own.
This is one of the simplest admin customization APIs in WordPress core, a widget is just a callback function that outputs HTML, but it's also one of the most useful for client sites, since it's the one place in wp-admin every logged-in user is guaranteed to see on login.
This guide covers registering a basic widget, adding a settings/configuration form to it, controlling widget priority and position, restricting a widget to specific user roles, and removing the default widgets you don't want cluttering the dashboard.
Step 1: Register a Basic Dashboard Widget
wp_add_dashboard_widget() must run on the wp_dashboard_setup action, and takes a widget ID, a title, and a callback that outputs the widget's body:
add_action( 'wp_dashboard_setup', function () {
wp_add_dashboard_widget(
'tidewave_site_status',
'Site Status',
'tidewave_render_site_status_widget'
);
} );
function tidewave_render_site_status_widget() {
$post_count = wp_count_posts( 'post' )->publish;
$product_count = post_type_exists( 'product' ) ? wp_count_posts( 'product' )->publish : 0;
$comment_count = wp_count_comments()->approved;
?>
<ul class="tidewave-status-list">
<li><strong><?php echo esc_html( $post_count ); ?></strong> published posts</li>
<li><strong><?php echo esc_html( $product_count ); ?></strong> published products</li>
<li><strong><?php echo esc_html( $comment_count ); ?></strong> approved comments</li>
</ul>
<?php
}
That's a complete, functioning dashboard widget. It appears in the main dashboard column (typically the left one) alongside the built-in widgets, and every logged-in user with access to wp-admin sees it, subject to any role restriction you add later.
Step 2: Add a Configuration Form to the Widget
wp_add_dashboard_widget() accepts a fifth argument, a "control callback," which renders a small settings form shown when the widget's Configure option (revealed by hovering the widget's title bar) is clicked. This is where per-user or per-site widget preferences get saved:
add_action( 'wp_dashboard_setup', function () {
wp_add_dashboard_widget(
'tidewave_announcements',
'Team Announcements',
'tidewave_render_announcements_widget',
'tidewave_configure_announcements_widget'
);
} );
function tidewave_configure_announcements_widget() {
if ( isset( $_POST['tidewave_announcement_limit'] ) ) {
check_admin_referer( 'tidewave-dashboard-widget', 'tidewave_dashboard_nonce' );
update_option(
'tidewave_announcement_limit',
absint( $_POST['tidewave_announcement_limit'] )
);
}
$limit = get_option( 'tidewave_announcement_limit', 5 );
wp_nonce_field( 'tidewave-dashboard-widget', 'tidewave_dashboard_nonce' );
?>
<p>
<label for="tidewave_announcement_limit">Number of announcements to show</label><br>
<input type="number" min="1" max="20"
name="tidewave_announcement_limit"
id="tidewave_announcement_limit"
value="<?php echo esc_attr( $limit ); ?>" />
</p>
<?php
}
function tidewave_render_announcements_widget() {
$limit = get_option( 'tidewave_announcement_limit', 5 );
$announcements = get_posts( [
'post_type' => 'announcement',
'posts_per_page' => $limit,
] );
if ( ! $announcements ) {
echo '<p>No announcements yet.</p>';
return;
}
echo '<ul>';
foreach ( $announcements as $announcement ) {
printf(
'<li><a href="%s">%s</a></li>',
esc_url( get_edit_post_link( $announcement->ID ) ),
esc_html( $announcement->post_title )
);
}
echo '</ul>';
}
WordPress handles displaying the control form and its "Submit" button automatically once a control callback is passed, your callback only needs to process $_POST on submission and render the current value; there's no separate hook for saving the form. check_admin_referer() on the nonce is essential here since this callback processes a raw $_POST submission directly.
Step 3: Control Widget Position with wp_add_dashboard_widget() Ordering
New widgets are added to the bottom of the "normal" (main, left-hand) dashboard column by default. To move a widget higher, reorder the global $wp_meta_boxes array after all widgets have registered, using the 'high' priority context:
add_action( 'wp_dashboard_setup', function () {
wp_add_dashboard_widget(
'tidewave_site_status',
'Site Status',
'tidewave_render_site_status_widget',
null,
null,
'normal',
'high'
);
} );
The sixth and seventh arguments to wp_add_dashboard_widget() are $context ('normal', 'side', or 'column3'/'column4' on some setups) and $priority ('high', 'core', 'default', or 'low'). Setting context to 'side' moves it into the right-hand column instead, useful for a compact widget like a quick-links list.
Step 4: Restrict a Widget to Specific User Roles
Not every dashboard widget belongs in front of every user. Checking current_user_can() before registering the widget keeps it out of the dashboard entirely for roles that shouldn't see it, rather than merely hiding it visually:
add_action( 'wp_dashboard_setup', function () {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
wp_add_dashboard_widget(
'tidewave_admin_only_widget',
'Site Health Summary',
'tidewave_render_admin_only_widget'
);
} );
This runs once per page load for the currently logged-in user, so current_user_can() correctly evaluates against whoever is viewing the dashboard right now, not a hardcoded role check.
Step 5: Remove the Default Dashboard Widgets
The built-in widgets ("Welcome," "At a Glance," "Activity," "Quick Draft," and "WordPress Events and News") can each be removed with remove_meta_box(), called after they've been registered, on wp_dashboard_setup with a later priority so it runs after core's own registration:
add_action( 'wp_dashboard_setup', function () {
remove_meta_box( 'dashboard_primary', 'dashboard', 'side' ); // WordPress Events and News
remove_meta_box( 'dashboard_quick_press', 'dashboard', 'side' ); // Quick Draft
remove_meta_box( 'dashboard_activity', 'dashboard', 'normal' ); // Activity
}, 20 );
Trimming the default widgets down to just what's relevant, alongside your own custom widget, is one of the more noticeable client-facing polish improvements you can make to a WordPress admin, especially for editors who log in only occasionally and don't need a WordPress.org news feed competing for attention with real site data.
Step 6: A Widget That Fetches Live Data with AJAX
For data that changes frequently (an order count, a queue depth), loading it via AJAX after the page renders avoids slowing down the initial dashboard load:
add_action( 'wp_dashboard_setup', function () {
wp_add_dashboard_widget( 'tidewave_live_stats', 'Live Stats', 'tidewave_render_live_stats_widget' );
} );
function tidewave_render_live_stats_widget() {
?>
<div id="tidewave-live-stats">Loading…</div>
<script>
(function () {
const el = document.getElementById('tidewave-live-stats');
fetch(ajaxurl + '?action=tidewave_get_live_stats', { credentials: 'same-origin' })
.then((res) => res.json())
.then((data) => { el.textContent = data.data.summary; })
.catch(() => { el.textContent = 'Unable to load stats.'; });
})();
</script>
<?php
}
add_action( 'wp_ajax_tidewave_get_live_stats', function () {
if ( ! current_user_can( 'edit_posts' ) ) {
wp_send_json_error( null, 403 );
}
wp_send_json_success( [
'summary' => sprintf( '%d posts published today.', tidewave_count_posts_today() ),
] );
} );
ajaxurl is a global JavaScript variable WordPress automatically defines on every admin page, pointing at wp-admin/admin-ajax.php, so it's always available inside admin-rendered inline scripts without needing to localize it yourself. The wp_ajax_{action} hook (note: no wp_ajax_nopriv_ counterpart here, since this data should only be reachable by logged-in users) is where the actual request gets handled.
Step 7: Cache Expensive Widget Output with Transients
A widget that runs a heavy query, an external API call, or an aggregate report across thousands of rows shouldn't recompute that data on every single dashboard page load. The Transients API caches the rendered result for a fixed period, only recalculating it once that period expires:
function tidewave_render_site_status_widget() {
$cached = get_transient( 'tidewave_site_status_html' );
if ( false !== $cached ) {
echo $cached; // phpcs:ignore WordPress.Security.EscapeOutput
return;
}
ob_start();
$post_count = wp_count_posts( 'post' )->publish;
$product_count = post_type_exists( 'product' ) ? wp_count_posts( 'product' )->publish : 0;
$comment_count = wp_count_comments()->approved;
?>
<ul class="tidewave-status-list">
<li><strong><?php echo esc_html( $post_count ); ?></strong> published posts</li>
<li><strong><?php echo esc_html( $product_count ); ?></strong> published products</li>
<li><strong><?php echo esc_html( $comment_count ); ?></strong> approved comments</li>
</ul>
<?php
$html = ob_get_clean();
set_transient( 'tidewave_site_status_html', $html, 5 * MINUTE_IN_SECONDS );
echo $html; // phpcs:ignore WordPress.Security.EscapeOutput
}
ob_start()/ob_get_clean() capture everything the block of inline HTML would normally print directly, as a string, so it can be stored with set_transient() and reused on the next several page loads without touching the database or an external API again. Five minutes is a reasonable default for something like a post count; scale the expiration up for anything genuinely expensive to compute, and down for anything that needs to feel closer to real-time.
Since the cached string was already built from properly escaped values the first time it ran, re-echoing it on a cache hit doesn't need to re-escape anything, there's nothing new to sanitize in already-rendered, trusted HTML your own code generated.
Step 8: Detect and Remove a Widget Added by a Plugin
Occasionally a plugin adds its own dashboard widget that isn't relevant to a particular site, and there's no setting to turn it off. remove_meta_box() works on any widget ID, not just WordPress core's own, as long as you know its ID and the metabox context it was registered under. Finding the ID usually means viewing the dashboard's page source and looking for the widget's wrapping <div id="...">, since plugins don't always document their widget IDs:
add_action( 'wp_dashboard_setup', function () {
remove_meta_box( 'some_plugin_dashboard_widget', 'dashboard', 'normal' );
}, 100 ); // a later priority makes sure the target plugin has already registered its widget
The priority argument (100 here) matters more than it does for your own widgets: if this callback runs before the plugin's own wp_dashboard_setup callback registers its widget, there's nothing yet to remove, and the widget reappears. Bumping the priority high enough to run after essentially every plugin's own registration solves this reliably.
Frequently Asked Questions (FAQ) About Custom Dashboard Widgets
New widgets are appended to the bottom of the 'normal' context by default. Pass 'high' as the seventh argument to wp_add_dashboard_widget() to request top placement in that column, though the exact final order still respects any drag-and-drop reordering a user has already saved for their own dashboard.
Yes, wrap the wp_add_dashboard_widget() call in a current_user_can() check inside your wp_dashboard_setup callback. Since this hook runs fresh on every dashboard page load for whoever is currently logged in, the check correctly reflects each individual user's role.
Use get_user_meta() and update_user_meta() keyed to get_current_user_id() inside your control callback instead of update_option(), which is what the built-in configuration form pattern in Step 2 would look like adapted for per-user rather than site-wide storage.
Yes, remove_meta_box() only detaches a widget's registration for the current page load; it doesn't delete any data or break other admin functionality. Some site owners deliberately strip the dashboard down to only their own custom widgets for a cleaner, more focused editorial experience.
Yes, and it's the better choice for anything you want to persist across a theme change, exactly the same reasoning as with custom post types. The wp_dashboard_setup hook and wp_add_dashboard_widget() function work identically regardless of whether the calling code lives in a plugin or the active theme.
Check that you're reading from $_POST and calling update_option() (or update_user_meta()) inside the control callback itself, not the render callback. WordPress submits the configuration form back to the same admin page and re-invokes the control callback before the dashboard re-renders, so that's the only place the submitted values are available.
Conclusion
wp_add_dashboard_widget() is a small API, but it's one of the highest-leverage admin customizations available for client work, because it's the one screen every editor sees immediately after logging in. A well-built widget replaces a dashboard full of generic WordPress.org news and "Quick Draft" boxes with the specific numbers, links, or announcements that team actually needs to act on.
Start simple with a read-only summary widget like the one in Step 1, add a control callback only once you actually need per-site or per-user configuration, and pair role restrictions from Step 4 with removing irrelevant default widgets from Step 5 for the cleanest result.
Here are a few additional resources if you want to go deeper:
- WordPress Developer Reference: wp_add_dashboard_widget() — the complete argument list and control callback behavior.
- WordPress Developer Reference: remove_meta_box() — reference for removing default and custom dashboard widgets.
- WordPress Developer Reference: wp_dashboard_setup — the hook documentation covering when and how dashboard widgets are registered.


