Client Router

Full-featured routing with guards, middleware, and security

Quick Start

import { createRouter } from './build/router.esm.js'
import { mount } from './build/noun.esm.js'
import { Home, About, User } from './pages.js'

const router = createRouter({ mode: 'hash' })

router
  .route('/', () => mount('#app', Home))
  .route('/about', () => mount('#app', About))
  .route('/users/:id', (to) => mount('#app', User, { id: to.params.id }))
  .start()

Router Modes

Hash Mode (Default)

URLs like #/users/123

  • Works without server config
  • Best for static hosting
  • No 404 on refresh

History Mode

Clean URLs like /users/123

  • Requires server SPA fallback
  • Better for SEO
  • Use mode: 'history'

Defining Routes

// Basic routes
router.route('/', () => mount('#app', Home))
router.route('/about', () => mount('#app', About))

// Route parameters
router.route('/users/:id', (to) => {
  console.log(to.params.id)  // "123" for /users/123
  mount('#app', UserDetail, { id: to.params.id })
})

// Multiple parameters
router.route('/users/:userId/posts/:postId', (to) => {
  console.log(to.params)  // { userId: "1", postId: "5" }
})

// Route with options
router.route('/admin', (to) => mount('#app', Admin), {
  name: 'admin',
  meta: { requiresAuth: true, roles: ['admin'] },
  guards: [adminGuard]
})

// Nested routes
router.route('/dashboard', (to) => mount('#app', Dashboard), {
  children: [
    { path: '/overview', handler: () => mount('#content', Overview) },
    { path: '/settings', handler: () => mount('#content', Settings) }
  ]
})

Navigation Guards

Guards run before navigation completes, allowing access control.

// Global guard
router.guard(async (to, from, next) => {
  if (to.meta?.requiresAuth && !isLoggedIn()) {
    router.state.redirectAfterAuth = to.path
    return next('/login')  // Redirect
  }
  next()  // Allow navigation
})

// Role guard
router.guard(async (to, from, next) => {
  if (to.meta?.roles) {
    const hasRole = to.meta.roles.includes(user?.role)
    if (!hasRole) return next('/unauthorized')
  }
  next()
})

// Helper methods
router.requireAuth(() => authService.isAuthenticated(), '/login')
router.requireRole(() => user.roles, '/unauthorized')

Middleware

Middleware runs after guards pass but cannot cancel navigation. Use it for side effects like analytics, logging, or updating page metadata.

Guards vs Middleware

Guards can block/redirect navigation. Middleware cannot — it only runs side effects after access is granted.

// Basic middleware - runs on every navigation
router.use(async (to, from) => {
  console.log(`Navigation: ${from?.path || 'initial'} → ${to.path}`)
})

// Page title middleware
router.use(async (to) => {
  document.title = to.meta?.title || 'My App'
})

// Analytics tracking
router.use(async (to, from) => {
  analytics.trackPageView({
    path: to.path,
    referrer: from?.path,
    params: to.params
  })
})

Real-World Middleware Examples

// Loading indicator middleware
router.use(async (to, from) => {
  // Show loading state while page renders
  document.body.classList.add('page-loading')

  // Remove after a tick (let render complete)
  requestAnimationFrame(() => {
    document.body.classList.remove('page-loading')
  })
})

// Breadcrumb middleware
router.use(async (to) => {
  const breadcrumbs = []
  const segments = to.path.split('/').filter(Boolean)

  let currentPath = ''
  for (const segment of segments) {
    currentPath += '/' + segment
    breadcrumbs.push({
      label: segment.charAt(0).toUpperCase() + segment.slice(1),
      path: currentPath
    })
  }

  // Update global state or emit event
  window.dispatchEvent(new CustomEvent('breadcrumbs', {
    detail: breadcrumbs
  }))
})

// Session activity tracker
router.use(async (to) => {
  // Update last activity timestamp
  sessionStorage.setItem('lastActivity', Date.now().toString())

  // Extend session if needed
  if (to.meta?.requiresAuth) {
    await authService.refreshSession()
  }
})

Security-Focused Middleware

Use middleware for security logging and monitoring (but use guards for actual access control).

