The modern JavaScript ecosystem often pressures teams into building heavy Single Page Applications (SPAs): setting up API endpoints, duplicating validation rules across frontend and backend, wiring client routers, and dealing with client-side state managers like TanStack Query or Pinia.


Pairing AdonisJS with HTMX gives you the snappy, fluid feel of an SPA while keeping 100% of your business logic, validation, and rendering where it belongs: on the backend.


The Concept: Backend-Driven SPA Flow

Instead of returning JSON that the browser parses and renders via a virtual DOM, AdonisJS renders HTML fragments on demand. HTMX swaps those fragments directly into target DOM containers.


Traditional SPA (Decoupled):
[Client Click] ──► [Fetch JSON] ──► [Client Router/Store] ──► [Virtual DOM Render]

AdonisJS + HTMX (Hypermedia):
[Client Click] ──► [HTTP GET/POST] ──► [Adonis Controller] ──► [Edge Partial Render]
                                                                        │
[HTMX Swaps DOM Target Element] ◄───────────────────────────────────────┘

You get zero client-bundle bloat, zero frontend routing synchronization bugs, and instantaneous page updates—all while maintaining an authentic SPA user experience.


1. Setting Up the Foundation (AdonisJS + Edge)

AdonisJS uses Edge.js as its template engine. To support HTMX without breaking traditional browser navigation, your server needs to determine whether an incoming request is a full page load or an HTMX fragment swap.


HTMX automatically sends the HX-Request: true header on every call.


Base Layout (resources/views/layouts/main.edge)

HTML

<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Dashboard</title><!-- Load HTMX --><script src="https://unpkg.com/htmx.org@2.0.0"></script><!-- CSRF setup for AdonisJS --><meta name="csrf-token" content="{{ csrfToken }}"></head><body hx-headers='{"x-csrf-token": "{{ csrfToken }}"}'>
  
  <nav>
    <a href="/projects" 
       hx-get="/projects" 
       hx-target="#main-content" 
       hx-push-url="true">Projects</a>
       
    <a href="/settings" 
       hx-get="/settings" 
       hx-target="#main-content" 
       hx-push-url="true">Settings</a></nav>

  <!-- The SPA swap container --><main id="main-content">
    {{{ await $slots.main() }}}
  </main>

</body></html>
  • hx-target="#main-content": Replaces only the inside of <main> instead of reloading the document.


  • hx-push-url="true": Automatically updates browser history and the URL bar, making the back/forward buttons behave like an SPA.


2. Handling the Split in Controllers

Your AdonisJS controller checks request.header('hx-request'). If it's present, render only the partial; if it's absent (direct bookmark or cold refresh), wrap it in the full layout:


TypeScript

// app/controllers/projects_controller.tsimport type { HttpContext } from '@adonisjs/core/http'import Project from '#models/project'

export default class ProjectsController {
  async index({ request, view }: HttpContext) {
    const projects = await Project.query().orderBy('created_at', 'desc')
    const isHtmx = request.header('hx-request')

    // If HTMX triggered the fetch, return only the partial fragment
    if (isHtmx) {
      return view.render('projects/partials/list', { projects })
    }

    // Direct access / refresh: render full page
    return view.render('projects/index', { projects })
  }

  async store({ request, view }: HttpContext) {
    const payload = request.only(['title', 'category'])
    const project = await Project.create(payload)

    // Append newly created row straight into table
    return view.render('projects/partials/row', { project })
  }
}

The Component Partial (resources/views/projects/partials/row.edge)

HTML

<tr id="project-{{ project.id }}"><td>{{ project.title }}</td><td>{{ project.category }}</td><td>
    <button 
      hx-delete="/projects/{{ project.id }}" 
      hx-target="#project-{{ project.id }}" 
      hx-swap="outerHTML swap:500ms"
      class="btn-delete">
      Delete
    </button></td></tr>

When you click Delete, HTMX sends an HTTP DELETE to AdonisJS. The server responds with 200 OK (empty content), and HTMX smoothly removes the <tr> element from the DOM without a page refresh.


3. Why This Architecture Beats Client-Heavy SPAs

FeatureReact/Vue SPA + REST APIAdonisJS + HTMX MonolithState SyncingRedux/Pinia/Zustand client cache managementSingle source of truth (Lucid ORM / Database)ValidationDuplicate schema on client (Zod) and server (VineJS)VineJS only. Server returns partial error blocks directlyAuth & SecurityJWT tokens, refresh rotation, custom CORS setupsNative session cookies, standard CSRF protectionJS Bundle Size200KB – 2MB+ minified bundle parsed by mobile CPU~14KB gzip total (just HTMX runtime)Speed to MarketHigh friction (2 codebases, multiple build steps)Rapid execution (one codebase, zero build step for API)

4. Structuring a Clean Micro-Pattern (@gellish/micro-framework)

If you want to package your AdonisJS backend logic into a reusable micro-architecture or modular structure (e.g., an internal convention or monorepo package like @gellish/micro-framework), you can treat it as a headless hypermedia provider:


  1. Extract Shared Edge Components: Bundle recurring UI cards, modals, and dynamic data tables into an Edge plugin or npm package so your views stay DRY.


  2. Standardize HTMX Response Macros: Extend AdonisJS's Response object using an Ace provider:


    TypeScript

    // providers/htmx_provider.tsimport { Response } from '@adonisjs/core/http'
    
    Response.macro('htmxPartial', function (this: Response, viewPath: string, data: Record<string, any>) {
      // Custom logic: check header, render fragment vs layout automaticallyconst isHtmx = this.ctx?.request.header('hx-request')
      return isHtmx 
        ? this.ctx?.view.render(viewPath, data) 
        : this.ctx?.view.render(layouts/shell, { slotView: viewPath, ...data })
    })
    
  3. Out-of-Band (OOB) Updates: Leverage hx-swap-oob="true" to update multiple page sections simultaneously (e.g., updating a cart counter in the navbar while injecting a success message in the main form) in a single HTTP cycle.


This pattern eliminates the maintenance debt of large client-side frameworks while preserving fast UI feedback.