Skip to content

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

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

typescript
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

FieldTypeDescription
isHtmxbooleantrue when the request carries an HX-Request header
boostedbooleantrue when triggered by hx-boost
historyRestoreRequestbooleantrue on a history restoration request
targetstring?Value of HX-Target
triggerstring?Value of HX-Trigger
triggerNamestring?Value of HX-Trigger-Name
currentUrlstring?Value of HX-Current-URL
promptstring?Value of HX-Prompt

Setting HTMX Response Headers

Use @HxRes() to inject the response object, then call hx() on it for a fluent builder:

typescript
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.

MethodSets headerDescription
.trigger(events)HX-TriggerFire client-side events after the swap
.triggerAfterSwap(events)HX-Trigger-After-SwapFire events after the DOM swap
.triggerAfterSettle(events)HX-Trigger-After-SettleFire events after settle
.redirect(url)HX-RedirectClient-side redirect
.refresh()HX-RefreshForce full page refresh
.pushUrl(url | false)HX-Push-UrlPush a URL to browser history
.replaceUrl(url | false)HX-Replace-UrlReplace current URL in history
.reswap(modifier)HX-ReswapOverride the swap method for this response
.retarget(selector)HX-RetargetOverride the target element
.reselect(selector)HX-ReselectOverride which part of the response to swap
.location(loc)HX-LocationNavigate without full page reload

trigger() accepts a plain event name string or an object mapping event names to detail payloads:

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

html
<body hx-headers='{"X-CSRF-Token": "{{ csrfToken }}"}'>
  ...
</body>

See the CSRF docs for full setup.

Full Example

typescript
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 {}