Skip to content

Enterprise-grade hash-based router in vanilla JavaScript. Zero dependencies, Vue Router-style API, scroll restoration, async guards.

License

Notifications You must be signed in to change notification settings

robert-hoffmann/vanillajs-router

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

9 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

πŸš€ VanillaJS Router

Enterprise-grade hash-based routing in pure vanilla JavaScript. Zero dependencies, UI-agnostic design, production-ready.

License: MIT JavaScript Size Demo

A lightweight, bulletproof hash-based router built for modern web applications. Completely UI-agnostic with event-driven architecture, works with any framework or vanilla JavaScript. Inspired by Vue Router's elegant API, with enterprise-grade features and comprehensive error handling.

✨ Features

  • 🎯 UI-agnostic design - Zero DOM dependencies, works with any framework
  • πŸ“‘ Event-driven architecture - Clean pub/sub system for UI updates
  • πŸ”§ Hash-bang routing - URLs use #!/ prefix for GitHub Pages compatibility
  • πŸ“¦ Zero dependencies - Pure vanilla JavaScript, no external libraries
  • πŸ”’ Parameter arrays - All params are consistently arrays for predictable handling
  • πŸ”„ Automatic type coercion - Strings β†’ numbers/booleans conversion
  • πŸ›‘οΈ Async navigation guards - beforeEach/afterEach hooks with cancellation support
  • πŸ“œ Scroll restoration - Remembers scroll positions with extensible event system
  • βš“ Anchor coexistence - Native #section links work alongside routing
  • 🧠 Memory safe - Proper cleanup prevents memory leaks
  • πŸ§ͺ Testing friendly - Easy to unit test without DOM manipulation
  • 🎨 Vue Router API - Familiar interface for Vue developers

πŸ“¦ Installation

Direct Download

# Download the router file
curl -O https://raw.githubusercontent.com/robert-hoffmann/vanillajs-router/master/router.min.js

CDN

<script src="https://cdn.jsdelivr.net/gh/robert-hoffmann/vanillajs-router@master/router.min.js"></script>

npm (if published)

npm install vanillajs-router

πŸš€ Quick Start

<!DOCTYPE html>
<html>
<head>
    <title>My App</title>
</head>
<body>
    <nav>
        <a href="#!/home">Home</a>
        <a href="#!/about">About</a>
        <a href="#!/user?id=123&name=John">User</a>
    </nav>

    <div id="content">Loading...</div>
    <div id="status"></div>

    <script src="router.js"></script>
    <script>
        // Subscribe to router events for UI updates
        MyRouter.onStatus((status, type, context) => {
            document.getElementById('status').textContent = status;
            document.getElementById('status').className   = `status ${type}`;
        });

        // Set up route handling
        MyRouter.beforeEach((to, from) => {
            console.log('Navigating to:', to.path);
            updateContent(to);
        });

        function updateContent(route) {
            const content = document.getElementById('content');

            switch(route.path) {
                case 'home':
                    content.innerHTML = '<h1>Welcome Home!</h1>';
                    break;
                case 'about':
                    content.innerHTML = '<h1>About Us</h1>';
                    break;
                case 'user':
                    const [userId]    = route.params.id || ['unknown'];
                    const [userName]  = route.params.name || ['Guest'];
                    content.innerHTML = `<h1>User: ${userName} (ID: ${userId})</h1>`;
                    break;
                default:
                    content.innerHTML = '<h1>Page Not Found</h1>';
            }
        }
    </script>
</body>
</html>

πŸ“š API Reference

Core Methods

MyRouter.beforeEach(callback)

Register a navigation guard that runs before each route change.

MyRouter.beforeEach(async (to, from) => {
    // Check authentication
    if (to.path === 'admin' && !isAuthenticated()) {
        throw new Error('Authentication required');
    }

    // Update page content
    updatePageContent(to);
});

MyRouter.afterEach(callback)

Register a hook that runs after each successful navigation.

