HTMX
@modularityjs/http-htmx wires HTMX into ModularityJS controllers. It parses HX-* request headers into a typed HxContext, provides @Hx() and @HxRes() parameter decorators, and exposes a fluent hx() builder for setting HTMX response headers.
Setup
import { HttpModule } from '@modularityjs/http';
import { HttpFastifyModule } from '@modularityjs/http-fastify';
import { HttpFastifyFormbodyModule } from '@modularityjs/http-fastify-formbody';
import { HttpHtmxModule } from '@modularityjs/http-htmx';
const modules = [
HttpModule.forRoot({ port: 3000 }),
HttpFastifyModule,
HttpFastifyFormbodyModule, // parses application/x-www-form-urlencoded form bodies
HttpHtmxModule,
];HttpFastifyFormbodyModule is needed alongside HttpHtmxModule when controllers accept HTML form submissions (HTMX posts forms as application/x-www-form-urlencoded by default).
Reading HTMX Request Context
Use @Hx() to inject the parsed request context:
import { Controller, Get, Html } from '@modularityjs/http';
import { Hx, HxContext } from '@modularityjs/http-htmx';
import { TemplateEngine } from '@modularityjs/template';
@Html()
@Controller('/todos')
class TodoController {
constructor(private readonly templates: TemplateEngine) {}
@Get('/')
async index(@Hx() ctx: HxContext) {
if (ctx.isHtmx) {
// HTMX partial request — return only the list fragment
return this.templates.render('todo-list', { todos });
}
// Full page load
return this.templates.render('todo-layout', { todos });
}
}HxContext Fields
| Field | Type | Description |
|---|---|---|
isHtmx | boolean | true when the request carries an HX-Request header |
boosted | boolean | true when triggered by hx-boost |
historyRestoreRequest | boolean | true on a history restoration request |
target | string? | Value of HX-Target |
trigger | string? | Value of HX-Trigger |
triggerName | string? | Value of HX-Trigger-Name |
currentUrl | string? | Value of HX-Current-URL |
prompt | string? | Value of HX-Prompt |
Setting HTMX Response Headers
Use @HxRes() to inject the response object, then call hx() on it for a fluent builder:
import { Body, Controller, Post } from '@modularityjs/http';
import { HtmlResponse } from '@modularityjs/http';
import { hx, HxRes } from '@modularityjs/http-htmx';
import type { HttpResponse } from '@modularityjs/http';
@Controller('/todos')
class TodoController {
@Post('/')
async create(@Body('text') text: string, @HxRes() res: HttpResponse) {
const todo = this.repo.add(text);
hx(res).trigger({ 'todo:added': { id: todo.id } });
return new HtmlResponse(`<li>${todo.text}</li>`);
}
}HxResponse Methods
All methods return this for chaining.
| Method | Sets header | Description |
|---|---|---|
.trigger(events) | HX-Trigger | Fire client-side events after the swap |
.triggerAfterSwap(events) | HX-Trigger-After-Swap | Fire events after the DOM swap |
.triggerAfterSettle(events) | HX-Trigger-After-Settle | Fire events after settle |
.redirect(url) | HX-Redirect | Client-side redirect |
.refresh() | HX-Refresh | Force full page refresh |
.pushUrl(url | false) | HX-Push-Url | Push a URL to browser history |
.replaceUrl(url | false) | HX-Replace-Url | Replace current URL in history |
.reswap(modifier) | HX-Reswap | Override the swap method for this response |
.retarget(selector) | HX-Retarget | Override the target element |
.reselect(selector) | HX-Reselect | Override which part of the response to swap |
.location(loc) | HX-Location | Navigate without full page reload |
trigger() accepts a plain event name string or an object mapping event names to detail payloads:
hx(res).trigger('item:deleted');
hx(res).trigger({ 'item:updated': { id: 42, name: 'new name' } });CSRF Protection
For state-mutating routes (POST, PUT, DELETE), use @modularityjs/http-csrf alongside this package. HTMX sends the token as a request header via hx-headers:
<body hx-headers='{"X-CSRF-Token": "{{ csrfToken }}"}'>
...
</body>See the CSRF docs for full setup.
Full Example
import { Inject } from '@modularityjs/di';
import { Html } from '@modularityjs/http';
import { Module } from '@modularityjs/modularity';
import {
Body,
Controller,
Get,
HttpControllersPool,
HttpModule,
Params,
Post,
Request,
type HttpRequest,
type HttpResponse,
} from '@modularityjs/http';
import { HtmlResponse } from '@modularityjs/http';
import { CsrfProtect, getCsrfToken } from '@modularityjs/http-csrf';
import {
HttpHtmxModule,
Hx,
hx,
type HxContext,
HxRes,
} from '@modularityjs/http-htmx';
import { TemplateEngine, TemplateModule } from '@modularityjs/template';
@Html()
@Controller('/todos')
class TodoController {
constructor(
@Inject(TemplateEngine) private readonly templates: TemplateEngine,
) {}
@Get('/')
async index(@Hx() ctx: HxContext, @Request() req: HttpRequest) {
const todos = this.repo.list();
if (ctx.isHtmx) {
return this.templates.render('todos-partial', { todos });
}
return this.templates.render('todos-page', {
todos,
csrfToken: getCsrfToken(req),
});
}
@Post('/')
@CsrfProtect()
async create(@Body('text') text: string, @HxRes() res: HttpResponse) {
const todo = this.repo.add(text);
hx(res).trigger({ 'todo:added': { id: todo.id } });
return new HtmlResponse(await this.templates.render('todo-item', todo));
}
@Post('/:id/delete')
@CsrfProtect()
async remove(@Params('id') id: string, @HxRes() res: HttpResponse) {
this.repo.remove(id);
hx(res).trigger({ 'todo:removed': { id } });
return '';
}
}
@Module({
name: 'todos',
imports: [HttpModule, HttpHtmxModule, TemplateModule],
providers: [TodoController],
pools: [
{ pool: HttpControllersPool, key: 'todos', useClass: TodoController },
],
})
export class TodosModule {}