DEV Community

Cover image for Laravel Blade Partial API Pattern with HTMX
Raheel Shan
Raheel Shan

Posted on • Originally published at raheelshan.com

Laravel Blade Partial API Pattern with HTMX

We have seen splendid UIs in this latest era. All thanks to Javascript, which has snatched the rendering responsibility from the backend that was always good at doing it. But it has also come with some cost. On the way to achieving SPA, reactivity, and interactivity, we have faced the following issues.

The SEO Compromised

This is the most important issue. When users or crawlers come to visit our page, the page is empty with a div with an id may be root. Users wait for the page to be loaded, but crawlers go back, marking this page empty without data. This heavily affects the ranking in search engines. To counter this issue, people started to use static site generation. Now that the pages are statically generated, they are not updated frequently, yet another problem to be solved.

The Markup Drift

The real issue isn’t JSX syntax — it’s that markup moved to the JavaScript side. Once that happened, the frontend had to take over everything the backend used to handle effortlessly.

Now the UI stack needs a build pipeline before a single element can render. JSX won’t run on its own, so tools like Babel, Vite, and Webpack become mandatory. Then come TypeScript, linters, formatters, and dependency managers — a full ecosystem just to output HTML.

This shift adds layers of fragility. Every change passes through a build; every runtime error risks a blank screen. The frontend must now fetch data, construct markup, manage reactivity, and coordinate state — all inside one sensitive language runtime.

And because markup no longer comes from the server, everything must be serialized into JSON first. The browser becomes responsible for turning that JSON back into visible UI. Two systems — backend and frontend — must now agree on the same shape of data and structure of views.

We didn’t simplify web development; we just moved the complexity closer to the user’s browser.

The Hidden Cost of JavaScript-Controlled Rendering

When JavaScript took over rendering, the web quietly stopped being simple. Every framework—React, Vue, Svelte, even the old Knockout and Backbone—started rebuilding what the browser was already doing just fine: creating and updating the DOM. Now, instead of writing HTML, frontend engineers spend their time being part-time compiler engineers—setting up build tools, handling hydration, debugging mismatched markup, and wrestling with virtual DOMs. What used to be a plain document has turned into a fragile choreography between server and client, where one small issue can break rendering, kill performance, or confuse search engines. We ended up building complex systems mostly to fix problems introduced by the same complexity.

How to Avoid JavaScript Hell

Since we have given JavaScript the responsibility to render/hydrate the frontend and have stuck with what we have experienced, what if we take back the responsibility from JavaScript and make it a little simple? Let Javascript only do what it is best at. We are simply going to use a few techniques.

Let's start step by step. First we are going to load a full page with Laravel Blade.

Let's say we need to display a list of products in a table. Each row will have a refresh button. A very basic example. We will do the below code.

class ProductController extends Controller
{
    public function index(Request $request)
    {
        $products = Product::paginate(10);
        return view('product.index', compact('products'));
    }
}
Enter fullscreen mode Exit fullscreen mode

Next, let's load the blade view.

// resources/views/product/index.blade.php
@extends('layouts.app')

@section('content')
<div class="container">
    <h2>Products</h2>
    <table>
        <thead>
            <tr>
                <th scope="col">Product</th>
                <th scope="col">Category</th>
                <th scope="col">Brand</th>
                <th scope="col">Actions</th>
            </tr>
        </thead>
        <tbody>
            @if (isset($products) && count($products) > 0)
                @foreach ($products as $product)
                    @include('product.partials.table.row',['product' => $product])
                @endforeach
            @endif

        </tbody>
    </table>
</div>
@endsection
Enter fullscreen mode Exit fullscreen mode

Do pay attention to how we are adding a partial inside loop.

You must load HTMX in your page before using it. HTMX installation.

//resources/views/product/partials/table/row.blade.php
<tr id="product-{{ $product->id }}">
    <td>{{ $product->name }}</td>
    <td>{{ $product->category }}</td>
    <td>{{ $product->brand }}</td>
    <td>
        <button 
            type="button"
            hx-get="{{ url('product/partials/table/row/'. $product->id) }}" 
            hx-target="#product-{{ $product->id }}"
            hx-swap="outerHTML" 
            class="btn btn-sm btn-primary mb-3">
            Refresh
        </button>    
    </td>
</tr>
Enter fullscreen mode Exit fullscreen mode

And here we are telling HTMX to refresh the current product row. We have provided it with a unique ID product-x and an API endpoint to fetch partial from.

Now let's move to Laravel routes.

use Illuminate\Support\Str;
use Illuminate\Support\Facades\View;
use Illuminate\Http\Request;