MyRouter.afterEach((to, from) => {
    // Analytics tracking
    analytics.track('page_view', { path: to.path });

    // Update page title
    document.title = `My App - ${to.path}`;
});

Event System (NEW!)

MyRouter.onStatus(callback)

Subscribe to navigation status updates for UI feedback.

MyRouter.onStatus((status, type, context) => {
    // status: "πŸ”„ Navigating...", "βœ… Navigation complete", etc.
    // type: 'info', 'loading', 'success', 'error'
    // context: { route, prevRoute }

    updateStatusDisplay(status, type);
});

MyRouter.onScroll(callback)

Subscribe to scroll events for custom scroll handling.

MyRouter.onScroll((scrollData) => {
    if (scrollData.type === 'capture') {
        // Add custom scroll positions
        scrollData.customContainerY = myContainer.scrollTop;
    }

    if (scrollData.type === 'restore') {
        // Restore custom scroll positions
        if (scrollData.customContainerY !== undefined) {
            myContainer.scrollTop = scrollData.customContainerY;
        }
    }

    if (scrollData.type === 'update') {
        // Real-time scroll position updates
        updateScrollIndicator(scrollData.winX, scrollData.winY);
    }
});

Navigation

MyRouter.push(path)

Navigate to a new route programmatically.

// Simple navigation
await MyRouter.push('/dashboard');

// With parameters
await MyRouter.push('/user?id=456&tab=settings');

// Returns true if navigation succeeded, false if cancelled

MyRouter.replace(path)

Replace the current route without adding to history.

await MyRouter.replace('/login');

Route Information

MyRouter.currentRoute()

Get the current route object.

const route = MyRouter.currentRoute();
console.log(route);
// {
//   path: "user",
//   params: { id: ["123"], name: ["John"] },
//   paramsTyped: { id: [123], name: ["John"] },
//   query: {},
//   queryTyped: {}
// }

MyRouter.getTypedParams() & MyRouter.getTypedQuery()

Get type-coerced parameters as convenient getters.

const params    = MyRouter.getTypedParams();
const [userId]  = params.id || [0];        // 123 (number)
const [isAdmin] = params.admin || [false]; // true (boolean)

History Control

MyRouter.back();     // Go back one page
MyRouter.forward();  // Go forward one page
MyRouter.go(-2);     // Go back 2 pages

Scroll Management

// Manual scroll position management
MyRouter.saveScrollPosition();
MyRouter.restoreScrollPosition();
MyRouter.clearScrollHistory();

Cleanup

// Important: Call when destroying your app
MyRouter.destroy();

πŸ”§ Advanced Usage

Framework Integration

React Integration

import { useEffect, useState } from 'react';

function useRouter() {
    const [route, setRoute]   = useState(MyRouter.currentRoute());
    const [status, setStatus] = useState('Ready');

    useEffect(() => {
        const unsubscribeRoute = MyRouter.beforeEach((to, from) => {
            setRoute(to);
        });

        const unsubscribeStatus = MyRouter.onStatus((status, type) => {
            setStatus(status);
        });

        return () => {
            unsubscribeRoute();
            unsubscribeStatus();
        };
    }, []);

    return { route, status, push: MyRouter.push };
}

Vue Integration

// Vue 3 Composition API
import { ref, onMounted, onUnmounted } from 'vue';

export function useRouter() {
    const route = ref(MyRouter.currentRoute());
    const status = ref('Ready');

    let unsubscribeRoute, unsubscribeStatus;

    onMounted(() => {
        unsubscribeRoute = MyRouter.beforeEach((to, from) => {
            route.value  = to;
        });

        unsubscribeStatus = MyRouter.onStatus((statusText, type) => {
            status.value  = statusText;
        });
    });

    onUnmounted(() => {
        unsubscribeRoute?.();
        unsubscribeStatus?.();
    });

    return { route, status, push: MyRouter.push };
}

Authentication Guards

let user = null;

