There are at least two basic approaches to making a vanilla SPA.
Hash router
The strategy is to add a listener to window.onhashchange
(or listen to the hashchange event) which fires whenever the hash in the URL changes from, say, https://www.example.com/#/foo
to https://www.example.com/#/bar
. You can parse the window.location.hash
string to determine the route and inject the relevant content.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
</head>
<body>
<div id="app"></div>
<script>
const nav = `<a href="/#/">Home</a> |
<a href="/#/about">About</a> |
<a href="/#/contact">Contact</a>`;
const routes = {
"": `<h1>Home</h1>${nav}<p>Welcome home!</p>`,
"about": `<h1>About</h1>${nav}<p>This is a tiny SPA</p>`,
};
const render = path => {
document.querySelector("#app")
.innerHTML = routes[path.replace(/^#\//, "")] || `<h1>404</h1>${nav}`;
};
window.onhashchange = evt => render(window.location.hash);
render(window.location.hash);
</script>
</body>
</html>
History API
A modern approach uses the History API which is more natural for the user because no hash character is involved in the URL.
The strategy I used is to add an event listener to all same-domain link clicks. The listener makes a call to window.history.pushState
using the target URL.
"Back" browser events are captured with the popstate
event which parses window.location.href
to invoke the correct route.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
</head>
<body>
<div id="app"></div>
<script>
const nav = `<a href="/">Home</a> |
<a href="/about">About</a> |
<a href="/contact">Contact</a>`;
const routes = {
"/": `<h1>Home</h1>${nav}<p>Welcome home!</p>`,
"/about": `<h1>About</h1>${nav}<p>This is a tiny SPA</p>`,
};
const render = path => {
document.querySelector("#app")
.innerHTML = routes[path] || `<h1>404</h1>${nav}`
;
document.querySelectorAll('[href^="/"]').forEach(el =>
el.addEventListener("click", evt => {
evt.preventDefault();
const {pathname: path} = new URL(evt.target.href);
window.history.pushState({path}, path, path);
render(path);
})
);
};
window.addEventListener("popstate", e =>
render(new URL(window.location.href).pathname)
);
render("/");
</script>
</body>
</html>
The above examples are as minimal as possible. I have a somewhat more full-featured proof of concept on Glitch that adds a component-based system and modules.
If you want to handle more complicated routes, the route-parser
package can save some wheel reinvention.
Without JS
As an aside, there's a trick for making a hash-based SPA without JS, using the :target
CSS pseudoselector to toggle display: none
and display: block
on overlapping, full-screen sections as described in A Whole Website in a Single HTML File and https://john-doe.neocities.org.
html {
height: 100%;
}
body {
margin: 0;
height: 100%;
}
section {
padding: 1em;
padding-top: 2em;
display: none;
position: absolute;
width: 100%;
height: 100%;
background: #fff;
}
nav {
padding: 1em;
position: absolute;
z-index: 99;
}
section:target {
display: block;
}
#home {
display: block;
}
<nav>
<a href="#">Home</a> |
<a href="#about">About</a> |
<a href="#contact">Contact</a>
</nav>
<section id="home">
<h1>Home</h1>
<p>Welcome home!</p>
</section>
<section id="about">
<h1>About</h1>
<p>This is a tiny SPA</p>
</section>
<section id="contact">
<h1>Contact</h1>
<p>Contact page</p>
</section>
#
to avoid page reload andwindow.addEventListener('popstate', ROUTERCALLBACK)
to handle the url change. – Adan