Building Simple SPAs with Vanilla JavaScript
Master single-page application architecture using pure JavaScript — routing, views, state management, and professional patterns.
npx serve), or create HTML files with ES modules.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
4. History Management
Browser back/forward support
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
Detect hash changes and handle routes
// 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
Clean URLs with pushState and popstate
// 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
SPA Folder Structure
Recommended project organization
// 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
...Defining Views (Each Page)
Each page is a function that returns HTML (as a string or DOM elements).
Defining Views
Each page as a function returning HTML
// views/home.js
export function Home() {
return `
<div class="page home-page">
<h1>Home Page</h1>
<p>Welcome to our SPA!</p>
<a href="#/about">Learn About Us</a>
</div>
`;
}
// views/about.js
export function About() {
return `
<div class="page about-page">
<h1>About Us</h1>
<p>This page tells our story.</p>
<a href="#/">Back to Home</a>
</div>
`;
}
// views/profile.js
export function Profile(params) {
return `
<div class="page
...Building a Simple Hash Router
Create a router that loads views based on the URL hash:
Simple Hash Router
Load views based on 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();
...Navigation Links
Event Delegation (Critical for SPAs)
Since the DOM is replaced on navigation, you can't attach events directly to elements that might disappear.
Event Delegation
Handle events on dynamic DOM elements
// ❌ 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.has
...💡 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.
Simple State Management
Track and update application state
// Simple state management
const state = {
user: null,
theme: "light",
counter: 0,
todos: []
};
// Update state and re-render
function setState(key, value) {
state[key] = value;
router(); // Re-render current view
}
// Example: Counter component
function Counter() {
return `
<div class="counter">
<h2>Count: ${state.counter}</h2>
<button id="increment">+</button>
<button id="decrement">-</button>
</div>
`;
}
// Handle counter clicks via delegation
docu
...Pub/Sub State Store (Mini Redux)
Pub/Sub State Store
Mini Redux-like reactive state
// A simple reactive store
const store = {
state: {},
listeners: [],
get(key) {
return this.state[key];
},
set(key, value) {
this.state[key] = value;
// Notify all listeners
this.listeners.forEach(fn => fn(key, value));
},
subscribe(fn) {
this.listeners.push(fn);
// Return unsubscribe function
return () => {
this.listeners = this.listeners.filter(l => l !== fn);
};
}
};
// Usage
store.set("user", { name: "Boopie" });
store.set("theme", "d
...Route Parameters (#/user/123)
Real apps need dynamic routes. Here's how to add parameter support:
Route Parameters
Dynamic routes with parameter support
// Enhanced router with parameter support
const routes = {
"/": Home,
"/about": About,
"/user/:id": UserProfile,
"/post/:id/comments": PostComments
};
function matchRoute(path) {
for (const route in routes) {
const routeParts = route.split("/");
const pathParts = path.split("/");
// Must have same number of segments
if (routeParts.length !== pathParts.length) continue;
const params = {};
// Check if all parts match
const matched = routeParts.
...Lazy Loading Routes (Dynamic Imports)
Load pages only when needed for massive performance gains:
Lazy Loading Routes
Dynamic imports for better performance
// 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("#
...💡 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:
Component Architecture
Structure your UI with reusable components, similar to React:
Component Architecture
Reusable UI components
// components/Button.js
export function Button({ text, type = "primary", onClick }) {
const id = "btn-" + Math.random().toString(36).slice(2);
// Register click handler after render
setTimeout(() => {
const btn = document.getElementById(id);
if (btn && onClick) {
btn.onclick = onClick;
}
});
return `
<button id="${id}" class="btn btn-${type}">
${text}
</button>
`;
}
// components/Card.js
export function Card({ title, content, footer }) {
retur
...Full Router Class (Framework-Grade)
Build a reusable router class like React Router or Vue Router:
Full Router Class
Framework-grade reusable 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());
...API Loading & Error States
Professional SPAs show loading states and handle errors gracefully:
API Loading & Error States
Professional data loading patterns
// View with async data loading
export async function PostList() {
// Initial loading state
document.getElementById("app").innerHTML = `
<div class="loading">
<div class="spinner"></div>
<p>Loading posts...</p>
</div>
`;
try {
const response = await fetch("/api/posts");
if (!response.ok) {
throw new Error("Failed to fetch posts");
}
const posts = await response.json();
if (posts.length === 0) {
return `
<div cl
...View Transitions (Smooth Navigation)
Add CSS transitions between views for a polished feel:
View Transitions
Smooth navigation with CSS transitions
/* 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
...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
Performance Optimization
Tips for fast SPAs
// Performance tips in code
// 1. Batch DOM updates
// ❌ Slow - multiple reflows
list.forEach(item => {
container.innerHTML += `<li>${item}</li>`;
});
// ✅ Fast - single reflow
const html = list.map(i => `<li>${i}</li>`).join("");
container.innerHTML = html;
// 2. Use DocumentFragment for many elements
const fragment = document.createDocumentFragment();
items.forEach(item => {
const li = document.createElement("li");
li.textContent = item;
fragment.appendChild(li);
});
container.appen
...SPA Security (XSS Prevention)
SPAs must defend against XSS and other injection attacks:
SPA Security
XSS prevention techniques
// XSS Prevention
// ❌ DANGEROUS - Never insert user input as HTML
const userInput = "<script>alert('XSS!')</script>";
app.innerHTML = userInput; // Executes the script!
// ✅ SAFE - Use textContent for user data
const userInput = "<script>alert('XSS!')</script>";
const p = document.createElement("p");
p.textContent = userInput; // Displays as text, not HTML
app.appendChild(p);
// ✅ SAFE - Escape HTML entities
function escapeHTML(str) {
const div = document.createElement("div");
div.textCo
...💡 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:
SPA Deployment
Server configuration for SPAs
// 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 pa
...Complete Mini SPA Example
Here's a complete working SPA in one file to get you started:
Complete Mini SPA Example
Working SPA in one file
<!-- Save as index.html and open in browser -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Mini SPA</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; line-height: 1.6; }
nav { background: #333; padding: 1rem; }
nav a { color: white; margin-right: 1rem; text-decoration: none; }
nav a:hover { text-decoration: underline; }
#app { padding: 2rem; max-width: 800px; margin: 0 auto; }
...💡 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
Sign up for free to track which lessons you've completed and get learning reminders.