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'));
}
}
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
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>
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', '.*');
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
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
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/form
→ resources/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/row
→ product.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)