MyRouter.beforeEach(async (to, from) => {
    const protectedRoutes = ['dashboard', 'profile', 'admin'];

    if (protectedRoutes.includes(to.path)) {
        if (!user) {
            // Router emits: "❌ Authentication required"
            throw new Error('Authentication required');
        }
    }
});

// Handle auth failures in UI
MyRouter.onStatus((status, type) => {
    if (type === 'error' && status.includes('Authentication')) {
        showLoginModal();
    }
});

Loading States with Events

MyRouter.onStatus((status, type) => {
    const loader = document.getElementById('loader');

    if (type === 'loading') {
        loader.style.display = 'block';
    } else {
        loader.style.display = 'none';
    }
});

MyRouter.beforeEach(async (to, from) => {
    // Router automatically emits "πŸ”„ Navigating..." status

    try {
        await loadPageData(to.path);
        updateContent(to);
        // Router automatically emits "βœ… Navigation complete" status
    } catch (error) {
        // Router automatically emits error status
        throw error;
    }
});

Custom Scroll Containers

MyRouter.onScroll((scrollData) => {
    const containers = [
        { id: 'sidebar'     , key: 'sidebarY' },
        { id: 'main-content', key: 'mainY' },
        { id: 'chat-area'   , key: 'chatY' }
    ];

    containers.forEach(({ id, key }) => {
        const element = document.getElementById(id);
        if (!element) return;

        if (scrollData.type === 'capture') {
            // Save custom container scroll positions
            scrollData[key] = element.scrollTop;
        } else if (scrollData.type === 'restore' && scrollData[key] !== undefined) {
            // Restore custom container scroll positions
            element.scrollTop = scrollData[key];
        }
    });
});

Parameter Handling

// URL: #!/products?category=electronics&category=books&sort=price&featured=true

MyRouter.beforeEach((to, from) => {
    const { category, sort, featured } = to.paramsTyped;

    console.log(category);  // ["electronics", "books"] (array)
    console.log(sort);      // ["price"] (array)
    console.log(featured);  // [true] (boolean in array)

    // Extract first values
    const [sortBy]     = sort || ['name'];
    const [isFeatured] = featured || [false];
});

Dynamic Route Building

function navigateToUser(userId, tab = 'profile') {
    const params = new URLSearchParams();
    params.set('id' , userId);
    params.set('tab', tab);

    MyRouter.push(`/user?${params.toString()}`);
}

navigateToUser(123, 'settings');
// Navigates to: #!/user?id=123&tab=settings

🎯 Type Coercion Examples

The router automatically converts string parameters to appropriate JavaScript types:

URL Parameter Raw Value Coerced Value Type
?count=42 "42" 42 number
?price=19.99 "19.99" 19.99 number
?active=true "true" true boolean
?enabled=false "false" false boolean
?data=null "null" null null
?name=John "John" "John" string

πŸ› οΈ Browser Support

  • βœ… Chrome 60+
  • βœ… Firefox 55+
  • βœ… Safari 12+
  • βœ… Edge 79+

πŸ“± GitHub Pages & Static Hosting

Perfect for static sites and GitHub Pages! Hash-based routing works without server configuration:

// Works on GitHub Pages out of the box
// No .htaccess or server config needed
https://yourusername.github.io/myapp#!/dashboard?tab=analytics

πŸ§ͺ Testing

The UI-agnostic design makes testing much easier:

