HTMX + Laravel: Building Interactive Apps Without JavaScript Frameworks

· 15 min read

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?

  1. Server-side rendering first: Laravel Blade already does a great job rendering HTML
  2. No separate API needed: Return HTML fragments instead of JSON
  3. SEO-friendly: Content is rendered from the server
  4. Simplified stack: No complex build steps required
  5. 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>
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

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

Comments