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>
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>
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>
µ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"
});
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>
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>
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">
-
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>
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>
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>
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>
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
- Live playground — test each feature interactively, with the page HTML, server response, and live result side by side
- Documentation
- GitHub
npm install @digicreon/mujs
Or via CDN:
<script src="https://unpkg.com/@digicreon/mujs/dist/mu.min.js"></script>
µ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)