// Example unit test structure
describe('VanillaJS Router', () => {
    let statusEvents = [];
    let scrollEvents = [];

    beforeEach(() => {
        // Reset router state
        MyRouter.destroy();
        window.location.hash = '';
        statusEvents         = [];
        scrollEvents         = [];

        // Set up event monitoring
        MyRouter.onStatus((status, type) => {
            statusEvents.push({ status, type });
        });

        MyRouter.onScroll((scrollData) => {
            scrollEvents.push(scrollData);
        });
    });

    it('should navigate to route', async () => {
        await MyRouter.push('/test');
        expect(MyRouter.currentRoute().path).toBe('test');
        expect(statusEvents).toContainEqual({
            status: 'βœ… Navigation complete',
            type  : 'success'
        });
    });

    it('should coerce parameters', () => {
        window.location.hash = '#!/test?count=42&active=true';
        const params = MyRouter.getTypedParams();
        expect(params.count[0]).toBe(42);
        expect(params.active[0]).toBe(true);
    });

    it('should emit scroll events', () => {
        MyRouter.saveScrollPosition('test');
        expect(scrollEvents.some(e => e.type === 'capture')).toBe(true);
    });

    it('should handle navigation cancellation', async () => {
        MyRouter.beforeEach((to, from) => {
            if (to.path === 'forbidden') {
                throw new Error('Access denied');
            }
        });

        const success = await MyRouter.push('/forbidden');
        expect(success).toBe(false);
        expect(statusEvents).toContainEqual({
            status: '❌ Access denied',
            type  : 'error'
        });
    });
});

🎨 Live Demo

Check out the interactive demo to see all features in action:

  • 🏠 Multiple route examples with event-driven UI
  • πŸ“Š Parameter handling demonstrations
  • πŸ”’ Authentication guard simulation with status events
  • πŸ“œ Scroll restoration testing with custom containers
  • βš“ Anchor link coexistence
  • 🎯 Type coercion examples
  • πŸ“‘ Real-time event monitoring

πŸ“– Why This Router?

Compared to other solutions:

Feature VanillaJS Router Vue Router React Router Page.js
Bundle Size ~4KB ~34KB ~45KB ~6KB
Dependencies 0 Vue required React required 0
UI Framework βœ… Agnostic ❌ Vue only ❌ React only βœ… Agnostic
Event System βœ… Built-in ❌ Manual ❌ Manual ❌ Manual
Type Coercion βœ… Built-in ❌ Manual ❌ Manual ❌ Manual
Parameter Arrays βœ… Consistent ❌ Manual ❌ Manual ❌ Manual
Async Guards βœ… Built-in βœ… Built-in ❌ Manual ❌ Manual
Scroll Restoration βœ… Built-in + Extensible βœ… Built-in ❌ Manual ❌ Manual
Memory Management βœ… Built-in βœ… Built-in βœ… Built-in ⚠️ Manual
Testing βœ… Easy (no DOM) ⚠️ Requires Vue ⚠️ Requires React βœ… Easy

Key Advantages:

  • 🎯 Framework Freedom: Use with React, Vue, Angular, Svelte, or vanilla JS
  • πŸ§ͺ Testing Friendly: No DOM dependencies make unit testing straightforward
  • πŸ“‘ Event-Driven: Clean separation between routing logic and UI updates
  • πŸ”„ SSR Compatible: Can run in Node.js environments
  • 🎨 Flexible: Extend scroll handling, status displays, and navigation behavior

🀝 Contributing

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/amazing-feature)
  3. Commit your changes (git commit -m 'Add amazing feature')
  4. Push to the branch (git push origin feature/amazing-feature)
  5. Open a Pull Request

πŸ“ License

This project is licensed under the MIT License - see the LICENSE file for details.

πŸ™ Acknowledgments

  • Inspired by Vue Router for the elegant API design
  • Hash-bang routing pattern from Google's AJAX crawling specification
  • Community feedback and contributions
  • Built with the help of AI collaborators:
    • Claude Sonnet 4 handled the coding implementation
    • OpenAI o3 provided critical thinking and architecture review
    • ~30 iterations of back-and-forth discussion to perfect the design

πŸ”— Related Projects


Made with ❀️ for the JavaScript community

πŸ†• v2.0: Now completely UI-agnostic with event-driven architecture!

If this router helped you build something awesome, consider giving it a ⭐ star!

About

Enterprise-grade hash-based router in vanilla JavaScript. Zero dependencies, Vue Router-style API, scroll restoration, async guards.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published