Best Practices
When to Use
Reference this when implementing HTMX features and wanting to follow security, performance, accessibility, and development standards.
Decision
| Area | Rule | Anti-pattern |
|---|---|---|
| Security | Validate all inputs server-side | Trusting client data |
| Security | Return render arrays, not HTML strings | Manual HTML strings = XSS risk |
| Security | Test all HTMX interactions under your CSP | Assuming strict CSP works out of the box |
| Performance | Use onlyMainContent() or _htmx_route |
Full page responses for HTMX endpoints |
| Performance | Cache render arrays with contexts/tags | Uncached HTMX responses |
| Performance | Debounce live search (500ms) | Request on every keystroke |
| Accessibility | Use aria-live on dynamic regions |
No screen reader announcements |
| Accessibility | Use semantic HTML elements | <div> with HTMX attributes |
| Code | Use dependency injection | Static \Drupal:: service calls |
| Code | Use Url objects |
Hardcoded path strings |
| Progressive enhancement | Form works without JavaScript | JavaScript-only form behavior |
Pattern
Security — proper validation:
public function buildForm(array $form, FormStateInterface $form_state, string $type = '') {
if (!in_array($type, $this->getAllowedTypes())) {
throw new AccessDeniedHttpException();
}
// Use render arrays, not raw HTML
$form['display'] = ['#markup' => $this->renderer->render($safe_build)];
}
CSP limitation: Strict CSP policies that exclude style-src 'unsafe-inline' are not yet fully supported in Drupal core. A style-src 'self' policy (without unsafe-inline) causes CSP violations in multiple places across core. Tracked as #3582309 (main branch, Active). Do not deploy a restrictive CSP in production without testing all HTMX interactions under that policy.
Performance — caching:
$build['#cache'] = [
'keys' => ['my_module', 'content', $entity_id],
'contexts' => ['url.query_args:page'],
'tags' => ['node:' . $entity_id],
'max-age' => 3600,
];
Performance — avoid N+1 queries:
// GOOD: Load all at once
$entities = $this->entityTypeManager->getStorage('node')->loadMultiple($ids);
// BAD: Loop loading
foreach ($ids as $id) {
$entity = $this->entityTypeManager->getStorage('node')->load($id);
}
Accessibility — ARIA live region:
$build['results'] = [
'#type' => 'container',
'#attributes' => ['id' => 'search-results', 'aria-live' => 'polite', 'aria-atomic' => 'true'],
];
Accessibility — focus management for major swaps:
(new Htmx())
->on('::afterSwap', 'document.querySelector("#modal-content").focus()')
->applyTo($build['trigger']);
Progressive enhancement:
// Form works as normal POST without JavaScript
$form['#action'] = Url::fromRoute('my.form')->toString();
$form['#method'] = 'post';
// HTMX enhances the experience
(new Htmx())->post(Url::fromRoute('my.form'))->onlyMainContent()->applyTo($form['submit']);
Dependency injection:
// GOOD
class MyForm extends FormBase {
public function __construct(
protected EntityTypeManagerInterface $entityTypeManager,
) {}
public static function create(ContainerInterface $container) {
return new static($container->get('entity_type.manager'));
}
}
// BAD: Static service call
$entity = \Drupal::entityTypeManager()->getStorage('node')->load($id);
Common Mistakes
- Wrong: Trusting client input → Right: Always validate server-side
- Wrong: Not caching HTMX responses → Right: Performance degrades with traffic
- Wrong: Using static service calls → Right: Breaks testability; use dependency injection
- Wrong: Building HTML strings → Right: XSS vulnerabilities; use render arrays
- Wrong: Not testing without JavaScript → Right: Progressive enhancement fails
- Wrong: Using
<div>with HTMX attributes → Right: Use semantic<button>,<a>elements - Wrong: Deploying strict CSP without testing → Right: Core has known CSP issues (#3582309); test thoroughly
See Also
- Production Example: ConfigSingleExportForm
- Troubleshooting
- Reference: Drupal Security Best Practices
- Reference: WCAG Quick Reference
- Reference: Drupal Core Issue #3582309 — CSP support