Building Simple SPAs with Vanilla JavaScript

    Master single-page application architecture using pure JavaScript — routing, views, state management, and professional patterns.

    💡 Running Code Locally: While this online editor runs real JavaScript, SPA examples work best locally. For the full experience: Download Node.js and use a local server (like 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

    Try it Yourself »
    JavaScript
    // 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

    Try it Yourself »
    JavaScript
    // 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

    Try it Yourself »
    JavaScript
    // 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

    Try it Yourself »
    JavaScript
    // 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

    Try it Yourself »
    JavaScript
    // 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

    Try it Yourself »
    JavaScript
    // ❌ 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

    Try it Yourself »
    JavaScript
    // 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

    Try it Yourself »
    JavaScript
    // 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

    Try it Yourself »
    JavaScript
    // 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

    Try it Yourself »
    JavaScript
    // 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:

    Navigation Guards

    Protected routes with auth checks

    Try it Yourself »
    JavaScript
    // 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;
    }
    
    // Enh
    ...

    Component Architecture

    Structure your UI with reusable components, similar to React:

    Component Architecture

    Reusable UI components

    Try it Yourself »
    JavaScript
    // 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

    Try it Yourself »
    JavaScript
    // 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

    Try it Yourself »
    JavaScript
    // 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

    Try it Yourself »
    JavaScript
    /* 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

    Try it Yourself »
    JavaScript
    // 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

    Try it Yourself »
    JavaScript
    // 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

    Try it Yourself »
    JavaScript
    // 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

    Try it Yourself »
    JavaScript
    <!-- 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.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service