Saved
Frontend Pattern

Client-Side Routing

Handle navigation without full page reloads for seamless single-page application experiences.

Difficulty Intermediate

By Den Odell

Client-Side Routing

Problem

Every navigation triggers a full page reload, destroying all application state and causing disruptive white flashes between pages. Users lose their place in forms, scroll positions reset to the top, and interactive features like expanded accordions or selected tabs restart from scratch. Media players stop playing, animations restart, and any data stored in memory disappears. The experience feels slow and janky compared to native applications where navigation between screens is instant and state persists. Form data entered but not submitted is lost when users accidentally navigate away, and the browser’s loading indicator appears on every click, creating the perception of a sluggish application even when content loads quickly.

Solution

Use the History API (pushState, replaceState) to update the browser URL and conditionally render components based on the current path, all without triggering actual browser navigation.

Intercept link clicks to prevent default browser behavior and instead update the URL programmatically, then render the appropriate component for the new path. Listen to the popstate event to handle browser back and forward button clicks, ensuring the UI updates to match the URL when users navigate through history.

This preserves application state across navigation, eliminates loading flashes, and creates instant transitions between views. Router libraries abstract these mechanics behind declarative routing APIs that map URL patterns to components.

Example

This example shows client-side routing implementations that handle navigation without page reloads.

React Router

import { BrowserRouter, Routes, Route, Link, useNavigate } from 'react-router-dom';

function App() {
  return (
    <BrowserRouter>
      <nav>
        {/* Link prevents default navigation and uses History API */}
        <Link to="/">Home</Link>
        <Link to="/about">About</Link>
        <Link to="/products">Products</Link>
      </nav>

      {/* Routes render components based on current path */}
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
        <Route path="/products" element={<Products />} />
        <Route path="*" element={<NotFound />} />
      </Routes>
    </BrowserRouter>
  );
}

// Programmatic navigation
function LoginForm() {
  const navigate = useNavigate();

  const handleSubmit = async (credentials) => {
    await login(credentials);
    // Navigate after successful login
    navigate('/dashboard');
  };

  return <form onSubmit={handleSubmit}>{/* form fields */}</form>;
}

Vue Router with Navigation Guards

<template>
  <div>
    <nav>
      <!-- router-link prevents default navigation -->
      <router-link to="/">Home</router-link>
      <router-link to="/about">About</router-link>
      <router-link to="/dashboard">Dashboard</router-link>
    </nav>

    <!-- router-view renders component for current route -->
    <router-view />
  </div>
</template>

<script>
import { createRouter, createWebHistory } from 'vue-router';

const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/', component: Home },
    { path: '/about', component: About },
    {
      path: '/dashboard',
      component: Dashboard,
      // Route guard - check auth before navigating
      beforeEnter: (to, from) => {
        if (!isAuthenticated()) {
          return '/login'; // Redirect to login
        }
      }
    }
  ]
});

// Global navigation guard
router.beforeEach((to, from) => {
  // Log analytics
  trackPageView(to.path);
});

export default {
  router
};
</script>

Programmatic Navigation in Vue

import { useRouter } from 'vue-router';

export default {
  setup() {
    const router = useRouter();

    const goToProduct = (productId) => {
      // Push new URL to history
      router.push(`/products/${productId}`);
    };

    const goBack = () => {
      // Navigate back in history
      router.back();
    };

    const replaceRoute = () => {
      // Replace current URL without adding to history
      router.replace('/new-path');
    };

    return { goToProduct, goBack, replaceRoute };
  }
};

Svelte with svelte-routing

<!-- App.svelte -->
<script>
  import { Router, Route, Link, navigate } from 'svelte-routing';
  import Home from './Home.svelte';
  import About from './About.svelte';

  function handleLogin() {
    // Programmatic navigation after login
    navigate('/dashboard', { replace: true });
  }
</script>

<Router>
  <nav>
    <!-- Link prevents default navigation -->
    <Link to="/">Home</Link>
    <Link to="/about">About</Link>
  </nav>

  <!-- Routes render components based on path -->
  <Route path="/" component={Home} />
  <Route path="/about" component={About} />
</Router>

Vanilla JavaScript Router Implementation

class Router {
  constructor(routes) {
    this.routes = routes;
    this.currentRoute = null;

    // Intercept all link clicks
    document.addEventListener('click', (e) => {
      if (e.target.matches('a[href]')) {
        const href = e.target.getAttribute('href');
        // Only handle internal links
        if (href.startsWith('/')) {
          e.preventDefault();
          this.navigate(href);
        }
      }
    });

    // Handle browser back/forward buttons
    window.addEventListener('popstate', () => {
      this.render(window.location.pathname);
    });

    // Render initial route
    this.render(window.location.pathname);
  }

  navigate(path) {
    // Update browser history without page reload
    window.history.pushState({}, '', path);
    this.render(path);
  }

