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
Stop doing full page loads. Use the History API to change the URL without actually navigating, then swap out just the content that needs to change. Intercept link clicks, call pushState to update the URL, and render the new route’s component; when users hit back, listen for popstate and render the previous route.
This preserves everything (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';
function App() {
const navigate = useNavigate();
const handleLogin = async (credentials) => {
await login(credentials);
navigate('/dashboard'); // Programmatic navigation
};
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="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
);
} <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>
<script>
import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: Home },
{ path: '/about', component: About },
{
path: '/dashboard',
component: Dashboard,
beforeEnter: (to, from) => {
if (!isAuthenticated()) return '/login';
}
}
]
});
export default { router };
</script> <script>
import { Router, Route, Link, navigate } from 'svelte-routing';
import Home from './Home.svelte';
import About from './About.svelte';
function handleLogin() {
navigate('/dashboard', { replace: true });
}
</script>
<Router>
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
</nav>
<Route path="/" component={Home} />
<Route path="/about" component={About} />
</Router> class Router {
constructor(routes) {
this.routes = routes;
document.addEventListener('click', (e) => {
if (e.target.matches('a[href^="/"]')) {
e.preventDefault();
this.navigate(e.target.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
- Instant navigation without white flash or loading indicators. Click and you’re there.
- 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 consider server-side fallbacks.
- 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: clean up listeners and subscriptions 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. This creates fluid single-page experiences with proper browser history and shareable URLs.