Building A Custom Drupal Dashboard Widget With JavaScript And Ajax
A dashboard widget can turn a routine Drupal task into a quick, useful interaction. Editors might need to see unpublished content, review support requests, check stock levels, or refresh a report without leaving the administration page. A small JavaScript and Ajax feature is often enough to provide that experience without building a separate application.
This example uses a custom Drupal module, a block plugin, a route returning JSON, and a JavaScript behaviour. The pattern suits Drupal 10 and Drupal 11, and it can be adapted for an intranet, a client portal, or a content team working across Sydney, Melbourne, Brisbane, or regional Australia.
| Approach | Best fit | Main trade-off |
|---|---|---|
| Render data in the block | Stable, small datasets | The page must reload for updates |
| Use a custom JSON route | Live counts, reports, and status checks | Requires route security and JavaScript |
| Use Drupal Form API Ajax | Multi-step forms and validation | More Drupal-specific boilerplate |
| Use a contributed dashboard module | Rapid administration features | Less control over markup and behaviour |
Choose The Widget Architecture
A custom block is a sensible starting point because it can be placed through the normal Drupal block layout screen. The block supplies the semantic HTML shell, while JavaScript adds interaction after the page loads. Keeping those responsibilities separate makes the widget easier to test and safer when JavaScript is disabled.
The Ajax endpoint should return only the data needed by the widget. For example, a response might contain a number of pending nodes, a short status message, and an ISO-formatted update time. Avoid returning a complete rendered page fragment unless the use case genuinely requires it. Small JSON responses are easier to cache, inspect, and consume.
For an Australian organisation, this approach also avoids tying the dashboard to a particular office workflow. A content editor in Perth can refresh the same widget as a communications team in Canberra, while a distributed agency can keep the response consistent across different browsers and time zones.
Create The Custom Module
Create a directory named web/modules/custom/dashboard_widget. The module needs an info file, routing definition, permissions, a block plugin, a controller, a library definition, and a JavaScript file.
The basic module declaration is:
# dashboard_widget.info.yml
name: Dashboard Widget
type: module
description: Provides an Ajax-powered administration dashboard widget.
package: Custom
core_version_requirement: ^10 || ^11
Define a dedicated permission rather than granting access through a broad permission such as “administer site configuration”:
# dashboard_widget.permissions.yml
access dashboard widget:
title: 'Access dashboard widget data'
description: 'View data returned by the dashboard widget.'
A custom permission lets a site owner decide whether editors, managers, or support staff can use the endpoint. That distinction matters when the dashboard contains operational information that should not be visible to every authenticated user.
Define The Ajax Route And Controller
The route maps a predictable URL to a controller method. It also applies the permission defined above:
# dashboard_widget.routing.yml
dashboard_widget.data:
path: '/admin/dashboard-widget/data'
defaults:
_controller: '\Drupal\dashboard_widget\Controller\DashboardWidgetController::data'
requirements:
_permission: 'access dashboard widget'
methods: [GET]
The controller can return a small JSON object. This example uses Drupal’s time service and the current user service through dependency injection. In a real project, replace the example values with a query, a reporting service, or a repository that contains the actual business logic.
<?php
namespace Drupal\dashboard_widget\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Datetime\TimeInterface;
use Drupal\Core\Session\AccountProxyInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
final class DashboardWidgetController extends ControllerBase {
public function __construct(
private readonly AccountProxyInterface $currentUser,
private readonly TimeInterface $time,
) {}
public static function create(ContainerInterface $container): self {
return new self(
$container->get('current_user'),
$container->get('datetime.time'),
);
}
public function data(): JsonResponse {
return new JsonResponse([
'pending' => 12,
'message' => 'The dashboard is up to date.',
'updated' => gmdate('c', $this->time->getRequestTime()),
'user' => $this->currentUser->getDisplayName(),
]);
}
}
The route should not expose sensitive records merely because it is called through JavaScript. Use entity access checks, validate query parameters, and keep the permission narrow. If the response is user-specific, set cache metadata appropriately or disable caching for the response. A public route containing private editorial data is a serious security error.
Build The Block Markup And Library
Create a block plugin at src/Plugin/Block/DashboardWidgetBlock.php. The block produces a heading, a status region, a refresh button, and a data attribute containing the endpoint URL.
<?php
namespace Drupal\dashboard_widget\Plugin\Block;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\Url;
/**
* @Block(
* id = "dashboard_widget",
* admin_label = @Translation("Dashboard widget")
* )
*/
final class DashboardWidgetBlock extends BlockBase {
public function build(): array {
return [
'#type' => 'container',
'#attributes' => [
'class' => ['dashboard-widget'],
'data-dashboard-widget' => 'true',
'data-endpoint' => Url::fromRoute('dashboard_widget.data')->toString(),
],
'heading' => [
'#type' => 'html_tag',
'#tag' => 'h2',
'#value' => $this->t('Content status'),
],
'status' => [
'#type' => 'html_tag',
'#tag' => 'p',
'#attributes' => [
'class' => ['dashboard-widget__status'],
'aria-live' => 'polite',
],
'#value' => $this->t('Loading status…'),
],
'refresh' => [
'#type' => 'html_tag',
'#tag' => 'button',
'#value' => $this->t('Refresh'),
'#attributes' => [
'type' => 'button',
'class' => ['dashboard-widget__refresh'],
],
],
'#attached' => [
'library' => ['dashboard_widget/widget'],
],
'#cache' => [
'max-age' => 0,
],
];
}
}
The aria-live attribute allows screen readers to announce a changed status without moving keyboard focus. The button has an explicit type so it will not accidentally submit a surrounding form. The data-dashboard-widget attribute gives JavaScript a stable hook without coupling the behaviour to a theme-specific class.
Register the library in dashboard_widget.libraries.yml:
widget:
version: 1.x
js:
js/dashboard-widget.js: {}
dependencies:
- core/drupal
- core/once
The block can now be placed under Structure → Block layout. Clear Drupal caches after enabling the module, then place it in the administration theme’s content region or a dashboard-specific region.
Add The JavaScript Ajax Behaviour
Drupal behaviours are important because markup can be replaced or attached more than once. The once utility prevents duplicate event handlers when another Ajax operation rebuilds part of the page.
Create js/dashboard-widget.js:
(function (Drupal, once) {
'use strict';
Drupal.behaviors.dashboardWidget = {
attach(context) {
once('dashboard-widget', '[data-dashboard-widget]', context)
.forEach((widget) => {
const endpoint = widget.dataset.endpoint;
const status = widget.querySelector('.dashboard-widget__status');
const refresh = widget.querySelector('.dashboard-widget__refresh');
const loadData = async () => {
refresh.disabled = true;
status.textContent = Drupal.t('Refreshing…');
try {
const response = await fetch(endpoint, {
headers: {
'Accept': 'application/json'
},
credentials: 'same-origin'
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
status.textContent = Drupal.t(
'@pending items need attention. Updated @time.',
{
'@pending': data.pending,
'@time': new Date(data.updated).toLocaleString('en-AU')
}
);
}
catch (error) {
status.textContent = Drupal.t(
'The dashboard could not be refreshed.'
);
console.error('Dashboard widget error:', error);
}
finally {
refresh.disabled = false;
}
};
refresh.addEventListener('click', loadData);
loadData();
});
}
};
})(Drupal, once);
This is an Ajax request because the browser requests data asynchronously and updates the current document without a full page navigation. The credentials: 'same-origin' option sends the user’s Drupal session cookie, allowing the route permission to work for an authenticated administrator.
Using toLocaleString('en-AU') gives a familiar date and time format for Australian users. A team in Adelaide or Darwin may still need a clear policy for displayed time zones, particularly when the server runs in UTC. For critical operational timestamps, display the timezone abbreviation or use Drupal’s date formatter rather than relying entirely on the visitor’s browser.
Secure And Shape The Response
The controller example returns a fixed number, but production data usually comes from an entity query. When counting nodes, use an access-aware query and restrict the bundle or publication state explicitly. An entity query should never assume that the current user can see every matching entity.
For example, a count might be built with a query such as:
$query = $this->entityTypeManager
->getStorage('node')
->getQuery()
->accessCheck(TRUE)
->condition('type', 'article')
->condition('status', 0);
$pending = $query->count()->execute();
Inject the entity type manager into the controller rather than calling the global service container. That makes the class easier to unit test and keeps dependencies visible. If the query is expensive, consider a cacheable report, a scheduled aggregation, or a dedicated service that stores a short-lived result.
Validate every parameter if the widget accepts filters such as date ranges, content types, or taxonomy terms. Whitelist values, impose sensible limits, and avoid building database conditions directly from request input. For a high-traffic site, rate limiting or a brief per-user cache may also be appropriate.
Style For Accessibility And Performance
Keep the initial widget useful before the request completes. A loading message explains what is happening, while a failure message gives the editor a clear result instead of leaving an empty panel. The refresh button should be keyboard accessible, have visible focus styling, and remain disabled during the request to prevent accidental request bursts.
A simple stylesheet can create a compact panel:
.dashboard-widget {
padding: 1rem;
border: 1px solid #d8d8d8;
background: #fff;
}
.dashboard-widget__status {
margin-block: 0.75rem;
}
.dashboard-widget__refresh:focus-visible {
outline: 3px solid #1464a5;
outline-offset: 2px;
}
Do not use colour alone to communicate a warning or success state. Add text, an icon with an accessible label, or a meaningful status message. Keep the response lightweight so the widget behaves well on mobile connections, including the variable connectivity common in regional Queensland and Western Australia.
Test, Deploy, And Extend The Widget
Test the widget as an anonymous visitor, a permitted authenticated user, and an authenticated user without the custom permission. Confirm that the endpoint returns a 403 response when appropriate, that invalid data cannot alter the query, and that a failed request restores the button to an enabled state.
Use browser developer tools to inspect the request URL, response status, JSON payload, and console output. Check the page with JavaScript disabled as well; the block should still have a meaningful heading and an understandable fallback message. Run Drupal coding standards checks on the PHP files and clear caches after changing routing, permissions, or libraries.
For a larger implementation, Drupal engineering support can help turn the example into a reporting service with tests, access rules, and deployment automation. Keep environment-specific configuration in configuration management, not in JavaScript. That is especially useful when a local Australian development site, a staging environment in Sydney, and a production platform use different domains.
The widget can later show charts, expose several JSON fields, or refresh on a timer. Each extension should preserve the same boundaries: Drupal controls permissions and data access, the controller returns a focused response, and the JavaScript behaviour handles presentation. For module-specific questions or implementation notes, the contact page provides a direct route to the site.