JavaScript Development Space

Build a Single-Page Application(SPA) Router in Vanilla JavaScript

Add to your RSS feed3 February 20258 min read
Build a Single-Page Application(SPA) Router in Vanilla JavaScript

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:

js
1 window.location.hash = "#newSection";

2. Anchor Links in HTML:

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

js
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:

js
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:

js
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:

js
1 (function (history) {
2 var originalPushState = history.pushState;
3 var originalReplaceState = history.replaceState;
4
5 history.pushState = function (state) {
6 if (typeof history.onpushstate === "function") {
7 history.onpushstate({ state });
8 }
9 return originalPushState.apply(history, arguments);
10 };
11
12 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);
19
20 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:

js
1 class Router {
2 constructor(routes) {
3 this.routes = routes;
4 this.rootElem = document.getElementById('app');
5
6 // Handle initial route
7 this.handleRoute();
8
9 // Listen for route changes
10 window.addEventListener('popstate', () => this.handleRoute());
11 }
12
13 handleRoute() {
14 const path = window.location.pathname;
15 const route = this.routes[path] || this.routes['/404'];
16
17 this.rootElem.innerHTML = route.template;
18 document.title = route.title;
19 }
20
21 navigate(path) {
22 window.history.pushState({}, '', path);
23 this.handleRoute();
24 }
25 }

Route Configuration

Define your routes with corresponding templates and metadata:

js
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 };
15
16 // Initialize router
17 const router = new Router(routes);

Handling Navigation Events

To create a seamless navigation experience, we need to intercept link clicks:

js
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:

js
1 class Router {
2 // ... previous code ...
3
4 parseRoute(path) {
5 const routes = Object.keys(this.routes);
6 return routes.find(route => {
7 const routeParts = route.split('/');
8 const pathParts = path.split('/');
9
10 if (routeParts.length !== pathParts.length) return false;
11
12 return routeParts.every((part, i) => {
13 return part.startsWith(':') || part === pathParts[i];
14 });
15 });
16 }
17
18 getParams(route, path) {
19 const params = {};
20 const routeParts = route.split('/');
21 const pathParts = path.split('/');
22
23 routeParts.forEach((part, i) => {
24 if (part.startsWith(':')) {
25 const paramName = part.slice(1);
26 params[paramName] = pathParts[i];
27 }
28 });
29
30 return params;
31 }
32
33 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);
38
39 const content = typeof route.template === 'function'
40 ? route.template(params)
41 : route.template;
42
43 this.rootElem.innerHTML = content;
44 document.title = route.title;
45 }
46 }

State Management

For more complex applications, we can add state management capabilities:

js
1 class Router {
2 constructor(routes) {
3 this.state = {};
4 // ... previous initialization code ...
5 }
6
7 setState(newState) {
8 this.state = { ...this.state, ...newState };
9 this.handleRoute(); // Re-render current route with new state
10 }
11
12 getState() {
13 return { ...this.state };
14 }
15 }

Example Usage

Here's a complete example bringing everything together:

js
1 // Define routes with dynamic parameters
2 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 };
26
27 // Initialize router
28 const router = new Router(routes);
29
30 // Add some global state
31 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 📌

bash
1 /spa-project
2 │── index.html
3 │── style.css
4 └── app.js

index.html (Main HTML file) 📄

html
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>
10
11 <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>
16
17 <div id="app">
18 <!-- Dynamic content will be injected here -->
19 </div>
20
21 <script src="app.js"></script>
22 </body>
23 </html>

style.css (Basic Styling) 🎨

css
1 body {
2 font-family: Arial, sans-serif;
3 margin: 0;
4 padding: 0;
5 text-align: center;
6 }
7
8 nav {
9 background-color: #333;
10 padding: 10px;
11 }
12
13 nav a {
14 color: white;
15 text-decoration: none;
16 margin: 0 15px;
17 }
18
19 nav a:hover {
20 text-decoration: underline;
21 }
22
23 #app {
24 margin-top: 20px;
25 font-size: 20px;
26 }

app.js (The SPA Router) 🚀

js
1 // SPA Router - Handles navigation and rendering
2 class Router {
3 constructor() {
4 this.routes = {}; // Stores route-to-handler mapping
5 this.mode = "history"; // Can be "hash" or "history"
6 this.root = "/"; // Base path
7
8 // Bind link clicks
9 document.addEventListener("click", (event) => {
10 if (event.target.matches("[data-link]")) {
11 event.preventDefault();
12 this.navigate(event.target.getAttribute("href"));
13 }
14 });
15
16 // Handle back/forward buttons
17 window.addEventListener("popstate", () => this.resolveRoute());
18
19 // Initial route handling
20 this.resolveRoute();
21 }
22
23 // Define a new route
24 add(route, handler) {
25 this.routes[route] = handler;
26 }
27
28 // Navigate to a new route
29 navigate(path) {
30 if (this.mode === "history") {
31 history.pushState(null, "", path);
32 } else {
33 location.hash = path;
34 }
35 this.resolveRoute();
36 }
37
38 // Determine the current route and load content
39 resolveRoute() {
40 let path = this.mode === "history"
41 ? location.pathname.replace(this.root, "") || "/"
42 : location.hash.replace("#", "") || "/";
43
44 let handler = this.routes[path] || this.routes["/404"];
45 document.getElementById("app").innerHTML = handler();
46 }
47 }
48
49 // Initialize Router
50 const router = new Router();
51
52 // Define routes
53 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() or location.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 or sessionStorage 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.

JavaScript Development Space

© 2025 JavaScript Development Space - Master JS and NodeJS. All rights reserved.