Chapter 10.7☕ 25 min read

Single-Page Router from Scratch — History API & Lazy Loading

PushState se URL badlo, popstate se Back/Forward suno, Regex se routes match karo — SPA banao bina framework ke!

01🔗 History API: Changing URLs Without Reload

History API is the foundation of every SPA router. It lets you change the URL in the browser address bar without triggering a full page reload.

history.pushState(state, title, url) pushes a new entry onto the browser history stack and updates the URL. The page does NOT reload.

history.replaceState(state, title, url) does the same but replaces the current entry instead of adding a new one.

function navigate(path) {
  // 1. Update the URL without page reload
  history.pushState({}, '', path);
  
  // 2. Manually trigger route resolution
  // (pushState does NOT fire popstate!)
  resolveRoute(path);
}

// Listen for Back/Forward button clicks
window.addEventListener('popstate', () => {
  resolveRoute(window.location.pathname);
});

// Handle initial page load
window.addEventListener('DOMContentLoaded', () => {
  resolveRoute(window.location.pathname);
});
pushState does NOT fire popstate! This is the #1 gotcha in SPA routing. When you call pushState, the URL changes but no event fires. You must manually call your route resolver after pushing state. The popstate event only fires when the user clicks the browser Back or Forward buttons.

Three things every SPA router needs:

1. pushState — change the URL on navigation

2. popstate listener — handle Back/Forward clicks

3. DOMContentLoaded listener — handle direct URL visits and refreshes

02🧩 Route Matching & URL Parsing

A router needs a route registry — an array of { path, handler } objects. When the URL changes, loop through the registry and find a match.

For dynamic routes like /users/:id, convert the route string to a Regular Expression. Replace :paramName with a capturing group ([^/]+) that matches any characters except a slash.

const routes = [
  { path: '/', handler: renderHome },
  { path: '/about', handler: renderAbout },
  { path: '/users/:id', handler: renderUser }
];

function resolveRoute(pathname) {
  let matched = false;
  
  for (const route of routes) {
    // Convert '/users/:id' to regex '/users/([^\/]+)'
    const paramNames = [];
    const regexPath = route.path.replace(/:([^\/]+)/g, (_, paramName) => {
      paramNames.push(paramName);
      return '([^\/]+)';
    });
    
    const match = pathname.match(new RegExp('^' + regexPath + '$'));
    
    if (match) {
      const params = {};
      paramNames.forEach((name, i) => {
        params[name] = match[i + 1]; // Extract :id value
      });
      
      route.handler(params); // Call handler with extracted params
      matched = true;
      break;
    }
  }
  
  if (!matched) render404();
}
How the regex conversion works: The route /users/:id becomes the regex pattern /users/([^/]+). The ([^/]+) captures one or more characters that are NOT a forward slash. When matched against /users/42, the capturing group extracts 42. Multiple params work too: /posts/:postId/comments/:commentId extracts both values.

Why regex over string comparison? /about can use simple ===. But /users/:id needs pattern matching — you cannot compare against every possible ID. Regex handles the variable parts elegantly.

03📦 Dynamic Import: Lazy Loading Pages

Why load the "Admin Dashboard" JavaScript if the user only visits the Home page? Dynamic import() loads a module at runtime, returning a Promise.

Use this in route handlers to fetch page code ONLY when the route is visited. This is called lazy loading — code splits into chunks that load on demand.

const routes = [
  { 
    path: '/', 
    handler: () => import('./pages/home.js').then(m => m.default())
  },
  { 
    path: '/dashboard', 
    handler: () => {
      showSpinner();
      import('./pages/dashboard.js') // Downloaded ONLY on /dashboard
        .then(module => module.default())
        .catch(() => renderError('Failed to load page'))
        .finally(hideSpinner);
    }
  }
];

// pages/dashboard.js
// export default function renderDashboard() {
//   document.getElementById('app').innerHTML = '

Dashboard

'; // }
Static vs Dynamic import: import Home from './home.js' (static) loads at the top of the file — always downloaded. import('./home.js') (dynamic) returns a Promise and only downloads when that line executes. V8 and bundlers like Webpack/Vite automatically create separate chunk files for dynamic imports.

