HTMX + Laravel: Building Interactive Apps Without JavaScript Frameworks
Introduction
Are you tired of learning a new JavaScript framework every year? React, Vue, Angular, Svelte... the list keeps growing. HTMX offers a completely different approach - using pure HTML to create interactive applications.
What is HTMX?
HTMX is an ultra-lightweight JavaScript library (~14KB gzipped) that allows you to access AJAX, CSS Transitions, WebSockets, and Server Sent Events directly in HTML through attributes.
<!-- Before with JavaScript -->
<button onclick="loadContent()">Load More</button>
<script>
function loadContent() {
fetch('/api/content')
.then(response => response.text())
.then(html => {
document.getElementById('content').innerHTML = html;
});
}
</script>
<!-- With HTMX - just HTML -->
<button hx-get="/content" hx-target="#content" hx-swap="innerHTML">
Load More
</button>
Why HTMX + Laravel is the Perfect Combo?
- Server-side rendering first: Laravel Blade already does a great job rendering HTML
- No separate API needed: Return HTML fragments instead of JSON
- SEO-friendly: Content is rendered from the server
- Simplified stack: No complex build steps required
- Keep Laravel conventions: Blade components, validation, sessions...
Installing HTMX
Option 1: CDN (Fastest)
<!-- resources/views/layouts/app.blade.php -->
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/htmx.org@2.0.0"></script>
</head>
<body>
@yield('content')
</body>
</html>
Option 2: NPM (Recommended for production)
npm install htmx.org
// resources/js/app.js
import htmx from 'htmx.org';
window.htmx = htmx;
// vite.config.js
export default defineConfig({
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.js'],
refresh: true,
}),
],
});
Essential HTMX Attributes
hx-get, hx-post, hx-put, hx-delete
<!-- GET request -->
<button hx-get="/users">Load Users</button>
<!-- POST request with form -->
<form hx-post="/users" hx-target="#user-list">
<input name="name" required>
<button type="submit">Add User</button>
</form>
<!-- DELETE request -->
<button hx-delete="/users/1" hx-confirm="Are you sure?">
Delete User
</button>
hx-target and hx-swap
<!-- Target: Specify which element will be updated -->
<button hx-get="/notifications" hx-target="#notification-panel">
Refresh Notifications
</button>
<!-- Swap: How to update the content -->
<button hx-get="/items" hx-target="#list" hx-swap="beforeend">
Load More (append)
</button>
Common hx-swap values:
| Value | Description |
|---|---|
innerHTML |
Replace inner HTML (default) |
outerHTML |
Replace the entire element |
beforeend |
Append to the end |
afterbegin |
Prepend to the beginning |
beforebegin |
Insert before the element |
afterend |
Insert after the element |
delete |
Delete the element |
none |
No swap (only trigger events) |
hx-trigger
<!-- Trigger on click (default for buttons) -->
<button hx-get="/data" hx-trigger="click">Click me</button>
<!-- Trigger on hover -->
<div hx-get="/preview" hx-trigger="mouseenter">Hover for preview</div>
<!-- Trigger on input change with debounce -->
<input hx-get="/search"
hx-target="#results"
hx-trigger="keyup changed delay:500ms"
name="q">
<!-- Trigger when element becomes visible (lazy loading) -->
<div hx-get="/more-content" hx-trigger="revealed">
Loading...
</div>
<!-- Trigger from another event -->
<button hx-get="/refresh" hx-trigger="newMessage from:body">
Messages
</button>
Building Real-World Applications
Example 1: Live Search
Controller:
// app/Http/Controllers/SearchController.php
namespace App\Http\Controllers;
use App\Models\Product;
use Illuminate\Http\Request;
class SearchController extends Controller
{
public function index()
{
return view('search.index');
}
public function search(Request $request)
{
$query = $request->input('q', '');
$products = Product::query()
->when($query, function ($q) use ($query) {
$q->where('name', 'like', "%{$query}%")
->orWhere('description', 'like', "%{$query}%");
})
->limit(20)
->get();
// Return HTML partial, not full page
return view('search.partials.results', compact('products'));
}
}
Main View:
{{-- resources/views/search/index.blade.php --}}
@extends('layouts.app')
@section('content')
<div class="container mx-auto px-4 py-8">
<h1 class="text-2xl font-bold mb-6">Product Search</h1>
<div class="mb-6">
<input type="text"
name="q"
placeholder="Enter product name..."
class="w-full p-3 border rounded-lg"
hx-get="{{ route('search') }}"
hx-target="#results"
hx-trigger="keyup changed delay:300ms"
hx-indicator="#loading">
<div id="loading" class="htmx-indicator">
<span class="text-gray-500">Searching...</span>
</div>
</div>
<div id="results">
{{-- Results will be loaded here --}}
</div>
</div>
@endsection
Partial View:
{{-- resources/views/search/partials/results.blade.php --}}
@if($products->isEmpty())
<p class="text-gray-500">No products found.</p>
@else
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
@foreach($products as $product)
<div class="border rounded-lg p-4 hover:shadow-lg transition">
<h3 class="font-semibold">{{ $product->name }}</h3>
<p class="text-gray-600 text-sm">{{ Str::limit($product->description, 100) }}</p>
<p class="text-blue-600 font-bold mt-2">
${{ number_format($product->price, 2) }}
</p>
</div>
@endforeach
</div>
@endif
Example 2: Infinite Scroll
Controller:
// app/Http/Controllers/PostController.php
public function index(Request $request)
{
$page = $request->input('page', 1);
$posts = Post::latest()
->paginate(10, ['*'], 'page', $page);
if ($request->header('HX-Request')) {
// HTMX request - return partial
return view('posts.partials.list', compact('posts'));
}
// Full page load
return view('posts.index', compact('posts'));
}
Main View:
{{-- resources/views/posts/index.blade.php --}}
@extends('layouts.app')
@section('content')
<div class="container mx-auto px-4 py-8">
<h1 class="text-2xl font-bold mb-6">Posts</h1>
<div id="posts-container">
@include('posts.partials.list', ['posts' => $posts])
</div>
</div>
@endsection
Partial View with Infinite Scroll:
{{-- resources/views/posts/partials/list.blade.php --}}
@foreach($posts as $post)
<article class="border-b py-4">
<h2 class="text-xl font-semibold">
<a href="{{ route('posts.show', $post) }}">{{ $post->title }}</a>
</h2>
<p class="text-gray-600 mt-2">{{ Str::limit($post->excerpt, 200) }}</p>
<time class="text-sm text-gray-400">{{ $post->created_at->format('M d, Y') }}</time>
</article>
@endforeach
@if($posts->hasMorePages())
{{-- This element will trigger loading more when visible --}}
<div hx-get="{{ $posts->nextPageUrl() }}"
hx-trigger="revealed"
hx-swap="outerHTML"
hx-indicator="#loading-more"
class="py-4 text-center">
<span id="loading-more" class="htmx-indicator">
Loading more...
</span>
</div>
@endif
Example 3: Modal CRUD
Routes:
// routes/web.php
Route::resource('tasks', TaskController::class);
Route::get('tasks/{task}/edit-modal', [TaskController::class, 'editModal'])
->name('tasks.edit-modal');
Controller:
// app/Http/Controllers/TaskController.php
namespace App\Http\Controllers;
use App\Models\Task;
use Illuminate\Http\Request;
class TaskController extends Controller
{
public function index()
{
$tasks = Task::latest()->get();
return view('tasks.index', compact('tasks'));
}
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string',
]);
$task = Task::create($validated);
return view('tasks.partials.task-row', compact('task'));
}
public function editModal(Task $task)
{
return view('tasks.partials.edit-modal', compact('task'));
}
public function update(Request $request, Task $task)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string',
]);
$task->update($validated);
// Close modal and refresh row
return response()
->view('tasks.partials.task-row', compact('task'))
->header('HX-Trigger', 'closeModal');
}
public function destroy(Task $task)
{
$task->delete();
// Return empty response with swap delete
return response('', 200);
}
}
Main View:
{{-- resources/views/tasks/index.blade.php --}}
@extends('layouts.app')
@section('content')
<div class="container mx-auto px-4 py-8">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">Tasks</h1>
<button hx-get="{{ route('tasks.create') }}"
hx-target="#modal-container"
hx-swap="innerHTML"
class="bg-blue-500 text-white px-4 py-2 rounded">
+ Add Task
</button>
</div>
{{-- Task list --}}
<div id="task-list" class="space-y-2">
@foreach($tasks as $task)
@include('tasks.partials.task-row', ['task' => $task])
@endforeach
</div>
{{-- Modal container --}}
<div id="modal-container"></div>
</div>
<script>
// Close modal when receiving trigger
document.body.addEventListener('closeModal', function() {
document.getElementById('modal-container').innerHTML = '';
});
</script>
@endsection
Task Row Partial:
{{-- resources/views/tasks/partials/task-row.blade.php --}}
<div id="task-{{ $task->id }}" class="flex items-center justify-between p-4 bg-white rounded-lg shadow">
<div>
<h3 class="font-semibold">{{ $task->title }}</h3>
@if($task->description)
<p class="text-gray-600 text-sm">{{ $task->description }}</p>
@endif
</div>
<div class="flex gap-2">
<button hx-get="{{ route('tasks.edit-modal', $task) }}"
hx-target="#modal-container"
hx-swap="innerHTML"
class="text-blue-500 hover:text-blue-700">
Edit
</button>
<button hx-delete="{{ route('tasks.destroy', $task) }}"
hx-target="#task-{{ $task->id }}"
hx-swap="outerHTML"
hx-confirm="Delete this task?"
class="text-red-500 hover:text-red-700">
Delete
</button>
</div>
</div>
Modal Partial:
{{-- resources/views/tasks/partials/edit-modal.blade.php --}}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
id="modal-backdrop"
hx-on:click="if(event.target === this) this.remove()">
<div class="bg-white rounded-lg p-6 w-full max-w-md"
hx-on:click="event.stopPropagation()">
<h2 class="text-xl font-bold mb-4">Edit Task</h2>
<form hx-put="{{ route('tasks.update', $task) }}"
hx-target="#task-{{ $task->id }}"
hx-swap="outerHTML">
@csrf
@method('PUT')
<div class="mb-4">
<label class="block text-sm font-medium mb-1">Title</label>
<input type="text"
name="title"
value="{{ $task->title }}"
class="w-full p-2 border rounded"
required>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-1">Description</label>
<textarea name="description"
rows="3"
class="w-full p-2 border rounded">{{ $task->description }}</textarea>
</div>
<div class="flex gap-2 justify-end">
<button type="button"
onclick="document.getElementById('modal-container').innerHTML = ''"
class="px-4 py-2 border rounded">
Cancel
</button>
<button type="submit"
class="px-4 py-2 bg-blue-500 text-white rounded">
Save
</button>
</div>
</form>
</div>
</div>
Handling Validation Errors
HTMX works beautifully with Laravel validation. When there are errors, just return the form with errors:
// Controller
public function store(Request $request)
{
$validator = Validator::make($request->all(), [
'email' => 'required|email|unique:users',
'password' => 'required|min:8',
]);
if ($validator->fails()) {
return response()
->view('auth.partials.register-form', [
'errors' => $validator->errors(),
'old' => $request->all(),
])
->setStatusCode(422); // Important: return 422 so HTMX knows it's an error
}
// Success logic...
}
{{-- Form partial --}}
<form hx-post="{{ route('register') }}"
hx-target="this"
hx-swap="outerHTML">
<div class="mb-4">
<input type="email"
name="email"
value="{{ old('email', $old['email'] ?? '') }}"
class="w-full p-2 border rounded @error('email') border-red-500 @enderror">
@error('email')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<button type="submit">Register</button>
</form>
Loading States and Indicators
<!-- CSS for indicators -->
<style>
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline;
}
.htmx-request.htmx-indicator {
display: inline;
}
</style>
<!-- Button with loading state -->
<button hx-post="/process"
hx-indicator="#spinner"
class="relative">
<span>Submit</span>
<span id="spinner" class="htmx-indicator absolute right-2">
<svg class="animate-spin h-5 w-5" viewBox="0 0 24 24">
<!-- Spinner SVG -->
</svg>
</span>
</button>
<!-- Disable button while loading -->
<button hx-post="/process"
hx-disabled-elt="this"
class="disabled:opacity-50">
Submit
</button>
HTMX with Laravel Middleware
Create middleware to handle HTMX requests:
// app/Http/Middleware/HtmxMiddleware.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class HtmxMiddleware
{
public function handle(Request $request, Closure $next)
{
// Check if it's an HTMX request
$request->macro('isHtmx', function () use ($request) {
return $request->hasHeader('HX-Request');
});
// Get target element
$request->macro('htmxTarget', function () use ($request) {
return $request->header('HX-Target');
});
// Get trigger element
$request->macro('htmxTrigger', function () use ($request) {
return $request->header('HX-Trigger');
});
$response = $next($request);
// Add HTMX headers if needed
if ($request->isHtmx()) {
// Can redirect in HTMX using header
// $response->header('HX-Redirect', '/new-url');
// Or refresh the entire page
// $response->header('HX-Refresh', 'true');
}
return $response;
}
}
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [
\App\Http\Middleware\HtmxMiddleware::class,
]);
})
Useful HTMX Extensions
1. json-enc - Send JSON instead of form data
<script src="https://unpkg.com/htmx.org/dist/ext/json-enc.js"></script>
<form hx-post="/api/data" hx-ext="json-enc">
<input name="title" value="Hello">
<button>Submit as JSON</button>
</form>
2. loading-states - Better loading state management
<script src="https://unpkg.com/htmx.org/dist/ext/loading-states.js"></script>
<button hx-get="/data"
hx-ext="loading-states"
data-loading-class="opacity-50 cursor-wait"
data-loading-disable>
Load Data
</button>
3. response-targets - Different targets per status code
<script src="https://unpkg.com/htmx.org/dist/ext/response-targets.js"></script>
<form hx-post="/submit"
hx-ext="response-targets"
hx-target="#success-message"
hx-target-422="#error-message"
hx-target-500="#server-error">
<!-- form fields -->
</form>
HTMX vs JavaScript Frameworks Comparison
| Criteria | HTMX | React/Vue |
|---|---|---|
| Learning curve | Low | High |
| Bundle size | ~14KB | 100KB+ |
| Build step | Not required | Required |
| SEO | Good (SSR by default) | Needs configuration |
| State management | Server-side | Client-side |
| Real-time updates | Server Sent Events | WebSocket |
| Offline support | Limited | Good (PWA) |
| Complex interactions | Can be tricky | More flexible |
Best Practices
1. Use Blade Components
{{-- resources/views/components/htmx-button.blade.php --}}
@props([
'method' => 'get',
'url',
'target' => 'body',
'swap' => 'innerHTML',
'confirm' => null,
])
<button
{{ $attributes->class(['btn']) }}
hx-{{ $method }}="{{ $url }}"
hx-target="{{ $target }}"
hx-swap="{{ $swap }}"
@if($confirm) hx-confirm="{{ $confirm }}" @endif
>
{{ $slot }}
</button>
<x-htmx-button url="/tasks" target="#task-list">
Load Tasks
</x-htmx-button>
<x-htmx-button
method="delete"
url="/tasks/1"
target="#task-1"
swap="outerHTML"
confirm="Delete this task?">
Delete
</x-htmx-button>
2. Organize Partials
resources/views/
├── tasks/
│ ├── index.blade.php # Full page
│ ├── partials/
│ │ ├── list.blade.php # Task list
│ │ ├── task-row.blade.php # Single task
│ │ ├── create-form.blade.php
│ │ └── edit-modal.blade.php
3. CSRF Token
<!-- Global CSRF setup -->
<meta name="csrf-token" content="{{ csrf_token() }}">
<script>
document.body.addEventListener('htmx:configRequest', (event) => {
event.detail.headers['X-CSRF-TOKEN'] = document.querySelector('meta[name="csrf-token"]').content;
});
</script>
Conclusion
HTMX + Laravel is an extremely powerful combo for building interactive web applications without the complexity of JavaScript frameworks. With the "HTML over the wire" approach, you can:
- Keep Laravel conventions - Blade templates, server-side validation, sessions
- Reduce complexity - No separate API needed, no complex state management
- Increase productivity - Less code, fewer bugs
- SEO-friendly - Content rendered from server
HTMX isn't meant to completely replace React/Vue in every case, but for most CRUD and content-focused applications, it's an excellent choice.
When to Use HTMX?
- CRUD applications
- Admin panels
- Content websites
- Forms-heavy applications
- Server-rendered apps that need interactivity
When to Consider React/Vue?
- Complex client-side state
- Offline-first applications
- Real-time collaboration
- Heavy animations/transitions
- Mobile apps (React Native)
References
- HTMX Documentation
- HTMX Examples
- Laravel Blade Templates
- htmx.org Essays - Great philosophy content