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:
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.
Standardize HTMX Response Macros: Extend AdonisJS's
Responseobject 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 }) })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.
Discussion
Sign in with GitHub to comment. Comments are moderated.