Build a Single-Page Application(SPA) Router in Vanilla JavaScript
Add to your RSS feed3 February 20258 min readTable of Contents
Single-page applications (SPAs) have revolutionized web development by offering smoother user experiences with seamless navigation. At the heart of every SPA lies a routing system that manages page transitions without full page reloads. In this guide, we'll explore how to build a robust routing system using vanilla JavaScript, understanding the core concepts and implementing them from scratch.
In a Single Page Application (SPA), routing is primarily about managing browser URLs and rendering the corresponding views. There are two common routing modes: Hash Mode and History Mode.
Hash Mode
This mode utilizes the hashchange event to update the page content without requiring a full refresh.
What is the hash property?
The hash
property of a URL contains a fragment identifier starting with #
. This fragment is not sent to the server, meaning hash-based navigation is purely client-side.
Key Features:
- Client-side only: The hash is not sent to the server.
- No page reload: Changing the hash does not trigger a page refresh.
- Event-driven updates: The hashchange event can be used to detect hash modifications.
How Hash Mode Works
Hash-based navigation depends on the window.hashchange
event.
Triggers:
1. JavaScript Modification:
1 window.location.hash = "#newSection";
2. Anchor Links in HTML:
1 <a href="#section2">Go to Section 2</a>
3. Browser Navigation Buttons: Clicking the back or forward button updates the hash and triggers hashchange
.
4. Using location.assign()
or location.replace()
:
1 location.assign("#anotherSection");2 location.replace("#anotherSection");
Note: history.pushState() does not trigger the hashchange event, even if the hash changes.
History Mode
History mode leverages the browser's history API to update the URL without a page refresh. It uses the window.popstate
event to detect navigation changes.
History API Overview
The History
object provides methods to manipulate session history, including navigating back and forth.
Key Methods:
1. Navigation:
1 history.back(); // Equivalent to history.go(-1)2 history.forward(); // Equivalent to history.go(1)3 history.go(-2); // Moves two steps back
2. Managing History Entries:
history.pushState(data, title, url)
: Adds a new entry to the history stack without refreshing.history.replaceState(data, title, url)
: Replaces the current entry without refreshing.
Example:
1 history.pushState({ page: 1 }, "Page 1", "/page1");2 history.replaceState({ page: 2 }, "Page 2", "/page2");
Note: Unlike hashchange
, pushState
and replaceState
do not trigger the popstate event.
Monitoring pushState
and replaceState
Since pushState
and replaceState
do not trigger events by default, we can override them:
1 (function (history) {2 var originalPushState = history.pushState;3 var originalReplaceState = history.replaceState;45 history.pushState = function (state) {6 if (typeof history.onpushstate === "function") {7 history.onpushstate({ state });8 }9 return originalPushState.apply(history, arguments);10 };1112 history.replaceState = function (state) {13 if (typeof history.onreplacestate === "function") {14 history.onreplacestate({ state });15 }16 return originalReplaceState.apply(history, arguments);17 };18 })(window.history);1920 window.onpopstate =21 history.onpushstate =22 history.onreplacestate =23 function (event) {24 console.log("State changed:", event.state);25 };
Hash Mode vs. History Mode
1. URL Structure:
- Hash Mode: The URL contains a
#
, such as/page#1
. - History Mode: Uses a clean, traditional URL format like
/page1
.
2. SEO
- Hash Mode: Not SEO-friendly, as search engines may not index hash fragments properly.
- History Mode: Better for SEO since URLs are structured like traditional web pages.
3. Ease of Use
- Hash Mode: Simple to implement and does not require server configuration.
- History Mode: More complex, requiring server-side configuration to handle URL requests correctly.
4. Page Refresh
- Hash Mode: No special handling is needed; the page remains intact when navigating.
- History Mode: Requires server redirection to prevent "404 Not Found" errors when users refresh the page.
Using history mode is generally preferred for better user experience and SEO, but hash mode remains a simpler option when server-side support is limited.
Building a Basic Router
Let's start by implementing a simple router class that handles route definitions and navigation:
1 class Router {2 constructor(routes) {3 this.routes = routes;4 this.rootElem = document.getElementById('app');56 // Handle initial route7 this.handleRoute();89 // Listen for route changes10 window.addEventListener('popstate', () => this.handleRoute());11 }1213 handleRoute() {14 const path = window.location.pathname;15 const route = this.routes[path] || this.routes['/404'];1617 this.rootElem.innerHTML = route.template;18 document.title = route.title;19 }2021 navigate(path) {22 window.history.pushState({}, '', path);23 this.handleRoute();24 }25 }
Route Configuration
Define your routes with corresponding templates and metadata:
1 const routes = {2 '/': {3 template: '<h1>Home Page</h1><p>Welcome to our SPA!</p>',4 title: 'Home'5 },6 '/about': {7 template: '<h1>About Us</h1><p>Learn about our spa website</p>',8 title: 'About'9 },10 '/404': {11 template: '<h1>Page Not Found</h1><p>Sorry, the page you requested does not exist.</p>',12 title: '404 - Not Found'13 }14 };1516 // Initialize router17 const router = new Router(routes);
Handling Navigation Events
To create a seamless navigation experience, we need to intercept link clicks:
1 document.addEventListener('click', (e) => {2 if (e.target.matches('[data-link]')) {3 e.preventDefault();4 router.navigate(e.target.href);5 }6 });
Adding Dynamic Routes
Let's enhance our router to support dynamic route parameters:
1 class Router {2 // ... previous code ...34 parseRoute(path) {5 const routes = Object.keys(this.routes);6 return routes.find(route => {7 const routeParts = route.split('/');8 const pathParts = path.split('/');910 if (routeParts.length !== pathParts.length) return false;1112 return routeParts.every((part, i) => {13 return part.startsWith(':') || part === pathParts[i];14 });15 });16 }1718 getParams(route, path) {19 const params = {};20 const routeParts = route.split('/');21 const pathParts = path.split('/');2223 routeParts.forEach((part, i) => {24 if (part.startsWith(':')) {25 const paramName = part.slice(1);26 params[paramName] = pathParts[i];27 }28 });2930 return params;31 }3233 handleRoute() {34 const path = window.location.pathname;35 const matchedRoute = this.parseRoute(path);36 const route = this.routes[matchedRoute] || this.routes['/404'];37 const params = this.getParams(matchedRoute, path);3839 const content = typeof route.template === 'function'40 ? route.template(params)41 : route.template;4243 this.rootElem.innerHTML = content;44 document.title = route.title;45 }46 }
State Management
For more complex applications, we can add state management capabilities:
1 class Router {2 constructor(routes) {3 this.state = {};4 // ... previous initialization code ...5 }67 setState(newState) {8 this.state = { ...this.state, ...newState };9 this.handleRoute(); // Re-render current route with new state10 }1112 getState() {13 return { ...this.state };14 }15 }
Example Usage
Here's a complete example bringing everything together:
1 // Define routes with dynamic parameters2 const routes = {3 '/': {4 template: '<h1>Home</h1><nav><a href="/users" data-link>Users</a></nav>',5 title: 'Home'6 },7 '/users': {8 template: (params) => `9 <h1>Users</h1>10 <ul>11 <li><a href="/users/1" data-link>User 1</a></li>12 <li><a href="/users/2" data-link>User 2</a></li>13 </ul>14 `,15 title: 'Users'16 },17 '/users/:id': {18 template: (params) => `19 <h1>User Profile</h1>20 <p>User ID: ${params.id}</p>21 <a href="/users" data-link>Back to Users</a>22 `,23 title: 'User Profile'24 }25 };2627 // Initialize router28 const router = new Router(routes);2930 // Add some global state31 router.setState({32 isAuthenticated: true,33 user: { name: 'John Doe' }34 });
Full implementation of a simple SPA (Single Page Application)
This example provides a human-friendly and clean approach to SPA development.
Project Structure 📌
1 /spa-project2 │── index.html3 │── style.css4 └── app.js
index.html (Main HTML file) 📄
1 <!DOCTYPE html>2 <html lang="en">3 <head>4 <meta charset="UTF-8">5 <meta name="viewport" content="width=device-width, initial-scale=1.0">6 <title>Simple SPA</title>7 <link rel="stylesheet" href="style.css">8 </head>9 <body>1011 <nav>12 <a href="/" data-link>Home</a>13 <a href="/about" data-link>About</a>14 <a href="/contact" data-link>Contact</a>15 </nav>1617 <div id="app">18 <!-- Dynamic content will be injected here -->19 </div>2021 <script src="app.js"></script>22 </body>23 </html>
style.css (Basic Styling) 🎨
1 body {2 font-family: Arial, sans-serif;3 margin: 0;4 padding: 0;5 text-align: center;6 }78 nav {9 background-color: #333;10 padding: 10px;11 }1213 nav a {14 color: white;15 text-decoration: none;16 margin: 0 15px;17 }1819 nav a:hover {20 text-decoration: underline;21 }2223 #app {24 margin-top: 20px;25 font-size: 20px;26 }
app.js (The SPA Router) 🚀
1 // SPA Router - Handles navigation and rendering2 class Router {3 constructor() {4 this.routes = {}; // Stores route-to-handler mapping5 this.mode = "history"; // Can be "hash" or "history"6 this.root = "/"; // Base path78 // Bind link clicks9 document.addEventListener("click", (event) => {10 if (event.target.matches("[data-link]")) {11 event.preventDefault();12 this.navigate(event.target.getAttribute("href"));13 }14 });1516 // Handle back/forward buttons17 window.addEventListener("popstate", () => this.resolveRoute());1819 // Initial route handling20 this.resolveRoute();21 }2223 // Define a new route24 add(route, handler) {25 this.routes[route] = handler;26 }2728 // Navigate to a new route29 navigate(path) {30 if (this.mode === "history") {31 history.pushState(null, "", path);32 } else {33 location.hash = path;34 }35 this.resolveRoute();36 }3738 // Determine the current route and load content39 resolveRoute() {40 let path = this.mode === "history"41 ? location.pathname.replace(this.root, "") || "/"42 : location.hash.replace("#", "") || "/";4344 let handler = this.routes[path] || this.routes["/404"];45 document.getElementById("app").innerHTML = handler();46 }47 }4849 // Initialize Router50 const router = new Router();5152 // Define routes53 router.add("/", () => `<h1>🏡 Home</h1><p>Welcome to the home page!</p>`);54 router.add("/about", () => `<h1>📖 About</h1><p>This is a simple SPA built in JavaScript.</p>`);55 router.add("/contact", () => `<h1>📞 Contact</h1><p>Feel free to reach out!</p>`);56 router.add("/404", () => `<h1>❌ 404</h1><p>Page not found.</p>`);
ow It Works 🎯
1. Dynamic Routing
- Routes (
/
,/about
,/contact
) are mapped to handlers that return HTML content.
2. Navigation Without Reloading
- Clicking a
<a>
link prevents the default behavior. - The
history.pushState()
orlocation.hash
updates the URL.
3. Rendering Content Dynamically
- The
#app
div is updated based on the current route.
4. Back & Forward Button Support
- The
popstate
event ensures proper navigation.
✅ Run the SPA
- Just open index.html in a browser.
- Click the links to navigate.
- Try using Back and Forward buttons.
🏁 Summary
✅ No page reloads
✅ Works with history & hash mode
✅ Simple yet powerful
This is a clean and easy implementation of an SPA router. Want to add AJAX, animations, or state management? You can easily extend this!
🔥 Next Steps:
- Add API fetching (e.g.,
fetch()
to load dynamic content). - Implement components for a more modular structure.
- Use
localStorage
orsessionStorage
for state persistence.
Conclusion
Building a custom router for your SPA using vanilla JavaScript provides complete control over navigation and state management. While modern frameworks offer sophisticated routing solutions, understanding the underlying principles helps you make better architectural decisions and troubleshoot issues more effectively.