Client-Side Routing
Handle navigation without full page reloads, so single-page apps keep their state between views.
In this article
Client-Side Routing
Problem
Every click triggers a full page reload with all its consequences: the white flash, scroll jumping to top, form data disappearing, videos stopping, accordions collapsing. This is what web development looked like for decades, and it feels terrible compared to native apps where navigation is instant and everything stays put.
I’ve watched users lose their work because they accidentally clicked a link before saving. That spinning loading indicator on every navigation makes everything feel sluggish, even when content loads quickly.
Solution
Client-side routing avoids full page loads. The History API changes the URL without navigating, and the app swaps out only the content that needs to change. The router intercepts link clicks, calls pushState to update the URL, and renders the new route’s component; when users hit back, a popstate listener renders the previous route.
Everything outside the swapped content stays as it was (form state, scroll position, expanded accordions, playing media), because you’re swapping components in place rather than tearing down the entire page. Router libraries wrap this in declarative APIs: you map URL patterns to components, and the library handles the History API plumbing.
Example
Here’s client-side routing across frameworks: declarative route configuration, programmatic navigation, and scroll management.
Basic Router Setup
import { BrowserRouter, Routes, Route, Link, useNavigate } from 'react-router-dom';
// useNavigate() only works inside <BrowserRouter>, so it lives in a route component
function Login() {
const navigate = useNavigate();
const handleLogin = async (credentials) => {
await login(credentials);
navigate('/dashboard'); // Programmatic navigation
};
return <LoginForm onSubmit={handleLogin} />;
}
function App() {
return (
<BrowserRouter>
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
<Link to="/products">Products</Link>
</nav>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/products" element={<Products />} />
<Route path="/login" element={<Login />} />
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
);
} <!-- App.vue -->
<template>
<div>
<nav>
<router-link to="/">Home</router-link>
<router-link to="/about">About</router-link>
<router-link to="/dashboard">Dashboard</router-link>
</nav>
<router-view />
</div>
</template>// main.js: the router is installed on the app, not declared inside a component
import { createApp } from 'vue';
import { createRouter, createWebHistory } from 'vue-router';
import App from './App.vue';
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: Home },
{ path: '/about', component: About },
{
path: '/dashboard',
component: Dashboard,
beforeEnter: (to, from) => {
if (!isAuthenticated()) return '/login';
}
}
]
});
createApp(App).use(router).mount('#app'); <!-- src/routes/+layout.svelte -->
<script>
import { page } from '$app/state';
// SvelteKit passes the page matching the URL as the children snippet
let { children } = $props();
</script>
<!-- Plain links work: SvelteKit intercepts same-origin clicks and routes client-side -->
<nav>
<a href="/" aria-current={page.url.pathname === '/' ? 'page' : undefined}>Home</a>
<a href="/about" aria-current={page.url.pathname === '/about' ? 'page' : undefined}>About</a>
</nav>
<!-- src/routes/+page.svelte renders /, src/routes/about/+page.svelte renders /about -->
{@render children()}<!-- src/routes/login/+page.svelte -->
<script>
import { goto } from '$app/navigation';
async function handleLogin(event) {
event.preventDefault();
await login(new FormData(event.currentTarget));
goto('/dashboard'); // Programmatic navigation
}
</script>
<form onsubmit={handleLogin}>
<input name="email" type="email" />
<button>Log in</button>
</form> class Router {
constructor(routes) {
this.routes = routes;
document.addEventListener('click', (e) => {
// closest() also catches clicks on elements nested inside the link
const link = e.target.closest('a[href^="/"]');
if (link) {
e.preventDefault();
this.navigate(link.getAttribute('href'));
}
});
window.addEventListener('popstate', () => this.render(location.pathname));
this.render(location.pathname);
}
navigate(path) {
history.pushState({}, '', path);
this.render(path);
}
render(path) {
const route = this.routes.find(r => r.path === path)
|| this.routes.find(r => r.path === '*');
if (!route) return;
const container = document.querySelector('#app');
container.innerHTML = '';
container.appendChild(new route.component().render());
}
}
const router = new Router([
{ path: '/', component: HomePage },
{ path: '/about', component: AboutPage },
{ path: '*', component: NotFoundPage }
]); Scroll Position Management
// Scroll to top on route change
function ScrollToTop() {
const { pathname } = useLocation();
useEffect(() => window.scrollTo(0, 0), [pathname]);
return null;
}
// Or preserve scroll per route
const scrollPositions = new Map();
function preserveScroll() {
scrollPositions.set(location.pathname, window.scrollY);
}
function restoreScroll(pathname) {
window.scrollTo(0, scrollPositions.get(pathname) || 0);
}
Hash-Based Routing
// For environments without server config (URLs: example.com/#/about)
window.addEventListener('hashchange', () => {
render(location.hash.slice(1) || '/');
});
Benefits
- Faster navigation without the white flash and full reload on every click.
- State persists across navigation: form data, scroll position, expanded UI, playing videos.
- Animated transitions become possible since the DOM stays continuous between views.
- Server load drops because you’re fetching data, not re-rendering entire HTML pages.
- Precise control over route guards, programmatic navigation, and transition hooks.
Tradeoffs
- Requires JavaScript: the entire system breaks without it, so server-side fallbacks are worth considering.
- Server configuration needed: direct URL access must serve your app, not 404.
- SEO complexity since crawlers may not execute JS; you might need SSR or prerendering.
- Memory management falls on you: listeners and subscriptions need cleaning up when components unmount.
- Manual scroll and focus handling: browsers do this automatically for page loads, but not for client-side transitions.
- History quirks if you mix up
pushStateandreplaceState, creating confusing back-button behavior.
Summary
Client-side routing intercepts link clicks, updates the URL via the History API, and swaps content without page reloads. The back button keeps working, and every view still has a URL people can share.