AJAX and HTMX
|
This content was generated with the assistance of AI and should be verified against the official Drupal documentation before being relied on in production. This sub-section’s Drupal bibliography lists the reference material consulted while preparing these pages. |
Most pages in Drupal are rendered once on the server and sent whole. Two subsystems let a page change after it has loaded
without a full reload. The Ajax framework (the Ajax API) is Drupal’s historical mechanism: the browser sends a request
through the Drupal.ajax JavaScript object, the server answers with a list of commands in JSON, and the commands
change the page. HTMX is the newer mechanism, part of the Ajax subsystem of core since Drupal 11.3: elements carry
declarative data-hx- attributes, the server answers with plain HTML, and the htmx library swaps that HTML into the page.
This page explains both, shows the same interactions built with each on the Meridian Museum site, and ends with guidance
on choosing between them.
This page was checked against the official documentation, the Drupal 11.x API reference and the relevant change records
on 2026-10-30. It is written against Drupal 11.4.x, Drush 13 or 14 and PHP 8.3 or later (see
Requirements and hosting for the baseline). The examples belong to the
fictitious Meridian Museum site from the sub-section index and extend the meridian_collection
module with a quick-view dialog, a "show on display" toggle that returns several commands, a custom Ajax command, and a
"load more" list built with HTMX. Drupal 10 reaches end of life on 2026-12-09, so new sites should start on Drupal 11.
Drupal 12.0.0 is planned for the week of 2026-12-07 and is not yet released; the Drupal 12 remarks describe changes
recorded in change records for the 12.0.0 betas and should be re-checked at the final release.
Who uses Ajax and HTMX, and why
-
The module developer adds interactions to routes, forms and blocks: dialogs, partial updates, inline toggles and "load more" lists.
-
The site builder meets them without writing code: the Media Library dialog, the Views live preview, the Layout Builder off-canvas tray, dependent form fields and exposed filters that update a Views result in place all run on the Ajax framework.
-
The themer needs to know that markup replaced by Ajax or HTMX must still carry behaviors (see Libraries and assets) and that classes and wrapper ids are part of the contract.
-
The administrator needs to know that proxies, CDNs and web application firewalls must let the HTMX request headers through, and that dynamic requests are visible in the logs as ordinary GET and POST requests with a query string.
-
The security reviewer checks that every endpoint behind a dynamic interaction has an access check, that state-changing routes use CSRF protection, and that swapped HTML is rendered through Drupal’s escaping.
Using the framework is not optional for dynamic behaviour in Drupal. Hand-written fetch() calls that inject HTML skip the
asset loading, behavior attachment (Drupal.attachBehaviors), cache metadata, form token handling and access checks that
the Ajax subsystem provides, and are the usual cause of "works on the front page, breaks behind the cache" reports.
Version history
-
Drupal 7 introduced the Ajax framework with command functions. Drupal 8.0 made the response an
AjaxResponseobject and turned the commands into classes of theDrupal\Core\Ajaxnamespace. -
Drupal 8.7.0 added
MessageCommand, which displays messages through theDrupal.Message()object. -
Drupal 10.0 removed the
core/jquery.formlibrary; the Ajax documentation notes that form callbacks that return anAjaxResponsedo not need it. -
Drupal 10.1 and later let controllers use constructor injection and autowiring (see Services and dependency injection); Ajax commands themselves did not change.
-
Drupal 11.2 added the htmx library as a core dependency.
-
Drupal 11.3 made HTMX a supported part of the Ajax subsystem: the
\Drupal\Core\Htmx\Htmxclass, thecore/drupal.htmxlibrary,HtmxRequestInfoTrait(also added toFormBase), thedrupal_htmxwrapper format and the_htmx_routeroute option, andFormBuilderawareness of HTMX requests. Proxies and CDNs must pass the HTMX headers from this version on. -
Drupal 11.5 (the release that follows 11.4) deprecates
getHtmxPrompt()andgetHtmxTrigger()ofHtmxRequestInfoTrait, for removal in Drupal 12.0.0. -
Drupal 12 moves core to htmx 4, a rewrite with breaking changes:
fetch()replacesXMLHttpRequest, error responses are swapped, event names and several attributes and headers change, and someHtmxmethods are renamed or removed.FormBuilderInterface::HTMX_REQUESTis deprecated for removal in Drupal 13. See Drupal 12 and htmx 4.
Older books describe an earlier state. Drupal 10 Development Cookbook (2023) teaches the Form API and #ajax callbacks
well as a learning sequence, but it targets Drupal 10.0 on PHP 8.1: it predates HTMX in core, its examples reach services
through \Drupal:: calls inside classes (the current pattern is constructor injection, with the static calls kept for
procedural code), and its hooks are .module functions, where Drupal 11.1 and later use #[Hook] methods (see
Hooks and OOP hooks). Drupal 10 Masterclass (2023) does not cover HTMX, and
Modernizing Drupal 10 Theme Development (2023) predates the htmx library in core, so none of the three books mentions
Htmx, core/drupal.htmx or the Ajax API to HTMX documentation. Where the books and the current documentation disagree,
the documentation described here applies.
How the Ajax framework works
The framework has a client part and a server part.
-
The client part is
core/misc/ajax.js, in thecore/drupal.ajaxlibrary. ADrupal.ajaxobject represents one possible request: its URL, the element that triggers it, the event, how to submit data and how to show progress. It sends the request with an extra query parameter,_wrapper_format=drupal_ajax, and runs each command in the JSON array of the response. -
The server part is the
AjaxResponseclass (\Drupal\Core\Ajax\AjaxResponse), a response that holds a list of command objects. Each command implementsCommandInterface::render()and returns an array that becomes one JSON entry with acommandkey. The matching JavaScript function lives inDrupal.AjaxCommands.prototype. -
The
drupal_ajaxwrapper format tells the main-content view subscriber to render the route’s result with the Ajax renderer. A controller that returns a render array is wrapped into aninsertcommand (or a dialog command for the dialog formats); a controller that returns anAjaxResponseis used as it is. -
Libraries and settings that the new content needs are added automatically: the Ajax attachments processor turns the attachments of the response (libraries,
drupalSettings) into commands such asadd_css,add_jsandsettingsthat run before the content commands.
Four ways to start a request
| Trigger | How it works |
|---|---|
A link with the |
The framework turns the link into a |
A submit button with the |
The form is posted to its action through Ajax, and the route must return a response the JavaScript can process. |
The |
Builds the |
JavaScript |
|
Source: Basic Concepts
The /nojs/ segment that older routes put in their path is replaced by /ajax/ when the request is an Ajax request, which
lets one route serve both the Ajax and the plain page version of an interaction. A route that must work without
JavaScript returns a render array for the full page and receives the Ajax treatment only when _wrapper_format is present.
Dialogs from links
Dialogs are the most common use of the framework and need no PHP beyond a normal route. The library
core/drupal.dialog.ajax has to be loaded on the page that holds the link; a module declares it as a dependency of its own
library or attaches it to the render array.
On the Meridian site every card in the collection listing offers a quick view of the object in a modal dialog. The route returns an ordinary render array.
# modules/custom/meridian_collection/meridian_collection.routing.yml
meridian_collection.quick_view:
path: '/collection/{collection_object}/quick-view'
defaults:
_controller: '\Drupal\meridian_collection\Controller\QuickViewController::view'
_title: 'Quick view'
requirements:
_entity_access: 'collection_object.view'
collection_object: '\d+'
options:
parameters:
collection_object:
type: 'entity:collection_object'
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Url;
use Drupal\Component\Serialization\Json;
use Drupal\meridian_collection\Entity\CollectionObject;
/**
* Quick view of one museum object.
*/
final class QuickViewController extends ControllerBase {
public function __construct(
private readonly EntityTypeManagerInterface $entityTypes,
) {}
/**
* Returns the teaser of the object; the dialog is created by the link.
*
* @return array<string, mixed>
* A render array.
*/
public function view(CollectionObject $collection_object): array {
return $this->entityTypes
->getViewBuilder('collection_object')
->view($collection_object, 'teaser');
}
/**
* Builds the link that opens the quick view in a modal dialog.
*
* @return array<string, mixed>
* A link render array.
*/
public function link(CollectionObject $collection_object): array {
return [
'#type' => 'link',
'#title' => $this->t('Quick view'),
'#url' => Url::fromRoute('meridian_collection.quick_view', [
'collection_object' => $collection_object->id(),
]),
'#attributes' => [
'class' => ['use-ajax', 'button'],
'data-dialog-type' => 'modal',
'data-dialog-options' => Json::encode(['width' => 700, 'title' => $collection_object->label()]),
],
'#attached' => ['library' => ['core/drupal.dialog.ajax']],
];
}
}
Source: Ajax Dialog Boxes
The link degrades gracefully: without JavaScript it simply navigates to the quick-view route. In a template the same markup
is written with class="use-ajax", data-dialog-type="modal" and a data-dialog-options value whose double quotes are
written as "; invalid JSON in that attribute is the commonest reason a dialog does not open. A link that returns a
form shows the form’s action buttons at the bottom of the dialog (they must be in an actions element), and a cancel link
needs the button and dialog-cancel classes to appear in the action area.
AjaxResponse and the core commands
When a route must change several unrelated parts of the page, it returns an AjaxResponse and adds commands with
$response->addCommand(). Commands run in the order they were added. The core commands, all in the Drupal\Core\Ajax
namespace, are:
| Command | Arguments | Effect |
|---|---|---|
|
selector, content, optional settings |
Replaces the matched elements with the content. |
|
selector, content, optional settings |
Replaces the inner HTML of the matched elements. |
|
selector, content, optional settings |
Adds content inside the matched elements, at the end or at the start. |
|
selector, content, optional settings |
Adds content before or after the matched elements. |
|
selector, content, optional settings |
Generic insert that uses the method of the triggering element’s |
|
selector |
Removes the matched elements. |
|
selector and an array of properties; a |
Sets inline CSS properties on elements, or adds a style sheet to the page. |
|
selector, key, value |
Stores a value with the jQuery data API. |
|
selector, method, optional arguments |
Calls any jQuery method on the matched elements. |
|
selector, optional asterisk |
Marks elements as changed. |
|
message, optional selector, options and "clear previous" flag |
Adds a status message through the |
|
text, optional priority |
Sends a screen-reader announcement through |
|
text |
Shows a JavaScript alert box. |
|
full URL |
Loads another page in the browser. |
|
title, content, optional dialog options and settings |
Opens a modal dialog; needs |
|
selector, title, content, optional options and settings |
Opens a non-modal dialog on a given selector. |
|
title, content, optional options, settings and position |
Opens the off-canvas tray. |
|
optional persist flag; selector and persist flag |
Closes the visible modal dialog or a given dialog. |
|
selector and an option name and value; selector and a title |
Changes an option or the title of an open dialog. |
|
settings, optional merge flag |
Replaces or extends |
|
old and new |
Updates the hidden build id of a form (used by form rebuilds). |
|
selector |
Resets the odd and even striping of table rows. |
Source: Core AJAX Callback Commands
Several contributed or core modules ship commands of their own, for example the Media Library selection command and the
Views commands (ViewsHighlightCommand, ViewsScrollTopCommand, ViewsTriggerPreviewCommand and others). Selectors in
command classes need the # prefix for an id, unlike the wrapper key of #ajax, which takes the bare id.
A toggle that returns four commands
Curators mark an object "on display" from the collection listing. The link is an Ajax link; the route changes the entity,
swaps the link and the status badge, shows a message and announces the change to screen reader users. The route must change
state, so it is protected by a CSRF token (_csrf_token, see
Routing and controllers) and by an entity access check.
# modules/custom/meridian_collection/meridian_collection.routing.yml
meridian_collection.toggle_display_ajax:
path: '/collection/{collection_object}/toggle-display/ajax'
defaults:
_controller: '\Drupal\meridian_collection\Controller\DisplayToggleController::toggle'
requirements:
_entity_access: 'collection_object.update'
_csrf_token: 'TRUE'
collection_object: '\d+'
options:
parameters:
collection_object:
type: 'entity:collection_object'
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Controller;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\AnnounceCommand;
use Drupal\Core\Ajax\InvokeCommand;
use Drupal\Core\Ajax\MessageCommand;
use Drupal\Core\Ajax\ReplaceCommand;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Url;
use Drupal\meridian_collection\Entity\CollectionObject;
/**
* Toggles whether a museum object is on display, over Ajax.
*/
final class DisplayToggleController extends ControllerBase {
/**
* Flips the on_display field and returns the commands that update the page.
*/
public function toggle(CollectionObject $collection_object): AjaxResponse {
$on_display = !(bool) $collection_object->get('on_display')->value;
$collection_object->set('on_display', $on_display)->save();
$id = (int) $collection_object->id();
$label = $collection_object->label();
$text = $on_display
? $this->t('@label is now on display.', ['@label' => $label])
: $this->t('@label was taken off display.', ['@label' => $label]);
$link = [
'#type' => 'link',
'#title' => $on_display ? $this->t('Take off display') : $this->t('Put on display'),
'#url' => Url::fromRoute('meridian_collection.toggle_display_ajax', ['collection_object' => $id]),
'#attributes' => [
'id' => 'meridian-toggle-' . $id,
'class' => ['use-ajax', 'button'],
],
];
$response = new AjaxResponse();
// The replacement markup keeps the wrapper id, or the second click finds nothing.
$response->addCommand(new ReplaceCommand('#meridian-toggle-' . $id, $link));
$response->addCommand(new InvokeCommand('#meridian-card-' . $id, 'toggleClass', ['is-on-display', $on_display]));
$response->addCommand(new MessageCommand((string) $text, NULL, ['type' => 'status'], TRUE));
$response->addCommand(new AnnounceCommand((string) $text));
return $response;
}
}
Source: Core AJAX Callback Commands
Points to notice. The controller uses no static \Drupal:: call, and ControllerBase already provides t(). The link
render array is passed to ReplaceCommand and is rendered by the Ajax renderer, which also collects its cache metadata and
attachments. When the link is built with Url::fromRoute() and rendered by Drupal, the CSRF token is added to the URL as a query
parameter; a hand-written URL in a template does not carry it and is rejected with a 403 response.
Opening a dialog from a command
When the dialog depends on logic in the controller (for example, a different title depending on the object), the controller
creates the dialog with a command instead of letting the data-dialog-type attribute do it.
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\OpenModalDialogCommand;
public function provenance(CollectionObject $collection_object): AjaxResponse {
$build = $this->entityTypes
->getViewBuilder('collection_object')
->view($collection_object, 'provenance');
$response = new AjaxResponse();
$response->addCommand(new OpenModalDialogCommand(
$this->t('Provenance of @label', ['@label' => $collection_object->label()]),
$build,
['width' => 800],
));
return $response;
}
Source: Core AJAX Callback Commands
The link that calls this route has the use-ajax class but no data-dialog-type, because the command creates the dialog.
core/drupal.dialog.ajax still has to be loaded on the page.
Writing a custom command
A custom command is a PHP class that implements \Drupal\Core\Ajax\CommandInterface and a JavaScript function with the name
that the class returns. Use one when the page needs a behaviour that no core command offers and when it must run in the
sequence of the other commands. The Meridian site highlights a card briefly after its status changes.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Ajax;
use Drupal\Core\Ajax\CommandInterface;
/**
* Highlights an element for a short time.
*/
final class HighlightCardCommand implements CommandInterface {
public function __construct(
private readonly string $selector,
private readonly int $duration = 1500,
) {}
public function render(): array {
return [
'command' => 'meridianHighlightCard',
'selector' => $this->selector,
'duration' => $this->duration,
];
}
}
# modules/custom/meridian_collection/meridian_collection.libraries.yml
ajax_commands:
version: 1.x
js:
js/ajax-commands.js: {}
dependencies:
- core/drupal
- core/drupal.ajax
// modules/custom/meridian_collection/js/ajax-commands.js
((Drupal) => {
/**
* Runs for the meridianHighlightCard command returned by the server.
*
* @param {Drupal.Ajax} ajax The Ajax object that sent the request.
* @param {object} response The command: selector and duration.
* @param {number} status The HTTP status of the response.
*/
Drupal.AjaxCommands.prototype.meridianHighlightCard = function (ajax, response, status) {
document.querySelectorAll(response.selector).forEach((element) => {
element.classList.add('is-highlighted');
setTimeout(() => element.classList.remove('is-highlighted'), response.duration);
});
};
})(Drupal);
The command is added to the response in the controller and the library is attached to the response so that the script is loaded before the command runs:
$response->addAttachments(['library' => ['meridian_collection/ajax_commands']]);
$response->addCommand(new HighlightCardCommand('#meridian-card-' . $id));
Source: Core AJAX Callback Commands
If the library is attached to the page that holds the link instead, the command also works, and loading it once with the page avoids a second request after the first Ajax call. A command whose JavaScript is missing fails silently on the page; the browser console is the first place to look. Command names are global to the page, so prefix them with the module name.
Ajax pitfalls
-
The wrapper id is lost. Replacement markup must contain the same id as the element it replaces, or the second interaction finds nothing to replace.
-
The callback or controller returns
NULLor an invalid array. The request breaks; return anAjaxResponseor a valid render array. -
Behaviors do not run on the new markup. Code that binds on page load instead of using
Drupal.behaviors(withonce()) is not attached to inserted elements. -
Content is replaced but the styles are missing. The response did not carry the library. Attach the library to the render array that is returned, not only to the page.
-
Dialog libraries are not loaded.
OpenModalDialogCommandanddata-dialog-typeneedcore/drupal.dialog.ajax. -
State-changing GET route without a token. Use
_csrf_tokenand build the link withUrl. -
Unescaped output in commands. Content passed to a command as a string is inserted as HTML. Pass render arrays, or escape the text first; never concatenate user input into markup.
-
Cache confusion. An Ajax GET response is a different representation of the same path because of the
_wrapper_formatquery parameter. Reverse proxies that strip query strings serve the wrong variant; keep the query string in the cache key (see Performance and caching).
HTMX in core
HTMX is a small JavaScript library that extends HTML with attributes: an element states which request to make (hx-get,
hx-post), which part of the response to use (hx-select), where to put it (hx-target) and how (hx-swap). The server
answers with HTML, not with commands. Drupal 11.2 added the library as a dependency and Drupal 11.3 integrated it with the
Ajax subsystem. Drupal writes the standard attributes with the data-hx- prefix, so markup stays valid HTML.
The parts of the integration are:
-
The
\Drupal\Core\Htmx\Htmxclass. A builder that creates the attributes and the response headers. It ends withapplyTo($element), which writes the attributes, the headers, thecore/drupal.htmxlibrary and the cache metadata into a render array. When a method takes aUrl, the metadata of rendering that URL is merged into the element. The class is internal by policy, has no interface and changes only when HTMX changes.Htmx::createFromRenderArray()rebuilds an instance from an existing element. -
The
core/drupal.htmxlibrary. Attached byapplyTo(); it loads the htmx library and the Drupal glue. -
HtmxRequestInfoTrait. Protected helpers for classes that build responses (it is already part ofFormBase):isHtmxRequest(),isHtmxBoosted(),getHtmxCurrentUrl(),isHtmxHistoryRestoration(),getHtmxTarget(),getHtmxTriggerName(), and the deprecatedgetHtmxPrompt()andgetHtmxTrigger(). The using class implements the abstractgetRequest(). -
The
drupal_htmxwrapper format andHtmxRenderer. A request with_wrapper_format=drupal_htmxis rendered as the main content plus the CSS and JavaScript it needs, in the simplest HTML document: no regions, nopage_topandpage_bottom. -
onlyMainContent(). A method ofHtmxthat sets adata-hx-drupal-only-main-contentattribute; Drupal then adds the wrapper format to the requests it prepares for that element. Without it the request receives the full page, with all regions and blocks, andhx-selectpicks a fragment from it. -
The
_htmx_routeroute option. A route that exists only to serve HTMX requests sets_htmx_route: TRUEunderoptionsand receives the minimal response without the query parameter. Core’s test module uses this on a route that has only theaccess contentpermission as a requirement. A route that also serves full-page requests must not use it, because the response for the same URL would be minimal for everyone and a cached copy could be served to full-page requests. -
FormBuilder. Recognises HTMX requests as it does Ajax API requests and updates theform_build_idelement on the response. -
HtmxLocationResponseData. A data object for the structured form of theHX-Locationheader.
The attribute methods of Htmx mirror the htmx attributes: get(), post(), put(), patch() and delete() set the
request (a Url argument sets the address; without one the current URL is used), trigger(), on(), select(),
selectOob(), target(), swap() (strategy, modifiers and whether to ignore the title), swapOob(), indicator(),
sync(), include(), vals(), headers(), boost(), confirm(), encoding(), preserve(), validate(),
pushUrl(), replaceUrl() and historyElt(). The response-header methods are locationHeader(), pushUrlHeader(),
replaceUrlHeader(), redirectHeader(), refreshHeader(), reswapHeader(), retargetHeader(), reselectHeader() and
triggerHeader().
Source: API reference: Htmx
A "load more" list with HTMX
The "On display now" page of the Meridian site lists objects 12 at a time. A button replaces itself with the next chunk and a new button, a classic click-to-load pattern. The route serves only HTMX requests.
# modules/custom/meridian_collection/meridian_collection.routing.yml
meridian_collection.on_display_more:
path: '/collection/on-display/more/{page}'
defaults:
_controller: '\Drupal\meridian_collection\Controller\OnDisplayController::more'
page: 1
requirements:
_permission: 'access content'
page: '\d+'
options:
_htmx_route: TRUE
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Htmx\Htmx;
use Drupal\Core\Htmx\HtmxRequestInfoTrait;
use Drupal\Core\Url;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* The "On display now" list, loaded in chunks.
*/
final class OnDisplayController extends ControllerBase {
use HtmxRequestInfoTrait;
private const PAGE_SIZE = 12;
public function __construct(
private readonly EntityTypeManagerInterface $entityTypes,
private readonly RequestStack $requestStack,
) {}
protected function getRequest(): Request {
return $this->requestStack->getCurrentRequest();
}
/**
* Returns one chunk of objects, followed by a button for the next chunk.
*
* @return array<string, mixed>|\Symfony\Component\HttpFoundation\RedirectResponse
* A render array for HTMX requests; other requests go to the full page.
*/
public function more(int $page): array|RedirectResponse {
if (!$this->isHtmxRequest()) {
return new RedirectResponse(Url::fromRoute('view.on_display.page_1')->toString());
}
$storage = $this->entityTypes->getStorage('collection_object');
$ids = $storage->getQuery()
->accessCheck(TRUE)
->condition('on_display', 1)
->sort('title')
->range($page * self::PAGE_SIZE, self::PAGE_SIZE + 1)
->execute();
$has_more = count($ids) > self::PAGE_SIZE;
$ids = array_slice($ids, 0, self::PAGE_SIZE, TRUE);
$build = [
'#type' => 'container',
'#attributes' => ['class' => ['meridian-chunk']],
'items' => $this->entityTypes
->getViewBuilder('collection_object')
->viewMultiple($storage->loadMultiple($ids), 'teaser'),
'#cache' => [
'tags' => ['collection_object_list'],
'contexts' => ['user.permissions'],
],
];
if ($has_more) {
$build['more'] = $this->moreButton($page + 1);
}
// The browser address stays on the list page.
(new Htmx())->pushUrlHeader(FALSE)->applyTo($build);
return $build;
}
/**
* Builds the button that fetches the chunk after this one.
*
* @return array<string, mixed>
* A link render array carrying the HTMX attributes.
*/
public function moreButton(int $next): array {
$button = [
'#type' => 'link',
'#title' => $this->t('Load more'),
'#url' => Url::fromRoute('meridian_collection.on_display_more', ['page' => $next]),
'#attributes' => ['class' => ['button', 'meridian-load-more']],
];
(new Htmx())
->get($button['#url'])
->select('.meridian-chunk')
->target('this')
->swap('outerHTML')
->applyTo($button);
return $button;
}
}
How it works. The first page of the list (a Views page or a block) renders moreButton(1). applyTo() writes
data-hx-get, data-hx-select, data-hx-target, data-hx-swap and attaches core/drupal.htmx. A click sends a GET
request with the HX-Request header to the HTMX-only route; the route returns a meridian-chunk container, and htmx
swaps that container for the button that was clicked (target('this') with outerHTML). The new button, if there is one,
is inside the new chunk, so the cycle repeats. Drupal attaches the behaviors to the new content itself, as it does for
Ajax responses.
The request and the response look like this:
GET /collection/on-display/more/2 HTTP/1.1
Host: meridian.ddev.site
HX-Request: true
HX-Current-URL: https://meridian.ddev.site/collection/on-display
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Cache-Control: must-revalidate, no-cache, private
X-Drupal-Dynamic-Cache: MISS
Because the controller calls pushUrlHeader(FALSE) on the chunk, the response carries HX-Push-Url: false and the
browser location does not change; header methods belong on the render array of the response, not on the element that
starts the request. The change record shows pushUrlHeader() with a Url to put a new address in the browser history.
Source: HTMX (Drupal APIs)
Choosing between _htmx_route and onlyMainContent()
-
Use
onlyMainContent()on the element when the same URL also serves a normal page (an exposed filter form, a settings form, the Config single export form); the full page remains the answer to a request without HTMX. -
Use
_htmx_route: TRUEfor a route that only exists as a fragment endpoint, like the load-more route above. -
Use neither when the response is a deliberately small fragment already, or when the whole page is wanted and
hx-selectextracts the fragment.
The Config single export form of core shows the first style: its trigger name comes from
HtmxRequestInfoTrait::getHtmxTriggerName() and the elements it rebuilds carry the attributes built with Htmx.
Responding with headers
HTMX uses response headers for navigation and events, and the Htmx class builds them on the render array that the
controller returns. The example below saves the toggle, fires an event that a behavior can listen to and, as a
second option, sends the curator to the object page.
use Drupal\Core\Htmx\Htmx;
use Drupal\Core\Url;
public function toggleHtmx(CollectionObject $collection_object): array {
$collection_object->set('on_display', !(bool) $collection_object->get('on_display')->value)->save();
$build = [
'#markup' => $this->t('Saved.'),
];
(new Htmx())
// Fires a client-side event; listeners can refresh the card.
->triggerHeader(['meridian:objectToggled' => ['id' => (int) $collection_object->id()]])
// Navigates after the swap. Use only when the user must leave the page.
->redirectHeader(Url::fromRoute('entity.collection_object.canonical', [
'collection_object' => $collection_object->id(),
]))
->applyTo($build);
return $build;
}
Source: API reference: Htmx
redirectHeader() sets HX-Redirect, which makes the browser load the URL as a full navigation; locationHeader() sets
HX-Location, which navigates without a full reload. Both are only sensible when the page is meant to change. The other
response headers (HX-Refresh, HX-Reswap, HX-Retarget, HX-Reselect, HX-Replace-Url) let the server override what
the element requested, which is useful when an error replaces the target with something else.
A DDEV site to try the examples
The examples need a running site. A local DDEV project is enough.
# .ddev/config.yaml
name: meridian
type: drupal
docroot: web
php_version: "8.3"
webserver_type: nginx-fpm
database:
type: mariadb
version: "10.11"
ddev start
ddev composer create-project drupal/recommended-project:^11.4 .
ddev drush site:install minimal --account-name=admin --site-name="Meridian Museum" -y
ddev drush en meridian_collection -y
ddev drush cr
The HTMX endpoint can be tested with a request that carries the HTMX header:
curl -s -H 'HX-Request: true' https://meridian.ddev.site/collection/on-display/more/1 | head -n 20
Source: DDEV quickstart: Drupal
The Ajax API to HTMX cookbook
The Drupal documentation maintains a cookbook, "Ajax API to HTMX", with one page per Ajax command for developers who refactor legacy code. Its overview makes the points that matter for every conversion:
-
HTMX uses HTML as its data structure, so the server builds a rendered response, not a list of commands.
-
Logic in an Ajax callback that returns a render array can often be reused to conditionally build that render array.
-
Form callbacks differ: the form is built before any input exists, but a callback reacts to input. The authors found it necessary to check for input and pass the values to the code that builds the render array.
-
With Ajax the callback system identifies the triggering element; with HTMX the code checks the trigger and alters the response itself, using
getHtmxTriggerName()fromHtmxRequestInfoTrait. -
Render array structure is treated as changeable by the frontend backward compatibility policy, so a refactor should publish a change record to alert contributors.
The cookbook pages are marked as work in progress: some, such as the page on RedirectCommand, say that they need work and link
to open issues, and the page on OpenModalDialogCommand is marked as needing review. The table summarises the conversions
that are documented and the equivalents that follow from them; check the cookbook page of a command before relying on a
pattern.
| Ajax command | HTMX equivalent | Notes |
|---|---|---|
|
|
Documented in the cookbook. Build the dynamic part during the normal form build and let the rebuilt form provide the markup. |
|
|
Replaces the content of the target only. |
|
|
Standard htmx swap strategies. |
|
|
Standard htmx swap strategies. |
|
Swap an empty fragment, or use the |
Standard htmx behaviour. |
|
A native |
Marked as needing review in the cookbook. |
|
|
The cookbook page is unfinished; the methods come from the |
|
Render the status messages or the text inside the swapped fragment, or fire an event with |
A pattern rather than a documented conversion; accessibility of live regions still applies. |
|
Return the final markup (a class on the replaced element) or handle an event with a behavior. |
The server decides the result in HTMX; there is no remote jQuery call. |
Source: Ajax API to HTMX: Overview
Source: Ajax API to HTMX: ReplaceCommand
A conversion: the toggle with HTMX
The Ajax toggle above returns four commands. With HTMX the link swaps itself for the replacement markup, and the card class and the message come from the same response. The server returns the card fragment, not commands.
public function cardHtmx(CollectionObject $collection_object): array {
$build = $this->entityTypes
->getViewBuilder('collection_object')
->view($collection_object, 'teaser');
$build['#prefix'] = '<div id="meridian-card-' . (int) $collection_object->id() . '">';
$build['#suffix'] = '</div>';
$build['status'] = ['#type' => 'status_messages'];
return $build;
}
public function toggleLink(CollectionObject $collection_object): array {
$link = [
'#type' => 'link',
'#title' => $this->t('Put on display'),
'#url' => Url::fromRoute('meridian_collection.toggle_display_htmx', [
'collection_object' => $collection_object->id(),
]),
];
(new Htmx())
->post($link['#url'])
->select('#meridian-card-' . (int) $collection_object->id())
->target('#meridian-card-' . (int) $collection_object->id())
->swap('outerHTML')
->applyTo($link);
return $link;
}
Source: Ajax API to HTMX: ReplaceCommand
The routes behind toggle_display_htmx (the POST that changes state and a GET that renders the card) are declared like the
Ajax route above. The state-changing one keeps _entity_access: 'collection_object.update' and _csrf_token: 'TRUE' and is
limited to methods: [POST].
Security, caching and operations
-
Access checks are the same. A route called through HTMX is an ordinary route:
_permission,_entity_accessand the other requirements apply. Do not rely on the element being hidden. A fragment route without an access check exposes content to anyone who guesses its URL. -
State-changing requests need protection. Use
_csrf_tokenfor links and keep forms in the Form API, which adds the form token. Use POST for changes, never GET. -
Escaping is on. HTMX swaps HTML, so the server is responsible for what the HTML contains. Render through render arrays and Twig, which escape by default;
|raworMarkup::create()with user input is a cross-site scripting risk. From Drupal 12 on, htmx 4 swaps error responses too, so an error page returned with a 4xx or 5xx status can end up inside the page; design error responses as swap content or handle the status with thehx-statusattribute. -
Headers must reach Drupal. Proxies, CDNs and WAFs must pass the HTMX request headers (
HX-Boosted,HX-Current-URL,HX-History-Restore-Request,HX-Prompt,HX-Request,HX-Target,HX-Trigger-Name,HX-Trigger) and the response headers such asHX-Redirect. Hosts that filter non-standard headers break HTMX interactions without any error on the server. The Drupal 11.3 release notes state the requirement for proxies and CDNs. See Security hardening and Requirements and hosting. -
Cache metadata.
Htmx::applyTo()merges the cacheability of everyUrlit was given; the fragment must declare its own tags and contexts, asmore()does. Page cache and Dynamic Page Cache vary by URL and query string, so the HTMX fragment and the full page for the same path are separate entries only when the wrapper format or the route differs. Keep fragment routes separate for this reason (see Render API and cache metadata). -
Logs and monitoring. HTMX and Ajax requests appear in the access log as normal requests. Rate limits that count requests per page may need a higher limit for pages that poll.
-
Progressive enhancement. An Ajax link works without JavaScript because it is an ordinary link. An HTMX button built from a link does the same; a button built from a
buttonelement does not. Prefer links for navigation-like actions.
Choosing between Ajax and HTMX
| Question | Ajax framework | HTMX (11.3 and later) |
|---|---|---|
Where is the behaviour described? |
In |
In attributes generated by the |
What does the server return? |
A list of commands ( |
HTML that the browser swaps in, with headers for navigation and events. |
Client code |
The jQuery-based |
The htmx library; no jQuery is needed for the swap itself. |
Dialogs |
Built in: |
Not built in; the cookbook uses a native |
State of a form |
The form-state cache keyed by |
The same form builder; |
Availability |
Every Drupal 8 and later version; still how most core and contributed UI works. |
Drupal 11.3 and later only. |
Future |
Maintained next to HTMX. The community plan is to replace Ajax with HTMX gradually; a change record announces no
deprecation of |
Drupal 12 moves to htmx 4, with breaking changes for the |
Source: HTMX (Drupal APIs)
For the Meridian module: keep the toggle, the dialogs and any code that must run on Drupal 10.x on the Ajax framework;
write new fragment interactions (load more, inline filters, dependent fields) for Drupal 11.3 and later with the Htmx
class; use #states for pure show and hide logic; and do not put #ajax and HTMX attributes on the same element.
Drupal 12 and htmx 4
The change record "htmx now uses version 4" (Drupal 12.0.0-beta1) updates the library and lists items that affect module code.
-
fetch()replacesXMLHttpRequest; the request headerHX-Triggeris replaced byHX-Source, read withHtmxRequestInfoTrait::getHtmxSource(), andHX-Request-Typeis added.getHtmxPrompt()andgetHtmxTrigger()are removed (deprecated since 11.5.0). -
Error responses are swapped into the target (all responses except 204 and 304), which changes error handling.
-
Removed attributes and the
Htmxmethods that wrote them:params(),prompt(),ext(),disinherit(),inherit(),request()andhistory().hx-triggerloses its queue modifier and the history cache is gone. -
Renames: replace
Htmx::disable()withHtmx::ignore()first, thenHtmx::disabledElt()withHtmx::disable(), in that order, so the new calls are not overwritten. -
New:
hx-query,hx-action,hx-methodandhx-statushaveHtmxmethods; inheritance is explicit, controlled with the constantsHtmx::NO_MODIFIER,Htmx::INHERITEDandHtmx::APPEND. -
HX-Trigger-After-SwapandHX-Trigger-After-Settleare removed (triggerAfterSwapHeader()andtriggerAfterSettleHeader()are deprecated); usetriggerHeader()or listeners for the htmx events. All event names change, so custom JavaScript that listens to htmx events must be updated. -
FormBuilderInterface::HTMX_REQUESTis deprecated and will be removed in Drupal 13.
Because the changes are not backwards compatible, a module that supports Drupal 11 and 12 and uses the changed methods needs
\Drupal\Component\Utility\DeprecationHelper. The change record does not show how; the helper works like this:
use Drupal\Component\Utility\DeprecationHelper;
use Drupal\Core\Htmx\Htmx;
/**
* Disables htmx processing for an element on Drupal 11.x and 12.
*/
function meridian_collection_ignore_htmx(array &$element): void {
$htmx = new Htmx();
DeprecationHelper::backwardsCompatibleCall(
currentVersion: \Drupal::VERSION,
deprecatedVersion: '12.0.0',
currentCallable: fn() => $htmx->ignore(),
deprecatedCallable: fn() => $htmx->disable(),
);
$htmx->applyTo($element);
}
The Drupal 12 changes are recorded against the 12.0.0 betas; check the record again at the final release, and read the upstream htmx 4 migration guide when custom JavaScript uses the htmx API directly. See also Preparing for Drupal 12 and Where older books and tutorials are outdated.
Common errors and pitfalls with HTMX
-
Nothing happens on click. The
core/drupal.htmxlibrary is not attached. Always build attributes withapplyTo()on a render array, or attach the library yourself when writing raw attributes in Twig. -
The whole page appears inside the target. The route returned the full page and
select()is missing, or the element lacksonlyMainContent(). -
The fragment is cached for full-page visitors. A route that serves both uses
_htmx_route. UseonlyMainContent()or separate routes. -
The form works locally and fails in production. A proxy strips the HTMX headers.
-
target('this')and the title.swap()ignores the title of the response by default; passFALSEas the third argument if the fragment changes the page title. -
Dialogs. There is no HTMX dialog command; do not expect
data-dialog-typeto work on an HTMX element. -
Mixing both systems on one element. The two systems handle the same event and the result depends on the order of attachment.
-
Behaviors on swapped content. Drupal attaches them after a swap, but behaviors that bind to
documentrather than to the new elements, or that do not useonce(), run more than once.
Summary checklist
-
Decide the minimum Drupal version, then choose Ajax (10.x, 11.0 to 11.2, dialogs, existing commands) or HTMX (11.3 and later).
-
Put an access check on every fragment or command route and a
_csrf_tokenon state-changing links. -
Return render arrays so that escaping, cache metadata and attachments work.
-
Use
applyTo()for HTMX attributes, and_htmx_routeoronlyMainContent()for fragment responses. -
Let HTMX request headers through proxies, CDNs and WAFs.
-
Before Drupal 12, review calls to
disable(),disabledElt(),params(),prompt(),ext(),history(), theHX-Triggerrequest header andFormBuilderInterface::HTMX_REQUEST, and plan forDeprecationHelperif one code base supports both.