Client-side routing patterns using the History API — managing URL state without full page reloads, building provider-optional routing hooks, SSR-safe browser API access, scroll behavior, and parameter parsing. Covers both the architectural patterns (custom routechange events, popstate listeners) and practical hook design for zero-dependency routing libraries. Use when building or extending routing systems, debugging navigation bugs, or working with @page-speed/router patterns.
Scanned 9/3/2026
Install to Claude Code
npx -y skills add opensite-ai/opensite-skills --skill client-side-routing-patterns --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Client Side Routing Patterns?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/opensite-ai-client-side-routing-patterns)More formats (shields.io, HTML) on the badges page.
---
name: client-side-routing-patterns
description: >
Client-side routing patterns using the History API — managing URL state
without full page reloads, building provider-optional routing hooks, SSR-safe
browser API access, scroll behavior, and parameter parsing. Covers both the
architectural patterns (custom routechange events, popstate listeners) and
practical hook design for zero-dependency routing libraries. Use when building
or extending routing systems, debugging navigation bugs, or working with
@page-speed/router patterns.
compatibility: >
Requires a JavaScript or TypeScript frontend repo; browser access helps when
validating navigation behavior.
metadata:
opensite-category: frontend
opensite-scope: shared
opensite-visibility: public
---
# Client-Side Routing Patterns
## Skill Resources
- Activation and cross-agent notes: [references/activation.md](references/activation.md)
Modern client-side routing is built on two browser primitives: `history.pushState` / `history.replaceState` and the `popstate` event. Understanding these directly makes every router less magical and easier to debug.
---
## History API Fundamentals
```javascript
// pushState: add a new history entry (browser Back button goes back)
history.pushState(
{ key: 'some-state' }, // State object (serializable; retrieve via history.state)
'', // Title — browsers ignore this; pass empty string
'/new-path?q=foo', // URL to display
);
// replaceState: update current entry without creating a Back entry
history.replaceState(state, '', '/same-path?q=updated');
// Reading current state
const currentState = history.state; // { key: 'some-state' }
const currentUrl = window.location.href;
const pathname = window.location.pathname;
const search = window.location.search; // '?q=foo'
const hash = window.location.hash; // '#section-2'
```
### The `popstate` Event
`popstate` fires when the user navigates backward or forward via the browser buttons, or when `history.go()` is called. It does **not** fire on `pushState` or `replaceState` calls — those are silent by default.
```javascript
window.addEventListener('popstate', (event) => {
// event.state is whatever was passed to pushState/replaceState
console.log('Navigated to:', window.location.pathname, 'State:', event.state);
syncAppToCurrentUrl(); // Update your UI to match the new URL
});
```
---
## Custom `routechange` Event Pattern
Since `pushState` and `replaceState` don't fire any browser event, routing libraries need to dispatch their own. The standard pattern:
```javascript
// When performing programmatic navigation:
function navigate(path, options = {}) {
const { replace = false, state = {} } = options;
if (replace) {
history.replaceState(state, '', path);
} else {
history.pushState(state, '', path);
}
// Dispatch a custom event so all listeners know the URL changed
window.dispatchEvent(new PopStateEvent('routechange', { state }));
}
// Listeners subscribe to both popstate (user back/forward) and routechange (programmatic)
function subscribeToNavigation(callback) {
window.addEventListener('popstate', callback);
window.addEventListener('routechange', callback);
return () => {
window.removeEventListener('popstate', callback);
window.removeEventListener('routechange', callback);
};
}
```
This keeps the listening code uniform — it doesn't matter whether the URL changed from a button click, `history.back()`, or a programmatic `navigate()` call.
---
## SSR-Safe Browser API Access
Routing code runs in both Node.js (SSR/build) and the browser. `window`, `location`, and `history` do not exist in Node.js — guard every access.
```javascript
// Pattern 1: isBrowser guard
export const isBrowser = typeof window !== 'undefined';
export function getCurrentPath() {
return isBrowser ? window.location.pathname : '/';
}
// Pattern 2: ssrSafe wrapper — returns fallback when not in browser
export function ssrSafe(browserFn, fallback) {
return isBrowser ? browserFn() : fallback;
}
const currentUrl = ssrSafe(() => window.location.href, 'http://localhost/');
// Pattern 3: In React hooks — only access browser APIs in effects (not render)
function useCurrentPath() {
const [path, setPath] = useState(() =>
typeof window !== 'undefined' ? window.location.pathname : '/'
);
useEffect(() => {
const unsubscribe = subscribeToNavigation(() => {
setPath(window.location.pathname);
});
return unsubscribe;
}, []);
return path;
}
```
---
## Provider-Optional Hook Design
The best routing hooks work with or without a provider — they fall through to direct browser API calls when no RouterContext is present. This makes them usable in any context without requiring wrapper setup.
```javascript
import { useContext } from 'react';
import { RouterContext } from './RouterProvider';
// Hook reads from context if available, falls back to browser APIs
export function usePathname() {
const ctx = useContext(RouterContext);
// If inside a RouterProvider, use context value (reactive to updates)
if (ctx) return ctx.pathname;
// If outside a provider (SSR, tests, isolated usage), read directly
return typeof window !== 'undefined' ? window.location.pathname : '/';
}
export function useSearchParams() {
const ctx = useContext(RouterContext);
if (ctx) return ctx.searchParams;
return typeof window !== 'undefined'
? new URLSearchParams(window.location.search)
: new URLSearchParams();
}
```
### Memoized Context Value
When building a `RouterProvider`, memoize the context value to prevent re-renders in every consumer on every navigation:
```jsx
function RouterProvider({ children }) {
const [urlState, setUrlState] = useState(getCurrentUrlState);
useEffect(() => {
const update = () => setUrlState(getCurrentUrlState());
window.addEventListener('popstate', update);
window.addEventListener('routechange', update);
return () => {
window.removeEventListener('popstate', update);
window.removeEventListener('routechange', update);
};
}, []);
// Memoize so object identity only changes when URL actually changes
const contextValue = useMemo(() => ({
pathname: urlState.pathname,
search: urlState.search,
hash: urlState.hash,
searchParams: new URLSearchParams(urlState.search),
navigate,
}), [urlState]);
return (
<RouterContext.Provider value={contextValue}>
{children}
</RouterContext.Provider>
);
}
function getCurrentUrlState() {
if (typeof window === 'undefined') {
return { pathname: '/', search: '', hash: '' };
}
return {
pathname: window.location.pathname,
search: window.location.search,
hash: window.location.hash,
};
}
```
---
## Path Matching and Parameter Extraction
```javascript
// Match a path pattern against the current URL
// Returns null if no match, or an object of extracted params
export function matchPath(pattern, pathname) {
// Convert :param segments to named capture groups
const regexStr = pattern
.replace(/:[^/]+/g, '([^/]+)') // :id → ([^/]+)
.replace(/\*/g, '(.*)'); // * → (.*)
const paramNames = (pattern.match(/:[^/]+/g) || [])
.map(p => p.slice(1)); // strip leading ':'
const match = pathname.match(new RegExp(`^${regexStr}$`));
if (!match) return null;
const params = Object.fromEntries(
paramNames.map((name, i) => [name, decodeURIComponent(match[i + 1])])
);
return { params, pathname };
}
// Usage
matchPath('/restaurants/:id/menu', '/restaurants/42/menu');
// → { params: { id: '42' }, pathname: '/restaurants/42/menu' }
matchPath('/restaurants/:id/menu', '/restaurants/42/orders');
// → null
```
### `useRouteMatch` Hook
```javascript
export function useRouteMatch(pattern) {
const pathname = usePathname();
return useMemo(() => matchPath(pattern, pathname), [pattern, pathname]);
}
export function useIsActive(pattern, exact = false) {
const pathname = usePathname();
if (exact) return pathname === pattern;
return pathname.startsWith(pattern);
}
// Usage in NavLink component
function NavLink({ to, children, exact }) {
const isActive = useIsActive(to, exact);
const navigate = useNavigate();
return (
<a
href={to}
aria-current={isActive ? 'page' : undefined}
onClick={(e) => {
e.preventDefault();
navigate(to);
}}
>
{children}
</a>
);
}
```
---
## Search Parameter Management
```javascript
// Parse: URLSearchParams → plain object
export function parseSearchParams(search) {
const params = new URLSearchParams(search);
const result = {};
for (const [key, value] of params.entries()) {
if (key in result) {
result[key] = Array.isArray(result[key])
? [...result[key], value]
: [result[key], value];
} else {
result[key] = value;
}
}
return result;
}
// Update search params without wiping unrelated params
export function useUpdateSearchParams() {
const navigate = useNavigate();
return useCallback((updates) => {
const current = new URLSearchParams(window.location.search);
for (const [key, value] of Object.entries(updates)) {
if (value === null || value === undefined) {
current.delete(key);
} else {
current.set(key, String(value));
}
}
const newSearch = current.toString();
navigate(
`${window.location.pathname}${newSearch ? `?${newSearch}` : ''}`,
{ replace: true }, // Don't create a history entry for filter changes
);
}, [navigate]);
}
// Usage
const updateParams = useUpdateSearchParams();
updateParams({ page: 2, filter: 'pending' }); // Keeps existing params, updates these
updateParams({ filter: null }); // Removes the filter param
```
---
## Scroll Behavior
### Scroll to Top on Navigation
```javascript
function navigate(path, options = {}) {
const { replace = false, scroll = true, hash } = options;
history[replace ? 'replaceState' : 'pushState']({}, '', path);
window.dispatchEvent(new PopStateEvent('routechange'));
if (hash) {
// Scroll to anchor after next render
requestAnimationFrame(() => scrollToAnchor(hash));
} else if (scroll) {
window.scrollTo({ top: 0, behavior: 'instant' });
}
}
```
### Scroll to Anchor
```javascript
export function scrollToAnchor(id, options = {}) {
const { behavior = 'smooth', offset = 0 } = options;
const element = document.getElementById(id) ?? document.querySelector(`[name="${id}"]`);
if (!element) return;
const top = element.getBoundingClientRect().top + window.scrollY - offset;
window.scrollTo({ top, behavior });
}
```
### Preserve Scroll Position for Back Navigation
```javascript
// Save scroll position in history state before navigating away
function navigateWithScrollSave(to) {
const scrollY = window.scrollY;
history.replaceState({ ...history.state, scrollY }, '');
navigate(to);
}
// Restore on popstate
window.addEventListener('popstate', (event) => {
if (event.state?.scrollY !== undefined) {
requestAnimationFrame(() => {
window.scrollTo({ top: event.state.scrollY, behavior: 'instant' });
});
}
});
```
---
## `useNavigation` Core Hook
The central navigation hook wraps `pushState`/`replaceState` with scroll handling and same-path detection:
```javascript
export function useNavigate() {
return useCallback((to, options = {}) => {
if (!isBrowser) return;
const { replace = false, scroll = true } = options;
const isSamePath = to === window.location.pathname + window.location.search;
if (isSamePath && !replace) return; // Avoid duplicate history entries
if (replace || isSamePath) {
history.replaceState({}, '', to);
} else {
history.pushState({}, '', to);
}
window.dispatchEvent(new PopStateEvent('routechange'));
if (scroll) {
window.scrollTo({ top: 0, behavior: 'instant' });
}
}, []);
}
```
---
## Common Routing Bugs
| Bug | Symptom | Fix |
|-----|---------|-----|
| Accessing `window` during SSR | `ReferenceError: window is not defined` | Guard with `typeof window !== 'undefined'` |
| `pushState` doesn't update UI | URL changes, component doesn't re-render | Dispatch `routechange` event after `pushState` |
| Back button doesn't work | `popstate` not subscribed | Add `popstate` listener in addition to `routechange` |
| Duplicate history entries on same-path navigate | User clicks Back and nothing happens | Check `isSamePath` before `pushState`; use `replaceState` for same-path |
| Params not decoded | Space shows as `%20` | Always `decodeURIComponent()` extracted params |
| New URLSearchParams object on every render | Consumer re-renders constantly | Memoize the URLSearchParams object in context |
| Missing cleanup in useEffect | Memory leak; double-subscription | Always return cleanup function from navigation useEffect |
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!