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.

calendar_today
folder_openControllers

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.

descriptionhtmx_api.info.yml
1name: 'HTMX API Example'
2type: module
3description: 'An endpoint for fetching node fragments via HTMX.'
4package: 'Example'
5core_version_requirement: ^11
warning
Please note that the offical HTMX support started from Drupal 11.3!

2. Create the Routing

We need a path where HTMX can "ping" our site to get data. We define this in

descriptionhtmx_api.routing.yml
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.

descriptionsrc/Controller/HtmxAPIController.php
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.

codePHP
1// Inside your get() method:
2$html = $this->renderer->renderRoot($build);
3$response = new HtmlResponse($html);
4return $response;

The above code handles two scenarios:

  1. Single Node: If an id is passed (e.g., /api/test/htmx?id=5), it returns that specific node's title.
  2. 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:

codeTWIG
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:

  1. 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.
  2. 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.

 

Tags

ControllersHTMXCaching