Integrations

HTMX Integration

New to acton-service?

Start with the homepage to understand what acton-service is, then explore Core Concepts for foundational explanations. See the Glossary for technical term definitions.


HTMX simplifies web architecture by eliminating the API layer—your server returns HTML directly, not JSON that JavaScript must render. The browser swaps page fragments in place without full reloads, giving you interactive UIs without complex frontend frameworks.

acton-service provides first-class HTMX support through three integrated features:

  • htmx - Type-safe request extractors (detect HTMX requests) and response helpers (redirect, refresh, trigger events)
  • askama - Compile-time checked templates with automatic flash message and auth state aggregation
  • sse - Server-Sent Events for pushing real-time updates to all connected browsers

Together with session management, you can build complete interactive applications—authentication, flash messages, live updates—without writing custom JavaScript.

Quick Decision Guide

What do you want to build?
├─ Server-rendered pages with templates     → Enable askama
├─ Interactive UI with HTMX attributes      → Enable htmx + askama
├─ Real-time updates (live data, notifications) → Enable sse
└─ Complete web application                 → Enable htmx-full (includes all)

The htmx-full convenience feature includes all HTMX-related features plus session-memory for development:

[dependencies]
acton-service = { version = "0.11.0", features = ["htmx-full"] }

See Feature Flags for detailed descriptions of each feature.

Feature Overview

askama - Type-Safe Templates

Askama provides Jinja2-like templates with compile-time checking. Template errors become compile errors—no more runtime "variable not found" failures in production.

use acton_service::prelude::*;

#[derive(Template)]
#[template(path = "tasks/list.html")]
struct TaskListTemplate {
    ctx: TemplateContext,
    tasks: Vec<Task>,
}

async fn list_tasks(flash: FlashMessages) -> impl IntoResponse {
    let ctx = TemplateContext::new()
        .with_path("/tasks")
        .with_flash(flash.into_messages());

    HtmlTemplate::page(TaskListTemplate { ctx, tasks })
}

TemplateContext aggregates common page data—authentication status, flash messages, CSRF tokens, and current path—so every template has consistent access to session state.

See Askama Templates for the complete guide.


htmx - HTMX Utilities

The htmx feature provides extractors for HTMX request headers and responders for HTMX-specific response patterns.

Extractors detect HTMX requests and access header values:

use acton_service::prelude::*;

async fn list_tasks(
    HxRequest(is_htmx): HxRequest,
    HxTarget(target): HxTarget,
) -> impl IntoResponse {
    if is_htmx {
        // Return just the fragment
        HtmlTemplate::fragment(TaskListFragment { tasks })
    } else {
        // Return full page
        HtmlTemplate::page(TaskListPage { tasks })
    }
}

Responders set HTMX response headers:

use acton_service::prelude::*;

async fn create_task(Form(data): Form<NewTask>) -> impl IntoResponse {
    // Create task...

    // Redirect via HTMX (replaces only hx-target, not full page)
    HxRedirect::to("/tasks")
}

Out-of-band swaps update multiple elements from a single response:

// Return new task HTML + update stats counter
let task_html = TaskItemTemplate { task }.render().unwrap();
let stats_oob = format!(
    r#"<span id="task-count" hx-swap-oob="outerHTML">{}</span>"#,
    total_count
);
Html(format!("{}{}", task_html, stats_oob))

sse - Server-Sent Events

SSE provides real-time server-to-client updates. Unlike WebSockets, SSE is one-way (server to client only) and works over standard HTTP with automatic reconnection.

use acton_service::prelude::*;

async fn events(
    Extension(broadcaster): Extension<Arc<SseBroadcaster>>,
) -> Sse<impl Stream<Item = Result<SseEvent, Infallible>>> {
    let rx = broadcaster.subscribe();

    let stream = stream::unfold(rx, |mut rx| async move {
        match rx.recv().await {
            Ok(msg) => Some((Ok(msg.into_event()), rx)),
            Err(_) => None,
        }
    });

    Sse::new(stream).keep_alive(KeepAlive::default())
}

On the client, HTMX's SSE extension connects to your endpoint and automatically swaps content when events arrive:

<div hx-ext="sse" sse-connect="/events">
    <div id="notifications" sse-swap="notification">
        <!-- New notifications appear here -->
    </div>
</div>

See Server-Sent Events for broadcasting patterns and connection management.


Frontend Routes with ServiceBuilder

New in 0.11.0

The htmx feature now enables with_frontend_routes() on VersionedApiBuilder, allowing unversioned frontend routes alongside versioned API routes—all while using ServiceBuilder's batteries-included backend.

HTMX applications typically need unversioned routes (/, /login, /tasks) rather than API-style versioned routes (/api/v1/users). The with_frontend_routes() method lets you define these while still getting ServiceBuilder's automatic features:

