DEV Community

Cover image for Make your server-rendered website feel like an SPA — with 5KB of JavaScript
Amaury
Amaury

Posted on

Make your server-rendered website feel like an SPA — with 5KB of JavaScript

Make your server-rendered website feel like an SPA — with 5KB of JavaScript

You've built a server-rendered website. PHP, Python, Ruby, Go — doesn't matter. It works. It's fast. It's SEO-friendly.

But every link click triggers a full page reload. The browser flashes white. The scroll position resets. Users notice.

The usual answer is: "rewrite everything in React." That's overkill. There's a lighter path.

What µJS does

µJS intercepts link clicks and form submissions, fetches pages in the background with the fetch() API, and swaps the content — without a full page reload. Navigation feels instant. The URL updates. Back/forward buttons work.

That's the core. No framework. No build step. No server changes.

  • ~5 KB gzipped
  • Zero dependencies
  • Works with any backend

Let's walk through a concrete example, step by step.


Step 1: A plain HTML website

Here's a minimal site with two pages and a navigation bar.

index.html:

<!DOCTYPE html>
<html>
<head>
    <title>My site</title>
    <link rel="stylesheet" href="/style.css">
</head>
<body>
    <nav>
        <a href="/">Home</a>
        <a href="/about">About</a>
        <a href="/contact">Contact</a>
    </nav>
    <main id="content">
        <h1>Home</h1>
        <p>Welcome.</p>
    </main>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Clicking "About" reloads the entire page. The nav bar re-renders, the stylesheet re-fetches, the layout flickers.


Step 2: Add µJS

Add one script tag at the end of <body>, and call mu.init():

<body>
    <!-- your content -->
    <script src="https://unpkg.com/@digicreon/mujs/dist/mu.min.js"></script>
    <script>mu.init();</script>
</body>
Enter fullscreen mode Exit fullscreen mode

That's it. Every internal link (URLs starting with /) is now intercepted. Clicking "About" fetches /about via AJAX and swaps the entire <body> — no full reload, no flash, no scroll reset.

The browser history is updated. Back and forward buttons work. Your backend doesn't change.


Step 3: Update only the content area

Swapping the entire <body> works, but if your nav bar is complex (active states, dropdowns…), you may want to update only the <main> block.

Use mu-target and mu-source:

<a href="/about" mu-target="#content" mu-source="#content">About</a>
Enter fullscreen mode Exit fullscreen mode

µJS fetches /about, extracts #content from the response, and replaces the current #content. The nav bar is untouched.

You can also set this globally in mu.init() to avoid repeating it on every link:

mu.init({
    target: "#content",
    source: "#content"
});
Enter fullscreen mode Exit fullscreen mode

Step 4: Handle forms

µJS intercepts form submissions automatically. A GET form behaves like a link:

<form action="/search" method="get"
      mu-target="#content" mu-source="#content">
    <input type="text" name="q" placeholder="Search…">
    <button type="submit">Search</button>
</form>
Enter fullscreen mode Exit fullscreen mode

A POST form works too — data is sent as FormData, and the response replaces the target:

<form action="/comment" method="post">
    <textarea name="body"></textarea>
    <button type="submit">Post comment</button>
</form>
Enter fullscreen mode Exit fullscreen mode

HTML5 validation (required, pattern…) is checked before the request is sent. No extra code needed.


Step 5: Live search

Want search results to update as the user types? Use mu-trigger and mu-debounce:

<input type="text" name="q"
       mu-trigger="change"
       mu-debounce="300"
       mu-url="/search"
       mu-target="#results"
       mu-source="#results"
       mu-mode="update">
Enter fullscreen mode Exit fullscreen mode
  • mu-trigger="change" fires on every keystroke
  • mu-debounce="300" waits 300ms after the user stops typing before sending the request
  • mu-mode="update" replaces the inner content of #results (not the element itself)

Your /search endpoint returns plain HTML. No JSON, no JavaScript on the server side.


Step 6: Update multiple fragments at once (patch mode)

Sometimes one action needs to update several parts of the page: add a comment, increment a counter, clear the form. That would normally require multiple requests or a JSON API.

With patch mode, the server returns a single HTML response with multiple fragments, each annotated with a target:

Link or form:

<form action="/comment" method="post" mu-mode="patch">
    <textarea name="body"></textarea>
    <button>Send</button>
</form>
Enter fullscreen mode Exit fullscreen mode

Server response:

<!-- Append the new comment to the list -->
<div class="comment" mu-patch-target="#comments" mu-patch-mode="append">
    <p>The new comment</p>
</div>

<!-- Update the comment counter -->
<span mu-patch-target="#count">42 comments</span>

<!-- Reset the form -->
<form action="/comment" method="post" mu-patch-target="#comment-form">
    <textarea name="body"></textarea>
    <button>Send</button>
</form>
Enter fullscreen mode Exit fullscreen mode

Three DOM updates, one HTTP request, no JavaScript on the client side.


Step 7: Real-time updates with SSE

Need live updates without polling? µJS supports Server-Sent Events:

<div mu-trigger="load"
     mu-url="/notifications/stream"
     mu-method="sse"
     mu-mode="patch">
</div>
Enter fullscreen mode Exit fullscreen mode

The server pushes HTML fragments over the SSE connection. µJS renders them using patch mode. No WebSocket setup, no client-side message parsing.


Bonus: prefetch on hover

Prefetch is enabled by default. When the user hovers over a link for 50ms, µJS starts fetching the page in the background. By the time they click, the content is ready. This typically saves 100–300ms of perceived loading time — for free.

Disable it per-link if needed:

<a href="/heavy-page" mu-prefetch="false">Heavy page</a>
Enter fullscreen mode Exit fullscreen mode

What you get without changing your backend

Feature How
AJAX navigation mu.init()
Fragment update mu-target + mu-source
Live search mu-trigger + mu-debounce
Multi-fragment update mu-mode="patch"
Real-time push mu-method="sse"
Prefetch on hover Enabled by default
Progress bar Enabled by default
View Transitions Enabled by default
DOM morphing Auto-detected (idiomorph)
Back/forward buttons Built-in

Try it

npm install @digicreon/mujs
Enter fullscreen mode Exit fullscreen mode

Or via CDN:

<script src="https://unpkg.com/@digicreon/mujs/dist/mu.min.js"></script>
Enter fullscreen mode Exit fullscreen mode

µJS is MIT licensed, ~5KB gzipped, zero dependencies. Carson Gross (creator of htmx) listed it on the htmx alternatives page.

If you have questions about design decisions or how it compares to HTMX/Turbo, happy to discuss in the comments.

Top comments (0)