Hash Routing — URL Fragment-Based Navigation for SPAs
In this tutorial, you will learn about Hash Routing. We cover key concepts, practical examples, and best practices to help you master this topic.
Hash routing uses URL fragments for SPA navigation without server configuration, making it ideal for static hosting and simple single-page applications where History API setup is impractical.
What You'll Learn
By the end of this tutorial, you will understand how hash routing works, when to use it instead of the History API, its limitations, and how to implement a hash-based router.
Why It Matters
Hash routing works everywhere — no server configuration, no fallback handling, no special setup. It is the simplest way to add client-side routing to any SPA hosted on any static server (GitHub Pages, Netlify, S3 bucket).
Real-World Use
A developer built a documentation SPA hosted on GitHub Pages. Using hash routing, they created clean navigation like username.github.io/docs/#/getting-started without needing server-side rewrite rules. The entire site was static files.
How Hash Routing Works
Hash Routing Flow
URL: https://example.com/#/about
↓
Browser loads index.html (ignores hash)
↓
JavaScript reads window.location.hash
↓
Hash change detected → render new view
↓
User clicks link → update hash
↓
URL changes (no page reload)
↓
hashchange event fires
↓
Router reads new hash → render view
Think of hash routing like tabbed folders in a filing cabinet. The cabinet itself (index.html) is always the same. The tab labels (hash) tell you which folder section to look at. Opening different tabs does not change the cabinet itself.
Basic Hash Router
class HashRouter {
constructor(routes) {
this.routes = routes;
this.currentHash = '';
// Listen for hash changes
window.addEventListener('hashchange', this.handleHashChange.bind(this));
// Initialize from current hash
this.handleHashChange();
}
handleHashChange() {
const hash = window.location.hash.slice(1) || '/';
console.log('Hash changed to:', hash);
const route = this.routes.find(r => r.path === hash);
if (route) {
document.getElementById('app').innerHTML = route.template();
document.title = route.title;
} else {
this.render404(hash);
}
}
navigate(path) {
window.location.hash = path;
}
getCurrentPath() {
return window.location.hash.slice(1) || '/';
}
render404(path) {
document.getElementById('app').innerHTML = `
<h1>404 Not Found</h1>
<p>No route matches "${path}"</p>
`;
}
}
// Usage
const router = new HashRouter([
{
path: '/',
title: 'Home',
template: () => '<h1>Home</h1><p>Welcome!</p>'
},
{
path: '/about',
title: 'About',
template: () => '<h1>About</h1><p>About this app</p>'
},
{
path: '/contact',
title: 'Contact',
template: () => '<h1>Contact</h1><p>Contact us</p>'
}
]);
// Navigation
// <a href="#/about">About</a> → hashchange fires → renders About view
Parameterized Hash Routes
class AdvancedHashRouter {
constructor(routes) {
this.routes = routes.map(route => ({
...route,
regex: this.pathToRegex(route.path)
}));
window.addEventListener('hashchange', () => this.resolve());
this.resolve();
}
pathToRegex(path) {
// Convert /users/:id/posts/:postId to regex
const paramNames = [];
const regexStr = path.replace(/:([^/]+)/g, (_, name) => {
paramNames.push(name);
return '([^/]+)';
});
return {
regex: new RegExp(`^${regexStr}$`),
paramNames
};
}
resolve() {
const hash = window.location.hash.slice(1) || '/';
for (const route of this.routes) {
const match = hash.match(route.regex);
if (match) {
const params = {};
route.paramNames.forEach((name, i) => {
params[name] = match[i + 1];
});
console.log('Route matched:', route.path, params);
route.handler(params);
return;
}
}
this.render404(hash);
}
navigate(path) {
window.location.hash = path;
}
}
// Usage
const router = new AdvancedHashRouter([
{ path: '/', handler: () => renderHome() },
{ path: '/users/:id', handler: (params) => renderUser(params.id) },
{ path: '/users/:id/posts/:postId', handler: (p) => renderPost(p.id, p.postId) },
{ path: '/search', handler: () => renderSearch() }
]);
Hash vs History API Comparison
const comparison = {
hash: {
urls: 'https://example.com/#/users/123',
serverConfig: 'None required',
seo: 'Poor (hash not sent to server)',
browserSupport: 'All browsers',
setupComplexity: 'Minimal',
useCase: 'Static hosting, simple SPAs'
},
history: {
urls: 'https://example.com/users/123',
serverConfig: 'Must serve index.html for all routes',
seo: 'Better (clean URLs)',
browserSupport: 'IE10+, all modern',
setupComplexity: 'Requires server config',
useCase: 'Production apps with clean URLs'
}
};
Handling Page Refresh with Hash Routing
// Hash routing handles page refresh automatically
// because the browser fetches index.html and then
// the JavaScript reads the hash
// However, handle the case where there is no hash
document.addEventListener('DOMContentLoaded', () => {
if (!window.location.hash) {
// Redirect to default route
window.location.hash = '#/';
} else {
// Parse and render the current hash
router.resolve();
}
});
// Preserve state across refreshes using hash params
function navigateWithState(path, data) {
const queryString = Object.entries(data)
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
.join('&');
window.location.hash = `${path}?${queryString}`;
}
// Parse query params from hash
function parseHashParams() {
const hash = window.location.hash.slice(1);
const [path, queryString] = hash.split('?');
const params = {};
if (queryString) {
queryString.split('&').forEach(pair => {
const [key, value] = pair.split('=');
params[decodeURIComponent(key)] = decodeURIComponent(value);
});
}
return { path, params };
}
Hash Routing with Nested Views
// Nested hash routing for tabbed interfaces
const tabRouter = {
tabs: document.querySelectorAll('.tab'),
panels: document.querySelectorAll('.panel'),
init() {
// Listen for hash changes
window.addEventListener('hashchange', () => this.handleTabChange());
// Set initial tab from hash
this.handleTabChange();
// Add click handlers
this.tabs.forEach(tab => {
tab.addEventListener('click', (event) => {
event.preventDefault();
window.location.hash = tab.getAttribute('href');
});
});
},
handleTabChange() {
const hash = window.location.hash.slice(1) || 'tab1';
this.tabs.forEach(tab => {
const isActive = tab.getAttribute('href') === hash;
tab.classList.toggle('active', isActive);
});
this.panels.forEach(panel => {
const isActive = panel.id === hash;
panel.style.display = isActive ? 'block' : 'none';
});
}
};
// HTML structure:
// <a href="tab1" class="tab">Tab 1</a>
// <a href="tab2" class="tab">Tab 2</a>
// <div id="tab1" class="panel">Content 1</div>
// <div id="tab2" class="panel">Content 2</div>
Common Mistakes
- Not handling the initial hash. On first page load, there may be no hash. Set a default route or redirect to #/.
- Forgetting to use
#prefix in links. Hash links must usehref="#/path"nothref="/path". The latter causes a full page reload. - Using hash routing when clean URLs are required. Hash URLs look unprofessional. If you need clean URLs, configure the server for History API.
- Not encoding special characters in hash params. URL-encode values in hash query strings to prevent Parsing issues.
- Mixing hash routing with server-rendered routes. Hash routes are only handled client-side. Server routes cannot respond to hash fragments.
Practice Questions
- How does a hash change differ from a regular navigation in terms of network requests?
- What happens when you refresh a page with a hash URL?
- Why does hash routing not require server configuration?
- What are the SEO implications of hash routing?
- How do you pass route parameters with hash routing?
Challenge: Build a complete hash router that supports: parameterized routes (/users/:id), nested views (tabs within a user page), query string parsing, default route when no hash exists, and a 404 handler. Test by navigating between routes using both links and direct URL entry.
FAQ
Mini Project
Create a single-page application with hash routing that has four sections: home (#/), products (#/products), a product detail page (#/products/:id), and a cart (#/cart). Implement the hash router from scratch. Add a "back to products" link on the detail page. Host on any static server and verify deep linking works.
What's Next
You know both routing approaches. Now learn React Router, the most popular routing library for React-based SPAs.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro