Write Your Own Enhanced/AJAX Navigation Library
Lee Timmins
3rd Mar 2026
Comments
Before Enhanced Navigation was added to ASP.NET Core Blazor in .NET 8, I built a similar concept that I called AJAX Navigation.
My original version was not especially clean. It relied on overriding the built-in JavaScript event handling logic to ensure my form submission and anchor click handlers were registered last. That allowed the default handlers to prevent my handler from running.
You can see the Blazor implementation here: https://github.com/dotnet/aspnetcore/blob/main/src/Components/Web.JS/src/Services/NavigationEnhancement.ts.
The key difference is that it uses event delegation rather than the workaround I originally used.
Below is a framework-agnostic implementation that borrows that same idea.
Basic Structure
Create a file called ajax-navigation.js:
import morphdom from 'https://cdn.jsdelivr.net/npm/morphdom@2.7.8/+esm';
// Set the current url.
let currentUrl = window.location.href;
/// Events
document.addEventListener('click', e => {
// Get the closest anchor, incase they clicked an element inside the anchor, e.g. span.
const element = e.target.closest('a');
if (!element || !isEnabled(e))
return;
performAjaxPageLoad(element.href);
});
document.addEventListener('submit', e => {
if (!(e.target instanceof HTMLFormElement) || !isEnabled(e))
return;
performAjaxPageLoad(e.target.action, {
method: e.target.method,
body: new FormData(e.target)
});
});
window.addEventListener('popstate', e =>
performAjaxPageLoad(window.location.href, {}, e.state ?? {})
);
/// Public API
async function performAjaxPageLoad(url, options = {}, popstate = null) {
const response = await fetch(url, options);
// Make sure the response is valid.
if (!response.ok)
return;
// The response url may be different than the request url, if the server issued a redirect.
const finalUrl = response.url;
// Only modify history if this is NOT a popstate navigation.
if (popstate === null && finalUrl !== currentUrl)
window.history.pushState({ ajaxNavigation: true }, '', finalUrl);
// Set the current url.
currentUrl = finalUrl;
const contentType = response.headers.get('content-type') || '';
// If not a HTML response do a full page load.
if (!contentType.startsWith('text/html')) {
window.location.href = url;
return;
}
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// Set the title.
document.title = doc.title;
// Morph the DOM.
morphdom(document.body, doc.body, {
getNodeKey: node => {
if (node.nodeType !== Node.ELEMENT_NODE)
return;
// This makes sure script tags are not reused, which would cause them to not execute.
if (node.tagName === 'SCRIPT')
return node.outerHTML;
}
});
}
/// Helpers
function isEnabled(e) {
// Make sure the default action hasn't already been prevented.
if (e.defaultPrevented)
return false;
// Prevent the default action.
e.preventDefault();
return true;
}
function loadScript(script) {
// Create the new script.
const newScript = document.createElement('script');
// Prevent the new script from getting stuck in an infinite loop.
// Since the mutation observer will monitor for it.
newScript.dataset.ignoreNew = true;
// Add the attributes.
Array.from(script.attributes).forEach(a => newScript.setAttribute(a.name, a.value));
// Set the inner HTML.
newScript.appendChild(
document.createTextNode(script.textContent)
);
// Replace the old script with the new script tag.
script.parentNode.replaceChild(newScript, script);
}
// Create a MutationObserver.
const observer = new MutationObserver(mutations => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE)
continue;
// Check if the added node is a script tag.
if (node.tagName?.toLowerCase() === 'script' && node.dataset.ignoreNew !== 'true')
loadScript(node);
else
node.querySelectorAll('script').forEach(scriptNode => loadScript(scriptNode));
}
}
});
// Start observing changes in the document.
observer.observe(document.body, { childList: true, subtree: true });
Reference it on every page where you want enhanced navigation:
<script type="module" src="/js/ajax-navigation.js"></script>
This gives you:
- DOM merging via morphdom
- Automatic script re-execution using the MutationObserver API
- History API support
- Click and form interception
It can be extended to handle:
- Downloads, bookmarks, and external links
- Anchor/form targets other than _self
- method="dialog" forms
- Form submitter logic
- Scroll restoration for back/forward
- Additional content types
The goal here is to establish the structure without adding unnecessary complexity.
Adding petite-vue for Interactivity
Since this version does not rely on a server-side framework, we can use petite-vue for client-side interactivity.
Add the following at the top of ajax-navigation.js:
import { createApp } from 'https://unpkg.com/petite-vue?module'
// Create and mount the app.
const app = createApp();
app.mount();
After loading scripts inside the MutationObserver, add:
app.mount();
Registering Custom petite-vue Components
To allow page-level component registration, introduce a registry by modifying the createApp initialisation as following:
const registry = {};
// Create and mount the app.
const app = createApp(registry);
app.mount();
export function register(name, factory) {
registry[name] = factory;
}
Example page usage:
<script type="module" src="/js/page.js"></script>
<div v-scope="PageCounter({ initialCount: 5 })">
<span v-text="count"></span>
<button v-on:click="inc">inc</button>
</div>
page.js:
import { register } from './ajax-navigation.js';
register('PageCounter', props => ({
count: props.initialCount,
inc() {
this.count++;
}
}));
This does not work because the script may not have executed before petite-vue attempts to mount the component. To ensure scripts execute before mounting, update loadScript and the MutationObserver so they wait for execution to complete:
function loadScript(script) {
return new Promise((resolve, reject) => {
// If the script is not connected to the DOM, then it won't execute, so just resolve.
if (!script.isConnected) {
resolve();
return;
}
const newScript = document.createElement('script');
// Copy all attributes.
for (const { name, value } of script.attributes) {
newScript.setAttribute(name, value);
}
const isModule = newScript.type === 'module';
const hasSrc = newScript.hasAttribute('src');
if (hasSrc || isModule) {
// External or inline module converted to blob.
if (isModule && !hasSrc) {
const code = script.textContent;
const blob = new Blob([code], { type: 'text/javascript' });
newScript.src = URL.createObjectURL(blob);
newScript.onload = () => {
URL.revokeObjectURL(newScript.src);
resolve();
};
newScript.onerror = reject;
} else {
newScript.onload = resolve;
newScript.onerror = reject;
}
} else {
newScript.textContent = script.textContent;
resolve();
}
document.head.appendChild(newScript);
script.remove();
});
}
// Create a MutationObserver.
const observer = new MutationObserver(async mutations => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE)
continue;
// Check if the added node is a script tag.
if (node.tagName?.toLowerCase() === 'script')
await loadScript(node);
else
await Promise.all(Array.from(node.querySelectorAll('script'))
.map(scriptNode => loadScript(scriptNode)));
// Mount only after scripts have executed.
app.mount(node);
}
}
});
The above works for enhanced AJAX navigation but not for full page loads. The issue is that the application is created before page.js initially runs.
Instead of creating the app immediately, we need to defer its creation until the end of the page.
Replace the application create part with the following:
let app = null;
export function start() {
app = createApp(registry);
app.mount();
}
Then, before the closing <body> tag on every page that references ajax-navigation.js, add:
<script type="module">
import { start } from '/js/ajax-navigation.js';
start();
</script>
Now:
- On a full page load, page.js runs first and registers components.
- start() runs afterward, creating the app with the fully populated registry.
- On enhanced AJAX navigation, mounting still works correctly.
This ensures consistent behavior across both navigation modes.
One remaining concern is that petite-vue is tightly coupled to this library. Not all projects may use AJAX Navigation, but they may still use petite-vue. Extracting that integration into a separate module would make the design cleaner. That will be addressed in a follow-up post.