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!
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, 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
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();
}
/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.
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
';
// }
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.
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>
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!
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.
');
}
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.pushStateto change the URL without reloading the page, and listen topopstatefor 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, callinge.preventDefault()and yournavigate()function - ✅ Implement page transitions by adding/removing CSS classes and waiting for
transitionendevents before swapping DOM content
Want to track your progress?
Log in to save your place and pick up where you left off.
Progress track karna chahte ho?
Login karo apni progress save karne ke liye aur jahan chhoda tha wahan se shuru karo.
Login