  render(path) {
    // Find matching route
    const route = this.routes.find(r => r.path === path) || this.routes.find(r => r.path === '*');
    
    if (route) {
      // Clean up previous route
      if (this.currentRoute && this.currentRoute.cleanup) {
        this.currentRoute.cleanup();
      }

      // Render new route
      const container = document.querySelector('#app');
      container.innerHTML = '';
      const component = new route.component();
      container.appendChild(component.render());

      this.currentRoute = route;
    }
  }
}

// Usage
const router = new Router([
  { path: '/', component: HomePage },
  { path: '/about', component: AboutPage },
  { path: '/products', component: ProductsPage },
  { path: '*', component: NotFoundPage }
]);

Advanced Pattern: Route Transition Hooks

// React Router with transition tracking
function App() {
  const location = useLocation();
  const [isTransitioning, setIsTransitioning] = useState(false);

  useEffect(() => {
    // Start transition
    setIsTransitioning(true);
    
    // End transition after component renders
    const timer = setTimeout(() => setIsTransitioning(false), 300);
    return () => clearTimeout(timer);
  }, [location]);

  return (
    <div className={isTransitioning ? 'transitioning' : ''}>
      <Routes>{/* routes */}</Routes>
    </div>
  );
}

Preserving Scroll Position

// React Router scroll restoration
function ScrollToTop() {
  const { pathname } = useLocation();

  useEffect(() => {
    // Scroll to top on route change
    window.scrollTo(0, 0);
  }, [pathname]);

  return null;
}

// Or preserve scroll per route
const scrollPositions = new Map();

function preserveScroll() {
  // Save current scroll position
  scrollPositions.set(location.pathname, window.scrollY);
}

function restoreScroll(pathname) {
  // Restore saved scroll position
  const position = scrollPositions.get(pathname) || 0;
  window.scrollTo(0, position);
}

Hash-Based Routing Alternative

// Use hash instead of History API for environments without server config
function navigate(path) {
  window.location.hash = path;
}

window.addEventListener('hashchange', () => {
  const path = window.location.hash.slice(1) || '/';
  render(getCurrentRoute(path));
});

// URLs look like: example.com/#/about

Benefits

  • Eliminates page reloads for instant navigation and dramatically improved user experience with zero latency between route transitions.
  • Preserves application state across navigation including form data, scroll positions, expanded UI elements, and media playback state.
  • Creates smooth transitions between views without white flashes or loading indicators, maintaining visual continuity.
  • Enables sophisticated UX patterns like animated page transitions, slide-in panels, and cross-fade effects that are impossible with full page reloads.
  • Reduces server load by avoiding full page refreshes - only data needs to be fetched rather than rendering entire HTML pages on every navigation.
  • Improves perceived performance since the application shell (header, navigation, footer) persists while only the main content area updates.
  • Allows fine-grained control over navigation behavior through programmatic navigation, route guards, and transition hooks.

Tradeoffs

  • Requires JavaScript to function completely - the entire routing system breaks for users without JavaScript enabled, requiring server-side fallbacks or progressive enhancement strategies.
  • Initial page load must include routing logic and all route handling code, increasing the JavaScript bundle size compared to traditional multi-page applications where each page loads independently.
  • Must carefully manage memory to avoid leaks when components unmount - event listeners, timers, and subscriptions must be cleaned up or memory usage grows unbounded as users navigate.
  • Server-side configuration required to handle direct URL access and page refreshes - the server must serve the application shell for all routes rather than returning 404 errors for client-side paths, requiring catch-all rules in web server configuration.
  • Can complicate SEO and analytics tracking compared to traditional navigation - search engine crawlers may not execute JavaScript to discover routes, and analytics must explicitly track route changes rather than relying on page load events.
  • Browser back/forward behavior can become unpredictable if route state is not managed carefully - using replaceState vs pushState incorrectly can break expected back button behavior or create confusing history stacks.
  • Focus management requires manual implementation - when routes change, keyboard focus may remain on elements from the previous route or land nowhere, requiring explicit focus management code for accessibility.
  • Scroll restoration behavior must be implemented manually - browsers handle scroll position automatically for traditional navigation but client-side routers must decide whether to scroll to top, preserve position, or restore previous position on back navigation.
  • Large applications with many routes face bundle size challenges - either all route components load upfront (slow initial load) or code splitting is required to lazy load routes (additional complexity).
  • Route guards and middleware can create nested layers of navigation logic that become difficult to debug - determining why a navigation was blocked or redirected requires tracing through multiple guard functions.
  • The History API has cross-browser quirks - pushState behavior varies slightly across browsers, and hash-based routing fallbacks may be needed for legacy environments or static hosting without server-side configuration support.
Stay Updated

Get New Patterns
in Your Inbox

Join thousands of developers receiving regular insights on frontend architecture patterns

No spam. Unsubscribe anytime.