// Security audit logging
router.use(async (to, from) => {
  // Log access to sensitive routes
  if (to.meta?.sensitive) {
    await securityLog.record({
      action: 'page_access',
      path: to.path,
      userId: auth.user?.id,
      timestamp: new Date().toISOString(),
      previousPath: from?.path
    })
  }
})

// CSP nonce rotation (for inline scripts)
router.use(async (to) => {
  // Generate fresh nonce for each navigation
  const nonce = crypto.randomUUID()
  document.querySelectorAll('script[data-dynamic]').forEach(s => {
    s.nonce = nonce
  })
})

// Feature flag middleware
router.use(async (to) => {
  // Check if route requires specific feature flags
  if (to.meta?.featureFlag) {
    const enabled = await featureFlags.isEnabled(to.meta.featureFlag)
    if (!enabled) {
      // Log attempted access to disabled feature
      console.warn(`Feature "${to.meta.featureFlag}" accessed but disabled`)
    }
  }
})

Events

// Navigation completed
router.on('navigate', ({ to, from }) => {
  console.log('Navigated to:', to.path)
})

// Route not found
router.on('notFound', ({ path }) => {
  mount('#app', NotFound)
})

// Error in route handler
router.on('error', ({ error, route }) => {
  console.error('Route error:', error)
  mount('#app', ErrorPage, { error })
})

Security Features

Built-in Protection

The router automatically sanitizes all URLs passed to navigate(), protecting against common client-side attacks.

URL Sanitization

Blocks javascript: and data: URLs to prevent XSS attacks

Path Encoding

Automatic encoding/decoding of path parameters prevents injection attacks

Open Redirect Prevention

External URLs are stripped to path-only, preventing open redirect vulnerabilities

How URL Sanitization Works

// These malicious URLs are blocked:
router.navigate('javascript:alert(1)')    // → '/'
router.navigate('data:text/html,<script>') // → '/'

// External URLs are converted to paths (prevents open redirect):
router.navigate('https://evil.com/steal') // → '/steal' (path only)

// Only the path portion is preserved:
router.navigate('https://evil.com/page?token=abc')
// → '/page?token=abc' (origin stripped)

SecurityUtils API

Use these utilities for custom validation in guards and middleware.

import { SecurityUtils } from './build/router.esm.js'

// Sanitize any URL (returns safe path)
const safePath = SecurityUtils.sanitizeUrl(userInput)

// Encode path segments for safe URLs
SecurityUtils.encodePath('/users/John Doe')
// → '/users/John%20Doe'

// Safely decode paths
SecurityUtils.decodePath('/users/John%20Doe')
// → '/users/John Doe'

// Check if a redirect URL is same-origin
if (SecurityUtils.isValidRedirect(redirectUrl)) {
  router.navigate(redirectUrl)
} else {
  console.warn('Blocked cross-origin redirect')
  router.navigate('/')
}

Security Best Practices

Always validate user input

While the router sanitizes paths, always validate route parameters before using them in API calls or database queries.

Use guards for access control

Never rely solely on hiding links. Guards ensure routes are protected even when accessed directly.

Validate redirect targets

When storing redirect paths (like redirectAfterAuth), validate them before navigation.

Server-side validation required

Client-side guards can be bypassed. Always verify permissions server-side for sensitive operations.

Secure Guard Pattern

// Secure authentication guard with redirect validation
router.guard(async (to, from, next) => {
  if (to.meta?.requiresAuth && !auth.isAuthenticated()) {
    // Store intended destination securely
    const safePath = SecurityUtils.sanitizeUrl(to.path)
    router.state._redirectAfterAuth = safePath // underscore = private (not in URL)
    return next('/login')
  }
  next()
})

// After login - validate the redirect
async function handleLoginSuccess() {
  const redirect = router.state._redirectAfterAuth
  delete router.state._redirectAfterAuth

  // Validate before navigating
  if (redirect && SecurityUtils.isValidRedirect(redirect)) {
    router.navigate(redirect)
  } else {
    router.navigate('/dashboard') // Safe default
  }
}

API Reference

MethodDescription
route(path, handler, options?)Define a route
guard(fn)Add navigation guard
use(fn)Add middleware
start()Start router
navigate(path)Navigate to path
replace(path)Replace without history
back() / forward() / go(n)History navigation
on(event, handler)Listen for event