HTMX
@voltage/htmx integrates HTMX into NestJS. It detects hx-* attributes in rendered JSX and includes the HTMX script automatically — no manual script tags needed. For controllers, the htmx() helper maps a plain object to the appropriate HTMX response headers.
Installation
Section titled “Installation”yarn add @voltage/htmx @voltage/asset-manager @voltage/async-context @voltage/jsx @voltage/loggerBasic usage
Section titled “Basic usage”Call forRoot() once in your root module:
import { HTMXModule } from '@voltage/htmx';
@Module({ imports: [ HTMXModule.forRoot(), ],})export class AppModule {}That is all the setup required. Add hx-* attributes to any JSX element and the HTMX script is included in the page automatically:
<button hx-post={this.url.generate(CartController, 'add')} hx-swap="none"> Add to cart</button>The htmx() helper
Section titled “The htmx() helper”Controllers return the result of htmx() to send HTMX response headers alongside optional HTML content.
Content only — return a partial HTML update:
import { htmx } from '@voltage/htmx';
@Post('/cart/add')async add(@Body() body: AddItemDto) { await this.cart.add(body.itemId);
return htmx(<CartCount count={this.cart.count()} />);}Headers only — no content, just instructions:
@Post('/checkout')async checkout() { await this.orders.create();
return htmx({ redirect: await this.url.generate(OrderController, 'confirmation') });}Content and headers — partial update plus a side effect:
@Post('/account')async update(@Auth() user: User, @FormData() data: AccountForm) { user = await this.users.update(user, data);
return htmx( <> <Modal.Close /> <div hx-swap-oob="beforeend:#toasts"> <Toast>Account updated.</Toast> </div> </>, { trigger: 'account:updated' }, );}Response headers
Section titled “Response headers”interface HTMXResponseHeaders { /** Client-side redirect via HX-Location. Accepts a path or a full options object. */ location?: string | HTMXLocationOptions; /** Push a URL onto the browser history stack. */ pushURL?: string; /** Full-page redirect. */ redirect?: string; /** Trigger a full-page refresh. */ refresh?: boolean; /** Replace the current URL in the browser history without a push. */ replaceURL?: string; /** Override the swap method for this response. */ reswap?: 'innerHTML' | 'outerHTML' | 'beforebegin' | 'afterbegin' | 'beforeend' | 'afterend' | 'delete' | 'none'; /** Override the target element for this response. */ retarget?: string | `closest ${string}` | `find ${string}` | 'next' | `next ${string}` | 'previous' | `previous ${string}` | 'this'; /** CSS selector to select a subset of the response to swap. */ reselect?: string; /** Trigger client-side events after the swap settles. */ trigger?: HTMXTrigger; /** Trigger client-side events after the swap settles. */ triggerAfterSettle?: HTMXTrigger; /** Trigger client-side events after the swap completes. */ triggerAfterSwap?: HTMXTrigger;}HTMXTrigger accepts a string, an array of strings, or an object with event names as keys and payloads as values:
// Simple string{ trigger: 'cart:updated' }
// Multiple events{ trigger: ['cart:updated', 'nav:refresh'] }
// Event with payload{ trigger: { 'cart:updated': { count: 3 } } }Listening to server-triggered events with Alpine
Section titled “Listening to server-triggered events with Alpine”Events fired via trigger are dispatched as DOM custom events after the swap completes. Use x-on to listen to them in Alpine components.
A common pattern is a cart badge that updates itself when items are added from anywhere on the page:
Server — fire the event with a payload after adding to cart:
@Post('add')async add(@Body() body: AddToCartDto) { const cart = await this.cart.add(body.itemId);
return htmx({ trigger: { 'cart:updated': { count: cart.itemCount } } });}Client — listen in the Alpine component that owns the badge:
<span x-data={{ count: 0 }} {...{ 'x-on:cart:updated.window': "count = $event.detail.count" }} x-text="count"/>The .window modifier tells Alpine to listen on window instead of the element itself, so the event is caught regardless of where in the DOM the HTMX request originated.
For events without a payload, $event.detail is an empty object — use the event name alone as the signal:
// Serverreturn htmx({ triggerAfterSettle: 'modal:close' });// Client<div x-data={{ open: true }} {...{ 'x-on:modal:close.window': "open = false" }}> {/* modal content */}</div>triggerAfterSettle fires after Alpine has had a chance to process the new DOM, which avoids race conditions when the response also contains HTML that Alpine needs to initialize.
Request context
Section titled “Request context”Inject HTMXAsyncContext to read HTMX request headers:
import { HTMXAsyncContext } from '@voltage/htmx';
@Injectable()export class PageService { constructor(private readonly htmx: HTMXAsyncContext) {}
render() { if (this.htmx.isHTMX) { return <PartialView />; } return <FullPageView />; }}Available properties:
class HTMXAsyncContext { isHTMX: boolean | undefined; // true if request has HX-Request header currentURL: string | undefined; // HX-Current-Url target: string | undefined; // HX-Target trigger: string | undefined; // HX-Trigger triggerName: string | undefined; // HX-Trigger-Name prompt: string | undefined; // HX-Prompt boosted: boolean | undefined; // HX-Boosted historyRestore: boolean | undefined; // HX-History-Restore-Request}Smart redirects
Section titled “Smart redirects”HTMX.redirect() returns the correct response type depending on whether the current request is an HTMX request or a full-page request:
import { HTMX } from '@voltage/htmx';
@Injectable()export class AuthService { constructor(private readonly htmx: HTMX) {}
async logout() { // Returns htmx({ redirect: '...' }) for HTMX requests, // { url: '...', statusCode: 302 } for full-page requests return this.htmx.redirect(await this.url.generate(AuthController, 'login')); }}Common patterns
Section titled “Common patterns”Out-of-band swaps
Section titled “Out-of-band swaps”HTMX normally swaps the response into the element that triggered the request. Out-of-band swaps let you update additional regions of the page in the same response by adding hx-swap-oob to any element in the returned HTML.
A common use is appending a toast notification while the primary action completes:
@Post('add')async add(@Body() body: AddToCartDto) { await this.cart.add(body.itemId);
return htmx( <div hx-swap-oob="beforeend:#toasts"> <Toast>Item added to cart.</Toast> </div>, );}The beforeend:#toasts value tells HTMX to append the element to #toasts rather than swap it into the trigger target. Multiple out-of-band elements can be returned in one fragment:
@Post()async update(@Auth() user: User, @FormData() data: AccountForm) { user = await this.users.update(user, data);
return htmx( <> <Modal.Close /> <div hx-swap-oob="beforeend:#toasts"> <Toast>Account updated.</Toast> </div> <div hx-swap-oob="outerHTML:#account-card"> <AccountCard user={user} /> </div> </>, );}This closes a modal, appends a toast, and replaces an account card — all in a single round trip. Modal.Close has no hx-swap-oob because it is the primary response content; the rest are out-of-band.
No-swap responses
Section titled “No-swap responses”When all updates are out-of-band or header-driven, set hx-swap="none" on the trigger element so HTMX does not try to swap the response body into anything:
<button hx-post={this.url.generate(CartController, 'add')} hx-swap="none"> Add to cart</button>The server can still return out-of-band elements or response headers — HTMX processes them and discards the rest.
Updating navigation during boost
Section titled “Updating navigation during boost”When using hx-boost for full-page navigation, you often need to update both the main content and a secondary region like a nav bar. hx-select-oob pulls a specific element out of the full-page response and swaps it independently:
<a href={this.url.generate(DashboardController)} hx-boost hx-target="#main" hx-select="#main" hx-select-oob="#nav" hx-swap="outerHTML"> Dashboard</a>HTMX swaps #main from the response into the page, then separately finds #nav in the same response and swaps it out-of-band. The server returns a normal full-page response — no special handling needed.
Post-insertion behavior with Alpine
Section titled “Post-insertion behavior with Alpine”Combine hx-swap-oob with Alpine’s x-init to run code immediately after the swapped element is inserted into the DOM. A common use is scrolling new content into view:
return htmx( <div hx-swap-oob="outerHTML:#result" x-data x-init="$el.scrollIntoView({ behavior: 'smooth' })" > <Result id="result" item={item} /> </div>,);Extensions
Section titled “Extensions”forRoot() accepts an extensions array. HTMXHeadSupportExtension is included by default — it enables HTMX to merge <head> content on navigation, which is needed when swapping full page layouts. Each extension’s asset is included automatically and the hx-ext attribute is set on <body>.
import { HTMXModule, HTMXHeadSupportExtension } from '@voltage/htmx';import { HTMXAlpineMorphExtension } from '@voltage/htmx/alpine';
HTMXModule.forRoot({ extensions: [ HTMXHeadSupportExtension, HTMXAlpineMorphExtension, ],})Available extensions:
| Export | Description |
|---|---|
HTMXHeadSupportExtension | Merges <head> on swap (included by default) |
HTMXAlpineMorphExtension | Alpine.js morphing swap strategy — import from @voltage/htmx/alpine |
HTMXIdiomorphExtension | Idiomorph morphing swap strategy |
HTMXSSEExtension | Server-Sent Events support |