Building Simple SPAs with Vanilla JavaScript
Reviewed & published by Brayan K
Master single-page application architecture using pure JavaScript — routing, views, state management, and professional patterns.
Part of the free JavaScript course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
What is a Single Page Application (SPA)?
A Single Page Application is a web app where the page loads once and navigation happens client-side. New content is injected into the page dynamically without browser reloads.
Traditional Multi-Page
- • Click link → server sends new HTML
- • Whole screen reloads
- • State/UI resets on navigation
- • Slower perceived performance
SPA Approach
- • One HTML file loads once
- • JavaScript injects content
- • No flicker, no reload
- • App-like experience
💡 Real-World SPAs: YouTube, Instagram, Twitter, and most dashboard apps use SPA architecture. Building SPAs with pure JavaScript helps you understand how frameworks like React and Vue work internally.
Core SPA Principles
To build your own SPA, you need these components:
1. Routing System
Determines which page/view to show based on URL
2. Views/Templates
HTML content for each page
3. Rendering Logic
Show/hide or inject templates
5. State Handling
Keep track of app data
6. Event Delegation
Handle events on dynamic DOM
Two Main Routing Strategies
1. Hash-based Routing (#home, #about)
Uses the URL hash (#). Doesn't reload the page and works everywhere. Easiest to implement.
// Hash routing example
// URL: https://example.com/#/profile
// Detect hash changes
window.addEventListener("hashchange", () => {
const hash = location.hash.slice(1); // Remove the #
console.log("Current route:", hash);
handleRoute(hash);
});
function handleRoute(route) {
switch(route) {
case "/home": showHome(); break;
case "/about": showAbout(); break;
default: show404();
}
}2. History API Routing (/home, /about)
Clean URLs without hashes. Uses pushState() and popstate. Requires server configuration.
// History API routing
// URL: https://example.com/profile
// Navigate programmatically
function navigateTo(path) {
history.pushState(null, "", path);
handleRoute(path);
}
// Handle browser back/forward
window.addEventListener("popstate", () => {
const path = location.pathname;
handleRoute(path);
});
// Use with links
document.addEventListener("click", (e) => {
if (e.target.matches("a[data-link]")) {
e.preventDefault();
navigateTo(e.target.href);
}
});💡 Which to Choose: For beginners, use hash routing. For production apps, History API gives cleaner URLs but requires server rewrites to serve index.html for all routes.
Basic SPA Folder Structure
// Recommended SPA folder structure:
/*
/spa
index.html // Single HTML file
app.js // Main entry point
router.js // Routing logic
views/
home.js // Home page view
about.js // About page view
profile.js // Profile page view
components/
Button.js // Reusable components
Card.js
services/
api.js // API calls
styles.css // Global styles
*/
// This structure keeps code organized as the app scales
// Each view is a separate module that can be lazy-loadedDefining Views (Each Page)
Each page is a function that returns HTML (as a string or DOM elements).
Building a Simple Hash Router
Create a router that loads views based on the URL hash:
// router.js
import { Home } from "./views/home.js";
import { About } from "./views/about.js";
import { Profile } from "./views/profile.js";
const routes = {
"/": Home,
"/about": About,
"/profile": Profile
};
function router() {
// Get hash without the # symbol, default to "/"
const hash = location.hash.replace("#", "") || "/";
// Find the matching view
const view = routes[hash];
// Render the view (or 404)
if (view) {
document.getElementById("app").innerHTML = view();
} else {
document.getElementById("app").innerHTML = "<h1>404 - Page Not Found</h1>";
}
}
// Listen for hash changes
window.addEventListener("hashchange", router);
// Run on page load
window.addEventListener("load", router);
// Now navigating:
// #/ → Home()
// #/about → About()
// #/profile → Profile()Navigation Links
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
<title>My SPA</title>
</head>
<body>
<!-- Navigation -->
<nav>
<a href="#/">Home</a>
<a href="#/about">About</a>
<a href="#/profile">Profile</a>
</nav>
<!-- App container - views render here -->
<div id="app"></div>
<!-- Load as ES module -->
<script type="module" src="app.js"></script>
</body>
</html>
<!-- Click any link:
→ hash changes
→ router triggers
→ view updates
→ NO page reload! -->Event Delegation (Critical for SPAs)
Since the DOM is replaced on navigation, you can't attach events directly to elements that might disappear.
// ❌ WRONG - Element might not exist after navigation
document.getElementById("btn").onclick = () => {
console.log("Clicked!");
};
// ✅ CORRECT - Use event delegation
document.addEventListener("click", (e) => {
// Check if clicked element matches our selector
if (e.target.matches("#btn")) {
console.log("Button clicked!");
}
// Handle navigation links
if (e.target.matches("[data-link]")) {
e.preventDefault();
const path = e.target.getAttribute("href");
location.hash = path;
}
// Handle delete buttons
if (e.target.matches(".delete-btn")) {
const id = e.target.dataset.id;
deleteItem(id);
}
});
// This ensures events work even after navigation
// because we listen on document, not the element💡 Why This Matters: Event delegation is essential for SPAs because the DOM changes frequently. By listening on a parent element (like document), your handlers automatically work for dynamically inserted elements.
State Management Basics
SPAs need to track application state (user data, UI state, etc.) and re-render when it changes.
Route Parameters (#/user/123)
Real apps need dynamic routes. Here's how to add parameter support:
Worked Example — The Whole Routing Engine, Running
The examples above need a real page and a real address bar, which makes them hard to poke at while you are learning. This one does not. It is the same routing logic — route table, pattern matching, parameters, 404, history — with console.log standing in for the DOM and a plain array standing in for the browser's history stack.
Read the comments first, then press Run. Two lines are marked to show exactly where the real browser APIs slot in: app.innerHTML = html and history.pushState(...).
// -- WORKED EXAMPLE - the routing engine behind every SPA --
// A single-page app never reloads. When the URL changes it finds the matching
// view, builds some HTML, and swaps it into one container. Everything below is
// that logic, with console.log standing in for the DOM so you can watch it run
// here. In a real page, the two marked lines become innerHTML and pushState.
// 1) THE ROUTE TABLE. Each pattern maps to a function that returns HTML.
// A segment starting with ":" is a placeholder that matches anything.
const routes = {
"/": () => "<h1>Home</h1>",
"/about": () => "<h1>About</h1><p>Two people and a laptop.</p>",
"/users/:id": (params) => "<h1>User " + params.id + "</h1>",
"/users/:id/settings": (params) => "<h1>Settings for user " + params.id + "</h1>"
};
const notFound = (params) => "<h1>404</h1><p>No route for " + params.path + "</p>";
// 2) MATCHING. Split the pattern and the real path on "/" and compare them
// segment by segment. Returns the captured params on a hit, or null on a miss.
function matchRoute(pattern, path) {
const patternParts = pattern.split("/");
const pathParts = path.split("/");
if (patternParts.length !== pathParts.length) return null; // different depth = no match
const params = {};
for (let i = 0; i < patternParts.length; i++) {
const part = patternParts[i];
if (part.startsWith(":")) {
params[part.slice(1)] = pathParts[i]; // ":id" captures into params.id
} else if (part !== pathParts[i]) {
return null; // a literal segment did not match
}
}
return params; // {} means "matched, no params"
}
// 3) THE ROUTER. Try each pattern in order; the first match wins.
function resolve(path) {
for (const pattern of Object.keys(routes)) {
const params = matchRoute(pattern, path);
if (params) return { pattern: pattern, html: routes[pattern](params) };
}
return { pattern: "(none)", html: notFound({ path: path }) };
}
// 4) NAVIGATION. In a browser, historyStack.push becomes history.pushState and
// the console.log of html becomes app.innerHTML = html.
const historyStack = [];
function navigate(path) {
historyStack.push(path); // <- pushState does this for real
const view = resolve(path);
console.log(path + " -> " + view.pattern);
console.log(" " + view.html); // <- innerHTML = view.html, for real
}
navigate("/");
navigate("/about");
navigate("/users/42");
navigate("/users/42/settings");
navigate("/basket"); // nothing matches this one
// The back button, in two lines: drop the current entry and re-render the one
// underneath. A real app listens for the browser's "popstate" event instead.
historyStack.pop();
const previous = historyStack[historyStack.length - 1];
console.log("back -> " + previous);
console.log(" " + resolve(previous).html);
// Expected output:
// / -> /
// <h1>Home</h1>
// /about -> /about
// <h1>About</h1><p>Two people and a laptop.</p>
// /users/42 -> /users/:id
// <h1>User 42</h1>
// /users/42/settings -> /users/:id/settings
// <h1>Settings for user 42</h1>
// /basket -> (none)
// <h1>404</h1><p>No route for /basket</p>
// back -> /users/42/settings
// <h1>Settings for user 42</h1>Notice the second column of the output: /users/42 resolves to the pattern /users/:id. Matching a path to a pattern and pulling out the parameters is the only genuinely tricky part of a router. Everything else is bookkeeping.
🎯 Your Turn — Finish the Matcher
Three blanks, all in the part that does the real work. If you can write this matcher from memory, you understand routing better than most people who have used a router for years.
// 🎯 YOUR TURN - finish the router
// Three blanks, all inside the matcher. Everything else is written for you.
const routes = {
"/": () => "Home page",
"/products": () => "All products",
"/products/:sku": (params) => "Product " + params.sku
};
function matchRoute(pattern, path) {
const patternParts = pattern.split("/");
const pathParts = path.split("/");
if (patternParts.length !== pathParts.length) return null; // different depth
const params = {};
for (let i = 0; i < patternParts.length; i++) {
const part = patternParts[i];
if (part.startsWith(":")) {
// 👉 1) Capture this segment. 'part' looks like ":sku", so the parameter
// NAME is 'part' without its first character.
// Hint: "abc".slice(1) gives "bc".
params[___] = pathParts[i];
} else if (___) {
// 👉 2) This is a literal segment such as "products". If it does not
// equal the same segment of the real path, this route is wrong.
return null;
}
}
return params; // an object (possibly empty) means "matched"
}
function resolve(path) {
for (const pattern of Object.keys(routes)) {
const params = matchRoute(pattern, path);
// 👉 3) matchRoute gives null on a miss and an object on a hit.
// Only build the view when it actually matched.
if (___) return routes[pattern](params);
}
return "404 - no route for " + path;
}
["/", "/products", "/products/ABC-9", "/products/ABC-9/reviews"].forEach(p => {
console.log(p + " -> " + resolve(p));
});
// ✅ Expected output:
// / -> Home page
// /products -> All products
// /products/ABC-9 -> Product ABC-9
// /products/ABC-9/reviews -> 404 - no route for /products/ABC-9/reviewsBlank 3 has a trap in it. On a match, matchRoute can return {} — an empty object, because the route had no parameters. An empty object is truthy in JavaScript, so a plain truthiness check works; but if you were tempted to test Object.keys(params).length, the home route would 404.
Lazy Loading Routes (Dynamic Imports)
Load pages only when needed for massive performance gains:
// Instead of importing everything upfront:
// import { Home } from "./views/home.js";
// import { About } from "./views/about.js";
// import { Dashboard } from "./views/dashboard.js";
// Use dynamic imports - load only when needed
const routes = {
"/": () => import("./views/home.js"),
"/about": () => import("./views/about.js"),
"/dashboard": () => import("./views/dashboard.js")
};
// Async router to handle dynamic imports
async function router() {
const path = location.hash.replace("#", "") || "/";
const loadView = routes[path];
if (!loadView) {
document.getElementById("app").innerHTML = "<h1>404</h1>";
return;
}
// Show loading state
document.getElementById("app").innerHTML = "<p>Loading...</p>";
try {
// Dynamically import the module
const module = await loadView();
// Call the default export (the view function)
document.getElementById("app").innerHTML = module.default();
} catch (err) {
document.getElementById("app").innerHTML = "<p>Error loading page</p>";
console.error(err);
}
}
// This mimics React's lazy() and Vue's defineAsyncComponent
// Each page is a separate JS file loaded on demand💡 Why Lazy Load: With lazy loading, users only download the JavaScript they need. A 500KB app might only load 50KB initially, making the first page load much faster.
Navigation Guards (Protected Routes)
Control access to routes based on authentication or other conditions:
// Define guards for protected routes
const guards = {
"/dashboard": () => state.user !== null,
"/admin": () => state.user?.role === "admin",
"/settings": () => state.user !== null
};
// Navigate helper with guard support
function navigateTo(path) {
const guard = guards[path];
// If there's a guard, check it
if (guard && !guard()) {
// Redirect to login
location.hash = "#/login";
return;
}
// Guard passed or no guard - navigate
location.hash = path;
}
// Enhanced router with guards
function router() {
const path = location.hash.replace("#", "") || "/";
const guard = guards[path];
// Check guard before rendering
if (guard && !guard()) {
location.hash = "#/login";
return;
}
// Render the view...
const view = routes[path];
if (view) {
document.getElementById("app").innerHTML = view();
}
}
// This is EXACTLY how Vue Router and Next.js protect pages🏆 Mini-Challenge: Make the Guards Actually Guard
No blanks now — a brief, an outline and a test harness. The routes, the guards and the fake session are all written for you; the missing piece is navigate(), the function that decides whether a guard gets to redirect.
// 🎯 MINI-CHALLENGE: make the guards actually guard
//
// Brief: everything below is written except navigate(). A guard runs BEFORE a
// view and either lets it through or sends the user somewhere else. This is
// how "you must be logged in" works in React Router, Vue Router and every
// other router you will meet.
const session = { user: null }; // nobody is logged in yet
function requireUser() {
return session.user ? true : "/login"; // true = allowed, string = redirect
}
function requireAdmin() {
if (!session.user) return "/login";
return session.user.role === "admin" ? true : "/";
}
const routes = {
"/": { view: () => "Home", guard: null },
"/login": { view: () => "Login form", guard: null },
"/account": { view: () => "Account for " + session.user.name, guard: requireUser },
"/admin": { view: () => "Admin panel", guard: requireAdmin }
};
// Outline - no logic filled in, that part is yours:
//
// function navigate(path) {
// 1. look the path up in routes. Not there? log "404: " + path and stop.
// 2. if the route has a guard, call it:
// - it returned true -> carry on to step 3
// - it returned anything else -> that value is a path. Log
// "redirect " + path + " -> " + verdict
// then navigate() to it and stop. (Yes, navigate calls itself.)
// 3. log "render " + route.view()
// }
//
// Until you have written it, running this throws
// "ReferenceError: navigate is not defined" - that is expected, not a bug.
// your code here
// --- test harness: do not change anything below this line ---
navigate("/");
navigate("/account"); // logged out
session.user = { name: "Mia", role: "user" };
navigate("/account"); // logged in
navigate("/admin"); // logged in, but not an admin
session.user = { name: "Root", role: "admin" };
navigate("/admin");
navigate("/nope");
// ✅ Expected output:
// render Home
// redirect /account -> /login
// render Login form
// render Account for Mia
// redirect /admin -> /
// render Home
// render Admin panel
// 404: /nopeThe interesting line of expected output is redirect /admin -> / followed by render Home. That second line only appears if your navigate calls itself with the redirect target — which is also why you must return straight afterwards, or you will render the guarded view as well as the redirect.
One security note while you are here: a guard in the browser is a convenience, not a defence. Anyone can edit your JavaScript in devtools. The server must check permissions on every request as well — client-side guards only decide what to show.
Component Architecture
Structure your UI with reusable components, similar to React:
Full Router Class (Framework-Grade)
Build a reusable router class like React Router or Vue Router:
// router.js - A complete router class
export class Router {
constructor(options) {
this.routes = options.routes;
this.mode = options.mode || "hash"; // hash or history
this.root = document.getElementById(options.root || "app");
this.guards = options.guards || {};
this.beforeEach = options.beforeEach || null;
this.init();
}
init() {
const event = this.mode === "hash" ? "hashchange" : "popstate";
window.addEventListener(event, () => this.render());
window.addEventListener("load", () => this.render());
}
getPath() {
if (this.mode === "hash") {
return location.hash.slice(1) || "/";
}
return location.pathname || "/";
}
matchRoute(path) {
for (const route in this.routes) {
const routeParts = route.split("/");
const pathParts = path.split("/");
if (routeParts.length !== pathParts.length) continue;
const params = {};
const matched = routeParts.every((part, i) => {
if (part.startsWith(":")) {
params[part.slice(1)] = pathParts[i];
return true;
}
return part === pathParts[i];
});
if (matched) return { view: this.routes[route], params };
}
return null;
}
async render() {
const path = this.getPath();
// Run beforeEach hook
if (this.beforeEach) {
const result = await this.beforeEach(path);
if (result === false) return;
}
// Check guards
const guard = this.guards[path];
if (guard && !guard()) {
this.navigate("/login");
return;
}
const match = this.matchRoute(path);
if (!match) {
this.root.innerHTML = "<h1>404 Not Found</h1>";
return;
}
const html = await match.view(match.params);
this.root.innerHTML = html;
}
navigate(path) {
if (this.mode === "hash") {
location.hash = path;
} else {
history.pushState(null, "", path);
this.render();
}
}
}
// Usage
import { Router } from "./router.js";
import { Home } from "./views/home.js";
import { About } from "./views/about.js";
import { UserProfile } from "./views/user.js";
const router = new Router({
mode: "hash",
routes: {
"/": Home,
"/about": About,
"/user/:id": UserProfile
},
guards: {
"/dashboard": () => !!state.user
},
beforeEach: (path) => {
console.log("Navigating to:", path);
return true; // Allow navigation
}
});API Loading & Error States
Professional SPAs show loading states and handle errors gracefully:
View Transitions (Smooth Navigation)
Add CSS transitions between views for a polished feel:
/* CSS for transitions */
/*
#app {
opacity: 1;
transition: opacity 0.2s ease-in-out;
}
#app.transitioning {
opacity: 0;
}
*/
// Enhanced router with transitions
async function router() {
const app = document.getElementById("app");
const path = location.hash.replace("#", "") || "/";
// Start transition (fade out)
app.classList.add("transitioning");
// Wait for fade out
await new Promise(resolve => setTimeout(resolve, 200));
// Get and render new view
const match = matchRoute(path);
if (match) {
const html = await match.view(match.params);
app.innerHTML = html;
} else {
app.innerHTML = "<h1>404</h1>";
}
// End transition (fade in)
app.classList.remove("transitioning");
}
// Alternative: Using View Transitions API (modern browsers)
async function routerWithViewTransitions() {
const path = location.hash.replace("#", "") || "/";
if (document.startViewTransition) {
document.startViewTransition(async () => {
const match = matchRoute(path);
if (match) {
document.getElementById("app").innerHTML = await match.view(match.params);
}
});
} else {
// Fallback for older browsers
await router();
}
}SPA Performance Optimization
Do ✅
- • Lazy load routes/views
- • Use event delegation
- • Cache API responses
- • Use CSS transitions
- • Batch DOM updates
- • Preload likely routes
Avoid ❌
- • Deep nested DOM updates
- • Re-rendering entire app
- • Attaching events to dynamic elements
- • Loading all JS upfront
- • Storing state in DOM
- • Blocking main thread
SPA Security (XSS Prevention)
SPAs must defend against XSS and other injection attacks:
💡 Security Rule: Never trust user input. Always escape or sanitize any data that comes from users, URLs, or APIs before inserting into the DOM.
SPA Deployment
For History API routing, configure your server to serve index.html for all routes:
// Hash routing: No server config needed!
// Just deploy your files and it works
// History API routing requires server rewrites:
// Apache (.htaccess)
/*
RewriteEngine On
RewriteRule ^ index.html [L]
*/
// NGINX
/*
location / {
try_files $uri $uri/ /index.html;
}
*/
// Netlify (_redirects file)
/*
/* /index.html 200
*/
// Vercel (vercel.json)
/*
{
"rewrites": [
{ "source": "/(.*)", "destination": "/" }
]
}
*/
// Express.js server
/*
const express = require("express");
const path = require("path");
const app = express();
app.use(express.static("dist"));
// Send all routes to index.html
app.get("*", (req, res) => {
res.sendFile(path.join(__dirname, "dist", "index.html"));
});
app.listen(3000);
*/
// This ensures your SPA works on page refresh!Complete Mini SPA Example
Here's a complete working SPA in one file to get you started:
💡 Try It: Copy this HTML into a file and open it in your browser. Click the navigation links and try the counter — all without page reloads!
Key Takeaways
- ✅ SPAs load once and handle navigation with JavaScript
- ✅ Hash routing is easiest; History API gives cleaner URLs
- ✅ Views are functions that return HTML strings
- ✅ Event delegation is essential for dynamic DOM
- ✅ Use a simple state object and re-render on changes
- ✅ Lazy loading improves initial page load
- ✅ Navigation guards protect private routes
- ✅ Always escape user input to prevent XSS
- ✅ Building vanilla SPAs teaches you how frameworks work internally
Practice quiz
What defines a Single Page Application (SPA)?
- It has only one HTML element
- It cannot use JavaScript
- The page loads once and navigation happens client-side without full reloads
- It always needs a database
Answer: The page loads once and navigation happens client-side without full reloads. In an SPA the page loads once and navigation injects new content client-side without browser reloads.
Which routing strategy is easiest and works everywhere with no server config?
- Hash-based routing (#home, #about)
- History API routing
- Server-side routing
- DNS routing
Answer: Hash-based routing (#home, #about). Hash-based routing uses the URL hash, requires no server configuration, and works everywhere.
Which browser event fires when the URL hash changes?
- popstate
- load
- click
- hashchange
Answer: hashchange. The 'hashchange' event fires when the URL hash changes, letting the router respond.
What does the History API use to change the URL without reloading?
- location.reload()
- history.pushState()
- document.write()
- window.open()
Answer: history.pushState(). History API routing uses history.pushState() to change the URL and listens to popstate for back/forward.
Why does History API routing require server configuration?
- The server must serve index.html for all routes so deep links and refreshes work
- It needs a database
- It uses WebSockets
- It needs HTTPS only
Answer: The server must serve index.html for all routes so deep links and refreshes work. Clean URLs require server rewrites that return index.html for every route, or refreshes would 404.
In the lesson, how is each view typically represented?
- As a CSS class
- As a database row
- As a function that returns HTML (a string or DOM)
- As an image
Answer: As a function that returns HTML (a string or DOM). Each page/view is a function that returns HTML, which the router injects into the app container.
Why is event delegation essential in SPAs?
- It makes CSS faster
- Because the DOM is replaced on navigation, listening on a parent (like document) keeps handlers working for dynamic elements
- It encrypts events
- It prevents reloads
Answer: Because the DOM is replaced on navigation, listening on a parent (like document) keeps handlers working for dynamic elements. Since views replace the DOM, you listen on a stable parent like document so handlers still work after navigation.
How does the lesson capture a route parameter like '/user/:id'?
- With a regex on the whole URL
- With localStorage
- By reloading the page
- By splitting route and path into segments and capturing parts starting with ':'
Answer: By splitting route and path into segments and capturing parts starting with ':'. matchRoute splits both into segments; any segment starting with ':' is captured as a named parameter.
What is the benefit of lazy loading routes with dynamic imports?
- It removes the need for routing
- Users only download the JavaScript they need, speeding up the first load
- It disables caching
- It prevents XSS
Answer: Users only download the JavaScript they need, speeding up the first load. Dynamic imports load a view's module only when needed, so the initial bundle is much smaller.
How does the lesson recommend preventing XSS when inserting user data?
- Always use innerHTML with the raw input
- Disable JavaScript
- Use textContent or escape/sanitize user input before inserting it
- Store input in cookies
Answer: Use textContent or escape/sanitize user input before inserting it. Never insert raw user input as HTML; use textContent or escape/sanitize (e.g., DOMPurify) to prevent XSS.
Continue this course
- Previous: Testing JavaScript with Jest & Mocha
- Next: Deploying JavaScript Apps (CDN, Minification, Bundling) — Optimise and ship JavaScript apps to production with confidence
- Quick reference: JavaScript cheat sheet
- From the blog: Understanding the DOM in JavaScript