Show a loading spinner while the chunk downloads. The .finally(hideSpinner) ensures the spinner hides whether the import succeeds or fails. Users never stare at a blank screen.

04🖱️ Event Delegation: Intercepting Link Clicks

If you use <a href="/about">, clicking it reloads the page. You lose SPA behavior entirely! The browser makes a full HTTP request, the server sends HTML, and everything re-initializes.

Event Delegation solves this: attach ONE click listener on document that intercepts ALL link clicks. If the link is internal (same origin), call e.preventDefault() and use your router instead.

document.addEventListener('click', (e) => {
  // Find closest anchor tag
  const link = e.target.closest('a');
  
  // Ignore if not a link, or external, or has target="_blank"
  if (!link || 
      link.target === '_blank' || 
      link.origin !== window.location.origin ||
      e.ctrlKey || e.metaKey) { // Allow Ctrl+Click for new tab
    return;
  }
  
  // Prevent full page reload
  e.preventDefault();
  
  // Navigate using our SPA router
  navigate(link.pathname);
});

// HTML can now use normal links!
// <a href="/about">About Us</a>
Why Event Delegation over onclick: One listener on document catches clicks on ALL links — even ones added to the DOM dynamically after the page loads. No need to attach onclick to every <a> tag. Also respects Ctrl+Click (open in new tab) and target="_blank" — the user stays in control.

The closest('a') trick: Users might click a <span> or <img> inside the <a>. e.target would be the inner element, not the link. closest('a') walks up the DOM tree to find the anchor — robust click handling!

05✨ Transition Animations: The Final Polish

SPA routing allows smooth transitions between pages — fade in/out, slide, scale. This makes the app feel native, not like a webpage.

Add a CSS class to fade out the old page, wait for the animation, swap the DOM content, then fade in the new page. Use the transitionend event to know when the animation finishes.

const app = document.getElementById('app');

async function transitionTo(newContent) {
  // 1. Fade out current content
  app.classList.add('fade-out');
  await waitForTransition(app);
  
  // 2. Swap content
  app.innerHTML = newContent;
  
  // 3. Fade in new content
  app.classList.remove('fade-out');
  app.classList.add('fade-in');
  await waitForTransition(app);
  app.classList.remove('fade-in');
}

function waitForTransition(element) {
  return new Promise(resolve => {
    const handler = () => {
      element.removeEventListener('transitionend', handler);
      resolve();
    };
    element.addEventListener('transitionend', handler);
  });
}

// In route handler
function renderAbout() {
  transitionTo('

About Page

This is us.

'); }
Dynamic imports and route transitions make your app feel instant. Instead of a blank white screen while the browser reloads, the user sees a smooth fade or spinner. This is why SPAs feel faster than traditional websites, even if the actual data load time is the same. Perception is performance!

CSS for transitions:

#app {
  transition: opacity 0.3s ease, transform 0.3s ease;
}
#app.fade-out {
  opacity: 0;
  transform: translateY(8px);
}
#app.fade-in {
  opacity: 0;
  transform: translateY(-8px);
}

Combine lazy loading with transitions: show spinner while chunk downloads, fade out old content, render new content, fade in. The user never sees a blank screen. 🎨

Lo kar liya — Key Points:

  • ✅ Use history.pushState to change the URL without reloading the page, and listen to popstate for browser Back/Forward navigation
  • ✅ Parse dynamic route parameters (/users/:id) by converting route strings to Regular Expressions and extracting match groups
  • ✅ Use Dynamic import() inside route handlers to lazy load page components only when their route is visited
  • ✅ Intercept all internal <a> tag clicks using Event Delegation on the document, calling e.preventDefault() and your navigate() function
  • ✅ Implement page transitions by adding/removing CSS classes and waiting for transitionend events before swapping DOM content
Course Search
Search across all chapters & stages
📖

Search the course

Type any topic — branching, stash, rebase, hooks — and jump straight to that chapter.

merge branchesgit stashundo commitrebase