Introduction

Let’s recap the web’s evolution so far:

Now in Part 4, we’re witnessing a new shift: A return to server-first thinking, progressive enhancement, and leaner web architecture — not by going backward, but by merging the best of both worlds.

We’re building apps that are fast, interactive, and SEO-friendly — without overloading the browser.


The Problems SPAs Created

SPAs solved real pain points: smooth routing, rich interactivity, app-like behavior. But they came at a cost:

  • Every <form> had to be hijacked by JavaScript
  • Data fetching got entangled in useEffect, useQuery, and custom hooks
  • Routing became entirely JS-driven
  • Validation was duplicated in frontend and backend
  • Pages stayed blank until hydration completed
  • Massive JS bundles hurt performance and load times
  • SEO suffered without serious workaround
  • Build processes became fragile and overly complex

In trying to build better user experiences, we made development harder and websites slower.


Server-First, Client-Smart

Instead of dumping everything on the client, we’re swinging back — giving the server responsibility for what it's always done best:

  • Routing
  • Validation
  • Data handling
  • Rendering UI

But with a twist: We still use JavaScript — selectively, surgically, and wisely.

This approach is often called progressive enhancement or HTML-over-the-wire. It offers:

  • Faster initial page loads
  • Smaller JS bundles
  • Improved SEO
  • Simpler architecture

The New Balance

This new transition creates a balanced contract:

The server:

  • Accepts and validates form data
  • Handles routing
  • Returns HTML or JSON
  • Manages state and rendering logic

The frontend:

  • Handles interactivity enhancements
  • Submits forms via fetch()
  • Updates DOM elements intelligently
  • Doesn't reimplement server logic

We're not going back to full-page reloads. We’re just letting the browser and server do what they were designed for — with JavaScript filling in the gaps.


A Real-World Example Using fetch()

Let’s say you have a native HTML form. Instead of relying on a full-page reload or wrapping it in React or Vue logic, you can enhance it like this:

                                function enhanceForm({
  formId,
  targetId,
  method = null,
  swap = "innerHTML",
  beforeSend = null,
  onSuccess = null,
  onError = null
}) {
  const form = document.getElementById(formId);
  const target = document.getElementById(targetId);

  if (!form || !target) return;

  form.addEventListener("submit", async function (e) {
    e.preventDefault();
    if (typeof beforeSend === "function") beforeSend(form);

    const formData = new FormData(form);
    const reqMethod = method || form.method || "POST";

    try {
      const response = await fetch(form.action, {
        method: reqMethod.toUpperCase(),
        headers: { 'X-Requested-With': 'fetch' },
        body: formData
      });

      const contentType = response.headers.get("content-type") || "";
      const result = contentType.includes("application/json")
        ? await response.json()
        : await response.text();

      if (response.ok) {
        if (typeof onSuccess === "function") onSuccess(result);
        else applySwap(target, result, swap);
      } else {
        if (typeof onError === "function") onError(result, response.status);
        else applySwap(target, `<strong>Error:</strong> ${response.status}`, swap);
      }
    } catch (err) {
      if (typeof onError === "function") onError(err.message, 0);
      else applySwap(target, `<strong>Network Error:</strong> ${err.message}`, swap);
    }
  });
}

function applySwap(target, content, swap) {
  switch (swap) {
    case "outerHTML": target.outerHTML = content; break;
    case "append": target.insertAdjacentHTML("beforeend", content); break;
    case "prepend": target.insertAdjacentHTML("afterbegin", content); break;
    case "innerHTML":
    default: target.innerHTML = content;
  }
}

enhanceForm("myForm", "result");

                            

Now the server can respond with HTML (or JSON), and the frontend simply swaps it into the DOM.


What Does the Server Do?

In this model, the server:

  • Handles form submissions
  • Performs validation
  • Renders HTML fragments or JSON responses
  • Controls routing and redirection
  • Persist Data

Here’s how that might look in Laravel:

                                public function store(Request $request)
{
    $validated = $request->validate([
        'name' => 'required',
        'email' => 'required|email'
    ]);

    $user = User::create($validated);

    return view('partials.user-card', compact('user'));
}

                            

The frontend then drops this HTML into a specific section of the page.

What Does the Client Do?

While we’ve handed back most responsibilities to the server, the client still plays an essential role — just not everything like in the SPA era.

Here's what the browser is responsible for now:

  • UI Enhancements: The client enhances user experience where needed — like toggling modals, auto-focusing inputs, or handling optimistic UI updates. But it’s no longer overburdened with rendering entire pages.
  • Submitting Forms via JavaScript: Using fetch() or tools like enhanceForm(), the client intercepts form submissions to: Prevent full-page reloads, Show loading indicators, swap in HTML snippets or process JSON
  • DOM Updates: Instead of React’s virtual DOM diffing entire component trees, the client just updates specific parts of the page — like replacing a list, form section, or message area. It’s precise and fast.
  • Handling Client-Only Interactions: Some things still belong to the client — think dropdowns, drag-and-drop, keyboard shortcuts, and local state toggles. These don’t need the server.
  • Making Additional Data Requests: Need more data after the page loads? The client can still use fetch() to talk to APIs — just without wrapping it in giant state libraries or effect chains.

What Stacks Are Doing This?

This isn't just a theory — modern stacks are already embracing it.

JavaScript World:

  • Remix – Form-first, progressive enhancement built-in
  • SolidStart – Progressive-first rendering
  • SvelteKit – Server rendering with smart enhancement

PHP Ecosystem:

  • Laravel Livewire – HTML-over-the-wire with backend logic
  • Inertia.js – Uses Vue/React, but driven by backend routes
  • HTMX – Pure HTML-over-the-wire enhancement
  • Alpine.js – Lightweight interactivity on top of server-rendered HTML

ASP.NET:

  • Blazor Server – Server-driven components with real-time DOM updates
  • Razor Pages + fetch – Very close to this form-enhancement philosophy

Where We’ve Landed

After years of experimentation and overcomplication, we’re returning to a simpler truth:

Backend Responsibilities

  • Data persistence
  • Routing
  • Validation
  • Business logic
  • Rendering

Frontend Responsibilities

  • DOM updates
  • Form enhancements
  • Selective interactivity

Final Words

We didn’t go full circle — we evolved. From monolithic servers, to client-heavy SPAs, and now toward smarter, hybrid applications that blend the strengths of both.

The result? Simpler, faster, more maintainable apps — and a web that feels native again.


If you found this post helpful, consider supporting my work — it means a lot.

 Raheel Shan | Support my work
Raheel Shan | Support my work
Support my work
raheelshan.com
Comments


Comment created and will be displayed once approved.