Sometimes, the landscape of front-end development can seem overwhelming. Should I use React? Svelte? Livewire? Vue? And TypeScript? And a state manager, and maybe a meta-framework? Transpilers, bundlers, minifiers, and... hey, what is Bun?
Don't get me wrong; all these tools are fantastic and let us build highly interactive applications. But they come with a cost. The browser has to load the JavaScript, interpret it, take the data we send in JSON format, run the rendering functions, update the DOM, and sometimes even hold a virtual DOM in memory to keep track of future changes.
What if we could render templates on the server and return the HTML? Well, that is what we used to do. Back in the day, before the rise of frontend frameworks, it wasn't uncommon to make an AJAX request to the server, receive a HTML in the response, and just insert it somewhere on the page.
HTMX brings back that philosophy, with a twist: it allows us to trigger AJAX requests from any HTML element just by adding custom attributes to it. Thanks to this approach, we can create single-page applications without writing a single line of JavaScript. Sounds intriguing? Let's dive in!
Picture this: a "Subscribe" button must send a POST request to /subscribe
when clicked. If the response is successful, the button must be replaced with the message returned by the server.
Normally, you would need to handle the click, perform the request, and replace the element using JavaScript:
<button id="subscribe">Subscribe</button> <script>document.getElementById('subscribe').addEventListener('click', function() { fetch('/subscribe', { method: 'POST' }) .then(response => response.text()) .then(message => { document.getElementById('subscribe').outerHTML = message; });});</script>
Using HTMX, you can achieve the same result without writing JavaScript:
<button id="subscribe" hx-trigger="click" hx-post="/subscribe" hx-target="this" hx-swap="outerHTML"> Subscribe</button>
Wait, is that all? Yes, that's it. Let's delve into these hx
attributes we just added:
click
or mouseover
. By default, inputs, textareas, and selects are triggered by the change
event. Forms are triggered by the submit
event, while everything else is triggered by the click
event..results
or #list
. In this case, we use the default value this
, a special key indicating that the target is the element itself.innerHTML
, meaning that the HTML of the response will replace the contents of the target. Here, we don't want to embed the message inside the button; instead, we want it to replace the button entirely, so we use outerHTML
.Since the default value for hx-target is this
and the default hx-trigger for a button is click
respectively, we can condense our code a bit more:
<button id="subscribe" hx-post="/subscribe" hx-swap="outerHTML"> Subscribe</button>
Interpreting these attributes might seem daunting at first, but they become quite intuitive with practice. For example, let's take a look at this code:
<button hx-get="/contacts" hx-target="#contact-list" hx-swap="outerHTML"> Reload Contacts</button><ul id="contact-list"> <li>John Doe</li> <li>Jane Smith</li></ul>
What is this doing? If you answered, "Clicking that button fires a GET request to /contacts
and replaces the entire #contact-list
with the HTML of the response," then you are already an HTMX ninja.
To learn more about HTMX and how we can utilize it to enhance our applications, let's create a demo. How about a website where users can log in and watch a video stream? The challenge is to enable users to post comments and see comments from other users in real-time without interrupting the video playback.
Sounds like a good idea? Let's do it! Here's the roadmap:
This is the final result:
The code is available in this public GitHub repo.
Let's start by installing Laravel and Laravel Breeze, a starter kit that takes care of creating authentication routes, controllers, and templates so our users can register and log in to our application. Run the following commands:
# Install Laravelcomposer create-project laravel/laravel demo-htmx-laravelcd demo-htmx-laravel # Install Laravel Breezecomposer require laravel/breeze --dev # Scaffold using Blade viewsphp artisan breeze:install blade
Now you can start the application by running php artisan serve
, and remember to start Vite with npm run dev
as well.
We want to provide a form for users to create comments. Let's add the route to routes/web.php
using a resource controller.
Route::middleware('auth')->group(function () { Route::resource('comments', CommentController::class)->only(['store']);});
For now, the only method is store
. Later on, we'll add index
to retrieve all comments. But for now, this will do. Let's create the controller:
<?php namespace App\Http\Controllers; use Illuminate\Http\Request; class CommentController extends Controller{ public function store(Request $request) { $validated = $request->validate(['text' => 'string|required']); $request->user()->comments()->create($validated); return back(); }}
Here, we save the comment to the database via the authenticated user model. So, we should add a relationship to the User
to indicate a user has many comments:
public function comments(): HasMany{ return $this->hasMany(Comment::class);}
Finally, let's create that Comment
model.
<?php namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory;use Illuminate\Database\Eloquent\Model;use Illuminate\Database\Eloquent\Relations\BelongsTo; class Comment extends Model{ use HasFactory; protected $fillable = ['text']; public function user(): BelongsTo { return $this->belongsTo(User::class); }}
As you might expect, we also need to create the corresponding migration.
The stream page is where the video player and comment section will live. Let's add the route:
Route::middleware('auth')->group(function () { Route::resource('comments', CommentController::class)->only(['store']); Route::get('stream', StreamPageController::class)->name('stream'); });
Here, StreamPageController
is an invokable controller responsible for fetching comments and rendering the stream page. Additionally, it stores the ID of the last comment in the user session (hint: we'll use this later!).
<?php namespace App\Http\Controllers; use App\Models\Comment;use Illuminate\Http\Request; class StreamPageController extends Controller{ public function __invoke(Request $request) { $comments = Comment::with('user')->latest()->get(); if ($comments->isNotEmpty()) { session(['last_comment_read' => $comments->first()->id]); } return view('stream', ['comments' => $comments])); }}
Now, let's create the stream
template. Essentially, it consists of a flex container with two columns:
<x-app-layout> <div class="bg-white flex flex-col gap-6 p-6 lg:flex-row"> <div class="lg:w-2/3"> <!-- First column --> </div> <div class="lg:w-1/3"> <!-- Second column --> </div> </div></x-app-layout>
The first column will contain the player. For simplicity, instead of a live video feed, let's use the YouTube embed of an episode of the Laravel Podcast.
Subscribe, by the way!
<iframe class="w-full aspect-video" src="https://www.youtube.com/embed/gerh8ywmuyM?si=aGPlpJgk0HSm4IDJ" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
The second column will include the form to post a comment and the list of comments below it.
<form method="post" action="{{ route('comments.store') }}"> @csrf <input name="text" required autofocus class="w-full" placeholder="Type your comment and press Enter" /></form> <div id="comments" class="mt-2 flex flex-col gap-2"> @foreach ($comments as $comment) <div> <strong>{{ $comment->user->name }}</strong> {{ $comment->text }} </div> @endforeach</div>
Alright! The stream page is done. To complete the setup, let's link it in the top navigation bar.
Open resources/views/layouts/navigation.blade.php
and insert a link to /stream
immediately after the Dashboard link. Save the file, then open the application in the browser and click on the added link. You should now see the following:
We've made significant progress!
Now we need to focus on these issues:
Let's explore how we can use HTMX to address all these points and transform our application into a real-time experience without writing a single line of JavaScript.
hx-boost
Installing HTMX is super simple. We can add the script tag to the head of our layout:
<script src="https://unpkg.com/htmx.org@1.9.11"></script>
Then, let's start by adding hx-boost
to the body:
<body hx-boost="true">
This feature "boosts" all links and forms, meaning:
GET
AJAX request. Upon receiving a successful response, it will replace the current page's body contents with the body contents of the HTML returned by the server and create a history entry.This is great! You can navigate through the different sections of the application and even update your profile without a single full-page reload.
But we're not quite finished yet. Notice that on the stream page, the video still reloads when we submit a comment because we're swapping the entire body content, including the video player. Additionally, the backend still renders the whole page, including all the comments, every time we submit one.
It would be ideal if we could keep the video playing and re-render only the necessary content. Let's do that in the following steps.
hx-post
When using hx-boost
, all forms in the page are submitted using AJAX, but we can't control the fine details, like the target and the swap strategy.
To do so, we can add the hx-post
attribute to our form that submits comments. The value of the attribute should be the endpoint we want to hit, in this case, /comments
. So the form should look like this:
<form method="post" action="{{ route('comments.store') }}" hx-post="{{ route('comments.store') }}"> @csrf <input name="text" required autofocus class="w-full" placeholder="Type your comment and press Enter" /></form>
No need to define hx-target
or hx-swap
, because the default target is the element itself and the default swap strategy is innerHTML
. This means that, by default, HTMX will replace the inner content of the element that fired the event with whatever HTML is returned by the server. In this case, what needs to be returned is the form, with a new CSRF token and a clear text input:
<form method="post" action="http://demo-htmx-laravel.test/comments" hx-post="http://demo-htmx-laravel.test/comments"> <input type="hidden" name="_token" value="..." autocomplete="off"> <input name="text" type="text" required autofocus class="w-full" value="" placeholder="Type your comment and press Enter" /></form>
Let's adapt the store
method of our CommentController
controller to return just that.
We can verify if the request was made via HTMX by checking for the presence of the hx-request
header. If it exists, we return HTML; otherwise, we perform the classic redirect.
$validated = $request->validate(['text' => 'string|required']);$request->user()->comments()->create($validator->validated()); if ($request->hasHeader('hx-request')) { return view('stream');} return back();
But, wait... we don't want to return the entire stream
view, just a fragment of it. Otherwise, we'd end up embedding the entire page within the form. Your first thought might be to extract the form into a partial:
if ($request->hasHeader('hx-request')) { return view('partials.comment-form'); }
And that totally works, but Laravel has a trick to make it even easier: fragments.
We can keep the form in the current stream.blade.php
template and just wrap it between the @fragment
and @endfragment
directives, like this:
+@fragment('comment-form') <form method="post" action="{{ route('comments.store') }}" hx-post="{{ route('comments.store') }}"> @csrf <input name="text" required autofocus class="w-full" placeholder="Type your comment and press Enter" /> </form>+@endfragment
As you can see, we've delimited a fragment and assigned it a name. Now, we can return the view and chain the fragment
method, passing the fragment name as a parameter. This ensures that the response contains only that portion of HTML.
if ($request->hasHeader('hx-request')) { return view('stream')->fragment('comment-form'); }
When you render a fragment of a view, you still need to pass all the variables that the view needs, even those used outside the fragments you want to render. That's why we need to check if the $comments
variable is set, because otherwise, we would get the error Undefined variable $comments
.
+@if (isset($comments)) @foreach ($comments as $comment) <div> <strong>{{ $comment->user->name }}</strong> {{ $comment->text }} </div> @endforeach+@endif
If we did everything right, when submitting the form and saving the comment, the form should be swapped by that bit of HTML. Effectively, we are clearing out the form, HTMX-style.
Now, this only swaps the form. The list of comments remains unaltered. That's the last piece of the puzzle: adding new comments to the list.
There are many ways to accomplish this step, but today we'll explore one of the simplest methods:
This strategy is known as polling, and although it may not be ideal in many scenarios because it involves constantly sending requests to the server, it will help us learn more about HTMX. If you would like to see how you can replace it for websockets, stay tuned for the next (optional) step.
Let's start by telling HTMX to make the request every second. In the #comments
container we should add the attribute hx-get
with the value of the endpoint we want to hit. In this case, a new comments.index
route that we'll soon create.
hx-get="{{ route('comments.index') }}"
Then, add the attribute hx-trigger
, with the value every 1s
. This is one of the special triggers HTMX offers, apart from the standard DOM events like click
, change
and submit
. We can adjust the frequence to any number of seconds we consider appropiate for our app.
hx-trigger="every 1s"
Now let's use the hx-swap
attribute to tell HTMX how we want to handle the incoming HTML. We've already seen a few common swap strategies, like innerHTML
(to replace the content of an element) and outerHTML
(to replace the whole element).
Here we'll use another one: afterbegin
, which injects the HTML after the opening tag of the target element. In other words, we are instructing HTMX to place the new comments right at the top of the list.
hx-swap="afterbegin"
This means that the server should return only the new comments rows, like this:
<div><strong>Nico</strong> Hello world!</div><div><strong>John</strong> How are you?</div>
To do so, let's create a new fragment in our template, wrapping the loop of comments. To be original, let's call it comments
. The final updated template code should look like this:
<div id="comments" hx-get="{{ route('comments.index') }}" hx-trigger="every 1s" hx-swap="afterbegin" class="mt-2 flex flex-col gap-2"> @fragment('comments') @if (isset($comments)) @foreach ($comments as $comment) <div> <strong>{{ $comment->user->name }}</strong> {{ $comment->text }} </div> @endforeach @endif @endfragment </div>
Now let's create the endpoint. We can add the index
method to the resouce route:
Route::resource('comments', CommentController::class)->only(['store', 'index']);
... and define it in the controller:
public function index(){ $comments = Comment::with('user') ->where('id', '>', session('last_comment_read', 0)) ->latest() ->get(); if ($comments->isEmpty()) { return response()->noContent(); } session(['last_comment_read' => $comments->first()->id]); return view('stream', ['comments' => $comments]))->fragment('comments');}
Alright, let's break down this code snippet:
We start off by retrieving comments from the database. Remember that in a previous step, when we created the StreamPageController
, we saved the ID of the last retrieved comment in the session as last_comment_read
? Well, we've come full circle. We will now read that session key to retrieve all the messages with an ID greater than that value, because we're only interested in fetching comments that have been created after the last comment the user has seen.
If the query result is empty, we respond with a 204 No Content
status code. This indicates HTMX that there's nothing new to display.
If there are, we update the last_comment_read
session variable to store the ID of the latest comment fetched.
Finally, we return the stream
view, passing the retrieved comments to it and chaining the fragment()
method to specify that only the comments
fragment should be sent back in the response.
And we are done! Every second, new comments will be displayed in the list.
We accomplished all our goals:
And given that when taking this approach everything still works without JavaScript, you get two great aditional benefits:
You can access to the complete codebase on GitHub.
This is an optional, additional step. As we mentioned, polling is easy to implement, but for a more performant solution, we can use websockets. Spoiler alert, this implies writing JavaScript, but just a tiny bit (we promise!)
Let's create a simple CommentSent
event:
<?php namespace App\Events; use Illuminate\Broadcasting\InteractsWithSockets;use Illuminate\Broadcasting\PrivateChannel;use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;use Illuminate\Foundation\Events\Dispatchable;use Illuminate\Queue\SerializesModels; class CommentSent implements ShouldBroadcastNow{ use Dispatchable, InteractsWithSockets, SerializesModels; /** * Create a new event instance. */ public function __construct() { // } /** * Get the channels the event should broadcast on. * * @return array<int, \Illuminate\Broadcasting\Channel> */ public function broadcastOn(): array { return [ new PrivateChannel('comments'), ]; }}
When a new comment is posted, we emit that event:
$request->user()->comments()->create($validator->validated());event(new CommentSent);
Assumming we have configured broadcasting and installed Laravel Echo in our frontend, we can listen for the event and define a callback:
window.Echo.private('comments').listen('CommentSent', () => { htmx.trigger('#comments', 'loadComments');});
As you can see, we are using htmx
in JavaScript. Although the philosophy of HTMX is to express behavior via HTML attributes, it provides this handy JavaScript object, which includes methods to fire events programmatically and more.
In this case, we use the htmx.trigger
method to emit a custom event named loadComments
, which the #comments
can listen for. Now, we need to update it so that the request is made not every second but only when triggered by that custom event.
<div id="comments" hx-get="{{ route('comments.index') }}"- hx-trigger="every 1s" + hx-trigger="loadComments" hx-swap="afterbegin" class="mt-2 flex flex-col gap-2">
Done! Now, instead of polling every second, we'll only send the request to retrieve new comments when we receive the event from the websocket.
It's worth mentioning that HTMX offers an extension to support websockets, enabling the updating of the page with HTML fragments received through an open connection. That's a bit more advanced and beyond the scope of this article, but feel free to check it out.
Can you create rich, interactive experiences without relying on a big JavaScript framework like React, Vue, or Svelte? These days, more and more people are leaning towards "yes". Turbo Laravel and Laravel Livewire are fantastic tools to do so.
And HTMX is a magnificent addition to that list, with the bonus of working with any backend framework that can return HTML. Maybe that is why HTMX was the 2nd Front-end Framework rising star of the year in 2023, just behind React!
We invite you to check out the HTMX docs and their Hypermedia book to learn more.
And if you enjoy building HTMX applications in Laravel, explore the Laravel-HTMX package, which comes with many goodies for advanced usage.
Until next time!
We appreciate your interest.
We will get right back to you.