Creating an HTMX route
HTMX relies on the server returning HTML fragments rather than JSON. While Drupal usually wraps content in a full page shell (header, footer, sidebars), this tutorial shows you how to return an HTML response that HTMX can swap into your existing page.
As of Drupal 11.3, the Ajax subsystem now officially includes native support for HTMX; see official change record (also this) for more details.
In this example, we will use the module name htmx_api. Note that you can apply these same principles to any existing module.
1. Define the Module
Create your htmx_api.info.yml file.
1name: 'HTMX API Example'
2type: module
3description: 'An endpoint for fetching node fragments via HTMX.'
4package: 'Example'
5core_version_requirement: ^112. Create the Routing
We need a path where HTMX can "ping" our site to get data. We define this in
1htmx_api.get:
2 path: '/api/test/htmx'
3 methods: ['GET']
4 defaults:
5 _title: 'HTMX Example'
6 _controller: 'Drupal\htmx_api\Controller\HtmxAPIController::get'
7 requirements:
8 _permission: 'access content'3. The Controller: Serving Fragments
The core of an HTMX integration is the Controller. Unlike standard controllers that return a render array for a full page, an HTMX controller needs to return an HtmlResponse containing only the specific fragment.
1<?php
2
3declare(strict_types=1);
4
5namespace Drupal\htmx_api\Controller;
6
7use Drupal\Core\Controller\ControllerBase;
8use Drupal\Core\Render\RendererInterface;
9use Symfony\Component\DependencyInjection\ContainerInterface;
10use Drupal\Core\Render\RenderCacheInterface;
11use Drupal\Core\Render\HtmlResponse;
12use Symfony\Component\HttpFoundation\Request;
13use Drupal\Core\Cache\CacheableMetadata;
14use Drupal\node\Entity\Node;
15use Drupal\node\NodeStorage;
16
17
18/**
19 * Returns responses for Mint htmx routes.
20 */
21final class HtmxAPIController extends ControllerBase {
22 /**
23 * The controller constructor.
24 */
25 public function __construct(
26 private readonly RendererInterface $renderer,
27 protected RenderCacheInterface $renderCache,
28 protected array $rendererConfig,
29 ) {}
30
31 /**
32 * {@inheritdoc}
33 */
34 public static function create(ContainerInterface $container): self {
35 return new self(
36 $container->get('renderer'),
37 $container->get('render_cache'),
38 $container->getParameter('renderer.config')
39 );
40 }
41
42 /**
43 * Builds the response.
44 */
45 public function get(Request $request): HtmlResponse {
46 $id = $request->query->get('id') ?? '';
47
48 $single_node_request = FALSE;
49
50 if (!empty($id) && is_numeric($id)) {
51 $node = Node::load($id);
52
53 if ($node instanceof Node) {
54 $single_node_request = TRUE;
55 // build response, valid node id
56 $build = [
57 '#type' => 'markup',
58 '#markup' => '<h2>Single node request</h2>'
59 ];
60
61 $build[] = [
62 '#type' => 'markup',
63 '#markup' => '<div class="node-id-' . $node->id() . '">Title: ' . $node->getTitle() . '</div>'
64 ];
65 }
66 }
67
68 if (!$single_node_request) {
69 // response for list of all nodes
70 $build = $this->buildAllNodesResponse();
71 }
72
73 $cacheable_metadata = new CacheableMetadata();
74
75 // Render the snippet to a string.
76 $html = $this->renderer->renderRoot($build);
77
78 $response = new HtmlResponse($html);
79 $cacheable_metadata->addCacheContexts(['url.query_args:id']);
80 $cacheable_metadata->addCacheTags(['node_list:article']); // This should invalidate response when any node of type articles change
81 $response->addCacheableDependency($cacheable_metadata);
82
83 return $response;
84 }
85
86 private function buildAllNodesResponse(): array {
87 /** @var NodeStorage $node_storage */
88 $node_storage = $this->entityTypeManager()->getStorage('node');
89
90 $nids = $node_storage
91 ->getQuery()
92 ->accessCheck()
93 ->condition('type', 'article')
94 ->execute();
95
96 /** @var Node[] $nodes */
97 $nodes = $node_storage->loadMultiple($nids);
98
99 if ($nodes) {
100 $build = [
101 '#type' => 'markup',
102 '#markup' => '<h2 class="test-class">List of nodes</h2>',
103 ];
104
105 foreach ($nodes as $node) {
106 $build[] = [
107 '#type' => 'markup',
108 '#markup' => '<div class="node-id-' . $node->id() . '">ID: ' . $node->id() . ', Title: ' . $node->getTitle() . '</div>'
109 ];
110 }
111 } else {
112 // Empty response
113 $build = [
114 '#type' => '#markup',
115 '#markup' => '<div class="test-class">No nodes of type article found!</div>',
116 ];
117 }
118
119 return $build;
120 }
121}Key Concept: HtmlResponse vs. Render Arrays
Normally, Drupal takes your return array and puts it inside page.html.twig. By returning an HtmlResponse objects directly, we can bypass the theme wrapper, sending only the snippet HTMX expects.
1// Inside your get() method:
2$html = $this->renderer->renderRoot($build);
3$response = new HtmlResponse($html);
4return $response;The above code handles two scenarios:
- Single Node: If an
idis passed (e.g.,/api/test/htmx?id=5), it returns that specific node's title. - List View: If no ID is passed, it queries all "Article" nodes and returns a list.
4. How to use this with HTMX
Once your module is enabled and your cache is cleared (drush cr), you can call this API from your Twig templates like this:
1<div hx-get="/api/test/htmx" hx-trigger="load">
2 Loading articles...
3</div>
4
5<button hx-get="/api/test/htmx?id=1" hx-target="#details">
6 View Node 1
7</button>
8<div id="details"></div>How Caching Works in our Controller
In our HtmxAPIController, we use two key concepts to manage performance:
- Cache Contexts (
url.query_args:id): This tells Drupal that the output depends on the?id=parameter in the URL. Drupal will store a separate version of the HTML for every unique ID requested. - Cache Tags (
node_list:article): This is "smart" invalidation. We are telling Drupal: "This HTML snippet is valid until any Article node is created, updated, or deleted." . As long as your content doesn't change, Drupal serves the HTML fragment directly from the cache.