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 (2)

Collapse
 
igorsantos07 profile image
Igor Santos

But if only the body is replaced... What happens with pages that include different stuff in the head tag? I get that it will use JS to update the page title as well?

Collapse
 
amaury_bouchard profile image
Amaury

Good question. µJS doesn't just swap the body: it also merges the <head> of the fetched page with the current one. Scripts and stylesheets already present are not re-added or re-executed. New ones found in the fetched page are added and executed once. The page title is updated automatically.

So if page A loads main.css and page B loads both main.css and gallery.css, navigating from A to B will add gallery.css to the head without touching main.css.