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 AjaxResponse object and turned the commands into classes of the Drupal\Core\Ajax namespace.

  • Drupal 8.7.0 added MessageCommand, which displays messages through the Drupal.Message() object.

  • Drupal 10.0 removed the core/jquery.form library; the Ajax documentation notes that form callbacks that return an AjaxResponse do 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\Htmx class, the core/drupal.htmx library, HtmxRequestInfoTrait (also added to FormBase), the drupal_htmx wrapper format and the _htmx_route route option, and FormBuilder awareness 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() and getHtmxTrigger() of HtmxRequestInfoTrait, for removal in Drupal 12.0.0.

  • Drupal 12 moves core to htmx 4, a rewrite with breaking changes: fetch() replaces XMLHttpRequest, error responses are swapped, event names and several attributes and headers change, and some Htmx methods are renamed or removed. FormBuilderInterface::HTMX_REQUEST is 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 the core/drupal.ajax library. A Drupal.ajax object 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 AjaxResponse class (\Drupal\Core\Ajax\AjaxResponse), a response that holds a list of command objects. Each command implements CommandInterface::render() and returns an array that becomes one JSON entry with a command key. The matching JavaScript function lives in Drupal.AjaxCommands.prototype.

  • The drupal_ajax wrapper 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 an insert command (or a dialog command for the dialog formats); a controller that returns an AjaxResponse is 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 as add_css, add_js and settings that run before the content commands.

sequenceDiagram participant V as Visitor participant J as Drupal.ajax (browser) participant R as Route and controller participant P as Ajax renderer V->>J: click a use-ajax link or change an element with Ajax settings J->>R: GET or POST ...?_wrapper_format=drupal_ajax R->>R: access checks, then build content or an AjaxResponse R->>P: render array or AjaxResponse P-->>J: JSON list of commands plus attachments J->>J: run commands (insert, remove, openDialog, message, ...) J->>J: Drupal.attachBehaviors on new content

Four ways to start a request

Trigger How it works

A link with the use-ajax class

The framework turns the link into a Drupal.ajax object. data-dialog-type="modal" (or dialog) opens the response in a dialog, data-dialog-options passes jQuery UI dialog options as JSON, and data-dialog-renderer="off_canvas" selects the off-canvas tray. Optional attributes are data-ajax-wrapper, data-ajax-progress, data-ajax-focus and data-ajax-http-method (GET responses can be cached).

A submit button with the use-ajax-submit class

The form is posted to its action through Ajax, and the route must return a response the JavaScript can process.

The #ajax property of a form element or a link render element

Builds the Drupal.ajax object from the render array. See Form API for the form keys (callback, wrapper, method, event, progress). A link element uses dialogType and dialog keys.

JavaScript

Drupal.ajax({url: ...}) followed by execute() creates and sends a request from a behavior.

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 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']],
    ];
  }

}

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

ReplaceCommand

selector, content, optional settings

Replaces the matched elements with the content.

HtmlCommand

selector, content, optional settings

Replaces the inner HTML of the matched elements.

AppendCommand, PrependCommand

selector, content, optional settings

Adds content inside the matched elements, at the end or at the start.

BeforeCommand, AfterCommand

selector, content, optional settings

Adds content before or after the matched elements.

InsertCommand

selector, content, optional settings

Generic insert that uses the method of the triggering element’s #ajax property.

RemoveCommand

selector

Removes the matched elements.

CssCommand, AddCssCommand

selector and an array of properties; a <style> string

Sets inline CSS properties on elements, or adds a style sheet to the page.

DataCommand

selector, key, value

Stores a value with the jQuery data API.

InvokeCommand

selector, method, optional arguments

Calls any jQuery method on the matched elements.

ChangedCommand

selector, optional asterisk

Marks elements as changed.

MessageCommand

message, optional selector, options and "clear previous" flag

Adds a status message through the Drupal.Message() JavaScript object (since 8.7.0).

AnnounceCommand

text, optional priority

Sends a screen-reader announcement through Drupal.announce().

AlertCommand

text

Shows a JavaScript alert box.

RedirectCommand

