Best Practices
When to Use
You're implementing HTMX features and want to follow security, performance, accessibility, and development standards.
Security
Always validate and sanitize on server:
- HTMX requests are HTTP requests — all standard security rules apply
- Never trust client data — validate form inputs and URL parameters
- Use CSRF tokens — Drupal forms include automatic token validation
- Check permissions — Use #access on form elements and _permission on routes
- Sanitize output — Use render arrays, not raw HTML strings
- Avoid XSS — Return render arrays through FormBuilder/Controller, not manual HTML
Example: Proper Validation
public function buildForm(array $form, FormStateInterface $form_state, string $type = '') {
// Validate URL parameter
$allowed_types = ['a', 'b', 'c'];
if (!in_array($type, $allowed_types)) {
throw new AccessDeniedHttpException();
}
// Use form API for automatic sanitization
$form['display'] = [
'#markup' => $this->renderer->render($safe_render_array),
];
}
Anti-pattern:
// NEVER do this - XSS vulnerability
$form['display']['#markup'] = '<div>' . $_GET['user_input'] . '</div>';
Reference: OWASP XSS Prevention
Content Security Policy (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. This is an open feature request tracked as #3582309 (main branch, Active). Do not deploy a restrictive CSP in production without testing all HTMX interactions under that policy.
Performance
Use minimal responses:
- Always use onlyMainContent() or _htmx_route: TRUE for HTMX-only endpoints
- Return only necessary content via select() attribute
- Cache render arrays with appropriate contexts and tags
Optimize swap strategies:
- Use swap('innerHTML') for content-only updates
- Use swap('outerHTML') when wrapper changes
- Use swap('beforeend') for append operations (load more, infinite scroll)
- Avoid swap('none') unless using OOB swaps
Leverage differential asset loading:
- Drupal automatically loads only new CSS/JS files
- Don't manually manage asset loading — trust ajax_page_state integration
- Group related functionality in libraries to minimize requests
Cache aggressively:
$build['#cache'] = [
'keys' => ['my_module', 'content', $entity_id],
'contexts' => ['url.query_args:page'],
'tags' => ['node:' . $entity_id],
'max-age' => 3600,
];
Avoid N+1 queries in loops:
// 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);
}
Consider request debouncing:
// For live search, debounce keystrokes
(new Htmx())
->trigger('keyup changed delay:500ms') // Wait 500ms after typing stops
->applyTo($form['search']);
Accessibility
Status messages:
- HtmxRenderer automatically includes #type: 'status_messages' in responses
- Messages announce to screen readers via Drupal's messaging system
Focus management:
- HTMX maintains focus on triggering element by default
- For modals or major swaps, set focus explicitly via hx-on:
(new Htmx())
->on('::afterSwap', 'document.querySelector("#modal-content").focus()')
->applyTo($build['trigger']);
Keyboard navigation:
- Test all HTMX interactions with keyboard only
- Ensure buttons/links are focusable and activatable with Enter/Space
- Don't use <div> with HTMX attributes — use semantic HTML (<button>, <a>)
ARIA attributes:
- Add aria-live regions for dynamic content updates:
$build['results'] = [
'#type' => 'container',
'#attributes' => [
'id' => 'search-results',
'aria-live' => 'polite',
'aria-atomic' => 'true',
],
];
Screen reader announcements:
- Use aria-busy during requests:
(new Htmx())
->indicator('#status[aria-busy="true"]')
->applyTo($form['submit']);
Development Standards
Use dependency injection:
// GOOD: Inject services
class MyForm extends FormBase {
public function __construct(
protected EntityTypeManagerInterface $entityTypeManager,
protected ConfigFactoryInterface $configFactory,
) {}
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity_type.manager'),
$container->get('config.factory'),
);
}
}
// BAD: Static service calls
$entity = \Drupal::entityTypeManager()->getStorage('node')->load($id);
Return render arrays, not HTML:
// GOOD: Render arrays
return ['#markup' => $this->renderer->render($build)];
// BAD: Manual HTML
return ['#markup' => '<div class="content">' . $content . '</div>'];
Use Url objects:
// GOOD: Url objects
(new Htmx())->get(Url::fromRoute('my.route', ['id' => $id]));
// BAD: Hardcoded paths
(new Htmx())->get('/my-module/content/' . $id);
Follow coding standards: - Use type hints on all methods - Document complex logic with inline comments - Follow Drupal coding standards (phpcs, phpstan)
Test both HTMX and non-HTMX requests: - Initial page load is never HTMX - Progressive enhancement means non-JavaScript fallback required - Test form submissions with JavaScript disabled
Progressive Enhancement
Always provide fallback:
// 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']);
Graceful degradation: - Links work as regular links without JavaScript - Forms submit normally without JavaScript - Content is accessible without HTMX enhancements
Use semantic HTML:
// GOOD: Semantic button
$build['submit'] = ['#type' => 'button', '#value' => 'Submit'];
(new Htmx())->post($url)->applyTo($build['submit']);
// BAD: Non-semantic element
$build['submit'] = [
'#type' => 'html_tag',
'#tag' => 'div',
'#value' => 'Submit',
'#attributes' => ['role' => 'button'],
];
Common Mistakes
- Trusting client input — Always validate server-side
- Not caching HTMX responses — Performance degrades with traffic
- Forgetting keyboard accessibility — Not all users use mouse
- Using static service calls — Breaks testability and best practices
- Not testing without JavaScript — Progressive enhancement fails
- Hardcoding URLs — Breaks multilingual sites and aliases
- Building HTML strings — XSS vulnerabilities and bypasses render system
- Not using semantic HTML — Accessibility and SEO suffer
See Also
- Previous: Complete Production Example
- Next: Troubleshooting
- Reference: Drupal Security Best Practices
- Reference: Web Content Accessibility Guidelines (WCAG)