Route::match(['get', 'post'], '{path}', function ($path, Request $request) {
    // convert "product/partials/table/row" to "product.partials.table.row"
    $viewPath = str_replace('/', '.', $path);

    // ensure view exists
    if (!View::exists($viewPath)) {
        abort(404, "Partial [$viewPath] not found.");
    }

    // split segments for context
    $segments = explode('/', $path);

    // detect model name (singular or plural)
    $modelSegment = $segments[0] ?? null;
    $modelName = ucfirst(Str::singular($modelSegment));
    $modelClass = "App\\Models\\$modelName";

    $data = [];

    // only attempt model binding if model class exists
    if (class_exists($modelClass)) {
        // if last segment is numeric, assume it’s an ID → single item
        $last = end($segments);
        if (is_numeric($last)) {
            $data = [Str::lower($modelName) => $modelClass::findOrFail($last)];
        }
        // if plural model segment detected → fetch collection
        elseif (Str::plural($modelSegment) === $modelSegment) {
            $data = [Str::lower(Str::plural($modelName)) => $modelClass::all()];
        }
    }

    return view($viewPath, $data);
})->where('path', '.*');
Enter fullscreen mode Exit fullscreen mode

Another example.

Let's say we need to refresh the whole table this time and we are defining a button to refresh the table data. In this case, here is the example.

@extends('layouts.app')

@section('content')
<div class="container">
    <h2>Products</h2>
    <button 
        type="button"
        hx-get="{{ url('product/partials/table') }}" 
        hx-target="#product-rows"
        hx-swap="innerHTML" 
        class="btn btn-sm btn-primary mb-3">
        Refresh Table
    </button>       
    <table>
        <thead>
            <tr>
                <th scope="col">Product</th>
                <th scope="col">Category</th>
                <th scope="col">Brand</th>
                <th scope="col">Actions</th>
            </tr>
        </thead>
        <tbody id="product-rows">
            @include('products.partials.table.list',['products' => $products])
        </tbody>
    </table>
</div>
@endsection
Enter fullscreen mode Exit fullscreen mode

And again, extract the partial where needed.

//resources/views/product/partials/table/list.blade.php

@if (isset($products) && count($products) > 0)
    @foreach ($products as $product)
        @include('products.partials.table.row',['product' => $product])
    @endforeach
@endif
Enter fullscreen mode Exit fullscreen mode

In this setup we need to respect some rules. Here we go.

Rule 1—Directory equals URL

Every request path directly maps to a Blade file under resources/views. /product/partials/formresources/views/product/partials/form.blade.php

Rule 2 — Dots replace slashes

Internally, Laravel views use dot notation, so the route simply replaces / with . before rendering.

Rule 3 — Any depth allowed

There’s no fixed folder depth. The resolver supports nested structures like /product/partials/table/rowproduct.partials.table.row

Rule 4 — No model or data assumptions

The resolver doesn’t inject or infer data. Every partial is responsible for its own context (passed from the parent view or controller).

Rule 5 — 404 for missing partials

If the resolved view doesn’t exist, the route gracefully fails with a 404. No silent fallbacks, no guesswork.

Rule 6 — Both GET and POST supported

The same route handles GET and POST, so it can respond to hx-get, hx-post, or form submissions without extra definitions.

Rule 7 — Everything is a partial endpoint

Any Blade file—model-specific or global—can act as an endpoint. You’re free to mix singular (product) or plural (products) folders; the router doesn’t care.


How This Solves the SEO Problem

When the page first loads, Laravel renders everything—a fully formed HTML document with all partials in place. Search engines see a complete, server-rendered page.

After that, HTMX takes over. The same partials that were included during the initial load can now be reloaded individually via the same Blade templates and route pattern.

That means:

  • The page is crawlable and SEO-friendly.
  • The partials are reusable.
  • You’re not duplicating frontend logic or templates.

Why This Pattern Works

  • No duplication. The same Blade partials power both full-page and partial loads.
  • SEO intact. The first render is full HTML, not JavaScript-rendered fluff.
  • Reusable. Any component can be loaded standalone or as part of a bigger page.
  • Simple routing. One global route pattern controls all partial endpoints.
  • No JSON. The browser never has to assemble UI from raw data again.

This is Laravel doing what Laravel does best: serving clean HTML from the server, enhanced with just enough JavaScript to feel dynamic.

A Simpler Future

The Blade Partial API Pattern isn’t a framework or a library. It’s just Laravel used properly—with HTMX as the final piece that gives your pages dynamic behavior without losing their soul. It’s fast, indexable, and human to read. Server-driven UI, but done right. No build tools, no JSON, no magic—just Blade.

Final Thoughts

The beauty of this pattern is that every request still flows through Laravel’s full stack—middleware, validation, gates, and permissions. You’re not giving up structure or safety for convenience. Each Blade partial can be protected, validated, or even transformed the same way as any API route. The difference is that the response isn’t JSON—it’s ready-to-render markup, straight from the server, exactly as Laravel intended.

It’s not about going backward. It’s about taking what’s already powerful and using it as it was meant to be used—without pretending the browser needs to be a compiler.

Ending Notes

This article is experimental, and I am going to do a bit more research so we can complete all scenarios. if you are interested. Subscribe to my blog and stay tuned. 

Top comments (0)