full URL

Loads another page in the browser.

OpenModalDialogCommand

title, content, optional dialog options and settings

Opens a modal dialog; needs core/drupal.dialog.ajax.

OpenDialogCommand

selector, title, content, optional options and settings

Opens a non-modal dialog on a given selector.

OpenOffCanvasDialogCommand

title, content, optional options, settings and position

Opens the off-canvas tray.

CloseModalDialogCommand, CloseDialogCommand

optional persist flag; selector and persist flag

Closes the visible modal dialog or a given dialog.

SetDialogOptionCommand, SetDialogTitleCommand

selector and an option name and value; selector and a title

Changes an option or the title of an open dialog.

SettingsCommand

settings, optional merge flag

Replaces or extends drupalSettings.

UpdateBuildIdCommand

old and new form_build_id

Updates the hidden build id of a form (used by form rebuilds).

RestripeCommand

selector

Resets the odd and even striping of table rows.

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;
  }

}

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;
}

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));

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 NULL or an invalid array. The request breaks; return an AjaxResponse or a valid render array.

  • Behaviors do not run on the new markup. Code that binds on page load instead of using Drupal.behaviors (with once()) 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. OpenModalDialogCommand and data-dialog-type need core/drupal.dialog.ajax.

  • State-changing GET route without a token. Use _csrf_token and build the link with Url.

  • 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_format query 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\Htmx class. A builder that creates the attributes and the response headers. It ends with applyTo($element), which writes the attributes, the headers, the core/drupal.htmx library and the cache metadata into a render array. When a method takes a Url, 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.htmx library. Attached by applyTo(); it loads the htmx library and the Drupal glue.

  • HtmxRequestInfoTrait. Protected helpers for classes that build responses (it is already part of FormBase): isHtmxRequest(), isHtmxBoosted(), getHtmxCurrentUrl(), isHtmxHistoryRestoration(), getHtmxTarget(), getHtmxTriggerName(), and the deprecated getHtmxPrompt() and getHtmxTrigger(). The using class implements the abstract getRequest().

  • The drupal_htmx wrapper format and HtmxRenderer. A request with _wrapper_format=drupal_htmx is rendered as the main content plus the CSS and JavaScript it needs, in the simplest HTML document: no regions, no page_top and page_bottom.

  • onlyMainContent(). A method of Htmx that sets a data-hx-drupal-only-main-content attribute; 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, and hx-select picks a fragment from it.

  • The _htmx_route route option. A route that exists only to serve HTMX requests sets _htmx_route: TRUE under options and receives the minimal response without the query parameter. Core’s test module uses this on a route that has only the access content permission 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 the form_build_id element on the response.

  • HtmxLocationResponseData. A data object for the structured form of the HX-Location header.

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().

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.

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: TRUE for 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-select extracts 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;
}

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

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() from HtmxRequestInfoTrait.

  • 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

ReplaceCommand, or #ajax with wrapper

post() or get(), then select('#id'), target('#id'), swap('outerHTML') on the triggering element.

Documented in the cookbook. Build the dynamic part during the normal form build and let the rebuilt form provide the markup.

HtmlCommand

swap('innerHTML')

Replaces the content of the target only.

AppendCommand, PrependCommand

swap('beforeend'), swap('afterbegin')

Standard htmx swap strategies.

BeforeCommand, AfterCommand

swap('beforebegin'), swap('afterend')

Standard htmx swap strategies.

RemoveCommand

Swap an empty fragment, or use the delete swap strategy.

Standard htmx behaviour.

OpenModalDialogCommand

A native dialog element: get() the content, target() the dialog body, swap('innerHTML'), and call showModal() from an on() handler after the swap.

Marked as needing review in the cookbook.

RedirectCommand

redirectHeader() (full navigation) or locationHeader() (client-side navigation) on the response.

The cookbook page is unfinished; the methods come from the Htmx API.

MessageCommand, AnnounceCommand

Render the status messages or the text inside the swapped fragment, or fire an event with triggerHeader() and handle it in a behavior.

A pattern rather than a documented conversion; accessibility of live regions still applies.

