Skip to content

Hash Routing — URL Fragment-Based Navigation for SPAs

DodaTech Updated 2026-06-28 6 min read

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

  1. Not handling the initial hash. On first page load, there may be no hash. Set a default route or redirect to #/.
  2. Forgetting to use # prefix in links. Hash links must use href="#/path" not href="/path". The latter causes a full page reload.
  3. Using hash routing when clean URLs are required. Hash URLs look unprofessional. If you need clean URLs, configure the server for History API.
  4. Not encoding special characters in hash params. URL-encode values in hash query strings to prevent Parsing issues.
  5. Mixing hash routing with server-rendered routes. Hash routes are only handled client-side. Server routes cannot respond to hash fragments.

Practice Questions

  1. How does a hash change differ from a regular navigation in terms of network requests?
  2. What happens when you refresh a page with a hash URL?
  3. Why does hash routing not require server configuration?
  4. What are the SEO implications of hash routing?
  5. 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

Does hash routing work on all web servers?

Yes. Hash routing works on any web server because the hash is never sent to the server. The server always serves the same index.html regardless of the hash.

Can I use hash routing with SEO?

Hash URLs are not ideal for SEO. Google has improved hash crawling but clean URLs (History API) are strongly preferred for content that should appear in search results.

Does the hashchange event fire on page load?

No. hashchange only fires when the hash changes after the page has loaded. Handle the initial hash in DOMContentLoaded.

Can I use hash routing in a Progressive Web App?

Yes. Hash routing works with PWAs. However, install prompts and manifest scope work better with clean URLs. Consider History API for PWAs.

What is the maximum length of a URL hash?

The hash fragment is part of the URL. Total URL length should stay under 2000 characters. Practical hash routing uses much shorter hashes.

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