use acton_service::prelude::*;
use acton_service::versioning::VersionedApiBuilder;

#[tokio::main]
async fn main() -> Result<()> {
    let routes = VersionedApiBuilder::new()
        // Optional: Add versioned API routes
        .with_base_path("/api")
        .add_version(ApiVersion::V1, |router| {
            router.route("/data", get(api_handler))
        })
        // Frontend routes (htmx feature required)
        .with_frontend_routes(|router| {
            router
                .route("/", get(index))
                .route("/login", get(login_page).post(login))
                .route("/tasks", post(create_task))
                .layer(session_layer)
        })
        .build_routes();

    // ServiceBuilder provides automatic:
    // - /health and /ready endpoints
    // - Tracing initialization
    // - Configuration loading
    // - Graceful shutdown
    ServiceBuilder::new()
        .with_routes(routes)
        .build()
        .serve()
        .await?;

    Ok(())
}

Resulting routes:

GET  /health          # Auto-provided health check
GET  /ready           # Auto-provided readiness probe
GET  /                # Frontend index (unversioned)
GET  /login           # Frontend login (unversioned)
POST /login           # Frontend login handler (unversioned)
POST /tasks           # Frontend task creation (unversioned)
GET  /api/v1/data     # Versioned API endpoint

This pattern gives you the best of both worlds: clean frontend URLs for your HTMX UI, optional versioned API routes for programmatic access, and all of ServiceBuilder's production-ready features.


Complete Example: Task Manager

The Task Manager example demonstrates all HTMX features working together—templates, flash messages, out-of-band swaps, real-time updates, and proper ServiceBuilder integration.

cargo run --manifest-path=acton-service/Cargo.toml --example task-manager --features htmx-full

Open http://localhost:3000 to see:

  • ServiceBuilder integration with automatic health/ready endpoints and tracing
  • Session-based authentication with login/logout
  • Flash messages that survive redirects
  • Out-of-band swaps updating task list and statistics simultaneously
  • Inline editing with HTMX form handling
  • Real-time updates via SSE when tasks change

Key patterns from the example:

TemplateContext with flash messages:

let ctx = TemplateContext::new()
    .with_path("/")
    .with_auth(auth.data().user_id.clone())
    .with_flash(flash.into_messages());

Out-of-band statistics update:

fn render_stats_oob(total: usize, completed: usize, pending: usize) -> String {
    format!(
        r#"<span class="stat-value" id="total-count" hx-swap-oob="outerHTML">{}</span>
<span class="stat-value" id="pending-count" hx-swap-oob="outerHTML">{}</span>
<span class="stat-value" id="completed-count" hx-swap-oob="outerHTML">{}</span>"#,
        total, pending, completed
    )
}

ServiceBuilder with frontend routes:

let routes = VersionedApiBuilder::new()
    .with_frontend_routes(|router| {
        router
            .route("/", get(index))
            .route("/login", get(login_page).post(login))
            .route("/tasks", post(create_task))
            .route("/events", get(events))
            .layer(Extension(store))
            .layer(session_layer)
    })
    .build_routes();

ServiceBuilder::new()
    .with_routes(routes)
    .build()
    .serve()
    .await?;

Explore the complete source for implementation details.


Getting Started

1. Add Feature Flags

For development, use htmx-full which includes everything:

[dependencies]
acton-service = { version = "0.11.0", features = ["htmx-full"] }
tokio = { version = "1", features = ["full"] }

For production with Redis sessions:

acton-service = { version = "0.11.0", features = [
    "htmx", "askama", "sse", "session-redis"
] }

2. Run the Example

cargo run --manifest-path=acton-service/Cargo.toml --example task-manager --features htmx-full

3. Read the Detailed Guides


Integration with Other Features

HTMX features work alongside acton-service's other capabilities:

FeatureIntegration
Sessionsession-memory for dev, session-redis for production. Provides flash messages, CSRF tokens, and auth state.
AuthUse auth feature for password hashing. Sessions store authentication state.
Databasedatabase (PostgreSQL) or turso (SQLite) for persistent storage.
ObservabilityFull tracing support. Template rendering and HTMX requests are traced automatically.

Hybrid architectures are supported: use sessions + HTMX for your admin UI while exposing a JWT-authenticated API for mobile clients or third-party integrations.


When to Use HTMX vs. REST APIs

ScenarioRecommendation
Admin dashboards, internal toolsHTMX with sessions
Public-facing web appsHTMX with sessions
Mobile app backendREST API with JWT
Third-party integrationsREST API with JWT or API keys
Microservice communicationgRPC or REST with JWT
Hybrid (web + mobile)Both—HTMX for web, API for mobile

HTMX excels when the browser is your only client. If you need a JSON API anyway, consider whether HTMX adds value or just complexity.


Next Steps

Previous
WebSocket