InvokeCommand, CssCommand, DataCommand

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.

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;
}

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_access and 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_token for 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; |raw or Markup::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 the hx-status attribute.

  • 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 as HX-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 every Url it was given; the fragment must declare its own tags and contexts, as more() 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 button element does not. Prefer links for navigation-like actions.

Choosing between Ajax and HTMX

flowchart TD A[New dynamic interaction] --> B{Must run on Drupal 10.x or 11.0 to 11.2?} B -- yes --> C[Use the Ajax framework] B -- no --> D{Changes several unrelated parts of the page,<br/>or needs a dialog, message or custom command?} D -- yes, and the code already exists --> C D -- no --> E{Is the result one fragment of HTML?} E -- yes --> F[Use HTMX with the Htmx class] E -- no --> G[Use Ajax commands, or HTMX with out-of-band swaps] F --> H{Same URL also serves the full page?} H -- yes --> I[onlyMainContent on the element] H -- no --> J[_htmx_route option on the route]
Question Ajax framework HTMX (11.3 and later)

Where is the behaviour described?

In #ajax, in the use-ajax class or in the commands of an AjaxResponse.

In attributes generated by the Htmx class.

What does the server return?

A list of commands (AjaxResponse) or a render array.

HTML that the browser swaps in, with headers for navigation and events.

Client code

The jQuery-based Drupal.ajax and Drupal.AjaxCommands.

The htmx library; no jQuery is needed for the swap itself.

Dialogs

Built in: data-dialog-type, OpenModalDialogCommand, the off-canvas tray.

Not built in; the cookbook uses a native dialog element.

State of a form

The form-state cache keyed by form_build_id.

The same form builder; form_build_id is updated on the response.

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 #ajax.

Drupal 12 moves to htmx 4, with breaking changes for the Htmx class.

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() replaces XMLHttpRequest; the request header HX-Trigger is replaced by HX-Source, read with HtmxRequestInfoTrait::getHtmxSource(), and HX-Request-Type is added. getHtmxPrompt() and getHtmxTrigger() 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 Htmx methods that wrote them: params(), prompt(), ext(), disinherit(), inherit(), request() and history(). hx-trigger loses its queue modifier and the history cache is gone.

  • Renames: replace Htmx::disable() with Htmx::ignore() first, then Htmx::disabledElt() with Htmx::disable(), in that order, so the new calls are not overwritten.

  • New: hx-query, hx-action, hx-method and hx-status have Htmx methods; inheritance is explicit, controlled with the constants Htmx::NO_MODIFIER, Htmx::INHERITED and Htmx::APPEND.

  • HX-Trigger-After-Swap and HX-Trigger-After-Settle are removed (triggerAfterSwapHeader() and triggerAfterSettleHeader() are deprecated); use triggerHeader() or listeners for the htmx events. All event names change, so custom JavaScript that listens to htmx events must be updated.

  • FormBuilderInterface::HTMX_REQUEST is 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.htmx library is not attached. Always build attributes with applyTo() 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 lacks onlyMainContent().

  • The fragment is cached for full-page visitors. A route that serves both uses _htmx_route. Use onlyMainContent() 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; pass FALSE as the third argument if the fragment changes the page title.

  • Dialogs. There is no HTMX dialog command; do not expect data-dialog-type to 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 document rather than to the new elements, or that do not use once(), run more than once.

Summary checklist

  1. Decide the minimum Drupal version, then choose Ajax (10.x, 11.0 to 11.2, dialogs, existing commands) or HTMX (11.3 and later).

  2. Put an access check on every fragment or command route and a _csrf_token on state-changing links.

  3. Return render arrays so that escaping, cache metadata and attachments work.

  4. Use applyTo() for HTMX attributes, and _htmx_route or onlyMainContent() for fragment responses.

  5. Let HTMX request headers through proxies, CDNs and WAFs.

  6. Before Drupal 12, review calls to disable(), disabledElt(), params(), prompt(), ext(), history(), the HX-Trigger request header and FormBuilderInterface::HTMX_REQUEST, and plan for DeprecationHelper if one code base supports both.