AdSense Leaderboard

Google AdSense Placeholder

HEADER SLOT

How Client-Side Routing Works: From URLs to Components

Last updated:
How Client-Side Routing Works: From URLs to Components

Master client-side routing internals. Learn History API, route matching, URL parsing, and build a custom router from scratch with React.

Table of Contents

# How Client-Side Routing Works: From URLs to Components

When you click a link in a React application and the page changes instantly without a full reload, that's client-side routing at work. But what's actually happening under the hood? How does the browser know to render a different component when the URL changes? And why does the back button work without you implementing anything?

Understanding client-side routing mechanics is crucial for debugging navigation issues, building custom routers, and appreciating why libraries like React Router exist. Let's tear open the engine and see how it all works.

# Table of Contents

  1. The Problem Client-Side Routing Solves
  2. Browser History API: The Foundation
  3. URL Synchronization: Watching for Changes
  4. Route Matching: Finding the Right Component
  5. Building a Simple Router from Scratch
  6. Advanced Routing: Nested Routes and Parameters
  7. Handling Navigation Events
  8. Performance Considerations
  9. Real-World Routing Patterns
  10. FAQ

# The Problem Client-Side Routing Solves {#the-problem}

In traditional server-side applications, every navigation triggers a full HTTP request. The browser unloads the current page, requests a new one from the server, parses the HTML, loads CSS and JavaScript, and renders everything from scratch.

This works, but it has problems:

  1. Slow - Network latency, server processing, page parse time
  2. Jarring - The page flickers and reloads, losing scroll position
  3. State loss - Any client-side state (form inputs, scroll position, animations) is lost
  4. Bandwidth waste - Even if two pages share a layout, both are fully redownloaded

Client-side routing solves this by:

  • Keeping the application loaded in memory
  • Changing only what needs to change
  • Preserving state across "page" transitions
  • Making navigation instant

# TypeScript Version: The Problem Illustrated

typescript
// Server-side navigation (traditional)
// When you click a link:
// 1. Full page reload
// 2. All state lost
// 3. CSS re-parsed
// 4. JS re-executed
// 5. Components re-mounted

// Client-side routing
// When you click a link:
// 1. URL changes (browser history updated)
// 2. Component re-renders
// 3. Everything else stays in memory
// 4. Navigation is instant

interface NavigationComparison {
  serverSide: {
    time: number; // 500-2000ms
    stateLoss: boolean; // true
    renderTime: number; // Full page
  };
  clientSide: {
    time: number; // 10-100ms
    stateLoss: boolean; // false
    renderTime: number; // Only changed components
  };
}

export function NavigationComparison() {
  const comparison: NavigationComparison = {
    serverSide: {
      time: 1000,
      stateLoss: true,
      renderTime: 2000,
    },
    clientSide: {
      time: 50,
      stateLoss: false,
      renderTime: 50,
    },
  };

  return (
    <div>
      <p>Server-side navigation: {comparison.serverSide.time}ms</p>
      <p>Client-side navigation: {comparison.clientSide.time}ms</p>
      <p>Client-side is {comparison.serverSide.time / comparison.clientSide.time}x faster</p>
    </div>
  );
}

# JavaScript Version

javascript
export function NavigationComparison() {
  const comparison = {
    serverSide: {
      time: 1000,
      stateLoss: true,
      renderTime: 2000,
    },
    clientSide: {
      time: 50,
      stateLoss: false,
      renderTime: 50,
    },
  };

  return (
    <div>
      <p>Server-side navigation: {comparison.serverSide.time}ms</p>
      <p>Client-side navigation: {comparison.clientSide.time}ms</p>
      <p>Client-side is {(comparison.serverSide.time / comparison.clientSide.time).toFixed(0)}x faster</p>
    </div>
  );
}

# Browser History API: The Foundation {#history-api}

Client-side routing is built on the History API. This JavaScript API lets you manipulate the browser's session history (the list of URLs visited in the browser tab) and change the URL without triggering a page reload.

# The Three Key Methods

1. history.pushState() - Adds an entry to the history stack and changes the URL

typescript
// Changes URL to /products without reloading
history.pushState(
  { page: 'products' }, // State object
  'Products',            // Title (mostly ignored)
  '/products'            // New URL
);

2. history.replaceState() - Replaces the current history entry

typescript
// Changes URL without adding to back button history
history.replaceState(
  { page: 'login' },
  'Login',
  '/login'
);

3. window.addEventListener('popstate', ...) - Listens for back/forward button

typescript
window.addEventListener('popstate', (event) => {
  // Back/forward button was clicked
  const state = event.state;
  console.log('Navigated to:', state);
});

# TypeScript Version: History API Fundamentals

typescript
interface HistoryState {
  page: string;
  data?: Record<string, any>;
}

export function HistoryAPIExample() {
  const handleNavigate = (page: string, path: string, data?: Record<string, any>) => {
    // 1. Create state object
    const state: HistoryState = { page, data };

    // 2. Push to history (changes URL)
    history.pushState(state, page, path);

    // 3. Update UI (normally done in useEffect listening to popstate)
    console.log(`Navigated to ${page}`);
  };

  const handleBack = () => {
    // Browser's back button calls this automatically via popstate event
    history.back();
  };

  const handleForward = () => {
    history.forward();
  };

  const handleGoToSpecific = (n: number) => {
    history.go(n); // Positive = forward, negative = back
  };

  return (
    <div>
      <button onClick={() => handleNavigate('products', '/products')}>
        Products
      </button>
      <button onClick={() => handleNavigate('about', '/about')}>
        About
      </button>
      <button onClick={handleBack}>Back</button>
      <button onClick={handleForward}>Forward</button>
    </div>
  );
}

// History Stack Example:
// [Home] → [Products] → [Product Detail] ← [About]
//                              ↑
//                         You are here

// Back button: Takes you to [Product Detail]
// Forward button: Takes you to [About]

# JavaScript Version

javascript
export function HistoryAPIExample() {
  const handleNavigate = (page, path, data) => {
    const state = { page, data };
    history.pushState(state, page, path);
    console.log(`Navigated to ${page}`);
  };

  const handleBack = () => {
    history.back();
  };

  const handleForward = () => {
    history.forward();
  };

  return (
    <div>
      <button onClick={() => handleNavigate('products', '/products')}>
        Products
      </button>
      <button onClick={() => handleNavigate('about', '/about')}>
        About
      </button>
      <button onClick={handleBack}>Back</button>
      <button onClick={handleForward}>Forward</button>
    </div>
  );
}

# URL Synchronization: Watching for Changes {#url-synchronization}

Client-side routing works by syncing the URL to your component state. When the URL changes (either by user action or navigation), the router detects it and updates which component to render.

# The Core Loop

  1. User action - Click a <Link> or use navigate()
  2. URL update - Call history.pushState() to change the URL
  3. Event listener - popstate event fires (if user clicked back/forward) or you can detect immediately
  4. Component update - Determine which component matches the new URL
  5. Render - React re-renders with the new component

# TypeScript Version: URL Synchronization

typescript
import { useState, useEffect, useCallback } from 'react';

interface Route {
  path: string;
  component: React.ComponentType;
}

interface SimpleRouterProps {
  routes: Route[];
}

export function SimpleRouter({ routes }: SimpleRouterProps) {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  // Listen for URL changes (back/forward buttons)
  useEffect(() => {
    const handlePopState = () => {
      setCurrentPath(window.location.pathname);
    };

    window.addEventListener('popstate', handlePopState);
    return () => window.removeEventListener('popstate', handlePopState);
  }, []);

  // Navigate programmatically
  const navigate = useCallback((path: string) => {
    // 1. Update browser history
    history.pushState(null, '', path);

    // 2. Update component state (this is what triggers re-render)
    setCurrentPath(path);
  }, []);

  // Find matching route
  const matchedRoute = routes.find(route => route.path === currentPath);

  if (!matchedRoute) {
    return <div>404 - Not Found</div>;
  }

  const Component = matchedRoute.component;
  return <Component />;
}

// Usage
function HomePage() {
  return <div>Home Page</div>;
}

function ProductsPage() {
  return <div>Products Page</div>;
}

const routes: Route[] = [
  { path: '/', component: HomePage },
  { path: '/products', component: ProductsPage },
];

export function App() {
  return <SimpleRouter routes={routes} />;
}

# JavaScript Version

javascript
import { useState, useEffect, useCallback } from 'react';

export function SimpleRouter({ routes }) {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  useEffect(() => {
    const handlePopState = () => {
      setCurrentPath(window.location.pathname);
    };

    window.addEventListener('popstate', handlePopState);
    return () => window.removeEventListener('popstate', handlePopState);
  }, []);

  const navigate = useCallback((path) => {
    history.pushState(null, '', path);
    setCurrentPath(path);
  }, []);

  const matchedRoute = routes.find(route => route.path === currentPath);

  if (!matchedRoute) {
    return <div>404 - Not Found</div>;
  }

  const Component = matchedRoute.component;
  return <Component />;
}

function HomePage() {
  return <div>Home Page</div>;
}

function ProductsPage() {
  return <div>Products Page</div>;
}

const routes = [
  { path: '/', component: HomePage },
  { path: '/products', component: ProductsPage },
];

export function App() {
  return <SimpleRouter routes={routes} />;
}

Key insight: The magic is simply detecting when currentPath changes and re-rendering the matching component. The browser handles the URL and history automatically.

# Route Matching: Finding the Right Component {#route-matching}

The core challenge of routing is matching a URL to a route definition. URLs can have parameters, wildcards, and nested segments. Matching must be fast and reliable.

# Simple Matching: Exact Paths

typescript
// Simplest case: exact path matching
function matchRoute(pathname: string, routes: Route[]): Route | null {
  return routes.find(route => route.path === pathname) || null;
}

// /products matches /products ✓
// /products/123 doesn't match /products ✗

# Pattern Matching: Parameterized Routes

Most routers support parameterized routes like /products/:id. Implementing this requires parsing the pattern and extracting parameters.

# TypeScript Version: Route Matching with Parameters

typescript
interface RoutePattern {
  path: string; // e.g., "/products/:id/reviews/:reviewId"
  pattern: RegExp; // Compiled pattern for matching
  paramNames: string[]; // ["id", "reviewId"]
}

interface Params {
  [key: string]: string;
}

// Compile a route pattern into a regex
function compileRoute(routePath: string): RoutePattern {
  // Convert "/products/:id" → regex + param names

  // Split by segments
  const segments = routePath.split('/').filter(Boolean);

  // Replace :param with capture groups
  const patternSegments = segments.map(segment => {
    if (segment.startsWith(':')) {
      return '([^/]+)'; // Capture non-slash characters
    }
    return segment; // Literal segment
  });

  // Create regex
  const pattern = new RegExp(`^/${patternSegments.join('/')}/?

# How Client-Side Routing Works: From URLs to Components

When you click a link in a React application and the page changes instantly without a full reload, that's client-side routing at work. But what's actually happening under the hood? How does the browser know to render a different component when the URL changes? And why does the back button work without you implementing anything?

Understanding client-side routing mechanics is crucial for debugging navigation issues, building custom routers, and appreciating why libraries like React Router exist. Let's tear open the engine and see how it all works.

# Table of Contents

  1. The Problem Client-Side Routing Solves
  2. Browser History API: The Foundation
  3. URL Synchronization: Watching for Changes
  4. Route Matching: Finding the Right Component
  5. Building a Simple Router from Scratch
  6. Advanced Routing: Nested Routes and Parameters
  7. Handling Navigation Events
  8. Performance Considerations
  9. Real-World Routing Patterns
  10. FAQ

# The Problem Client-Side Routing Solves {#the-problem}

In traditional server-side applications, every navigation triggers a full HTTP request. The browser unloads the current page, requests a new one from the server, parses the HTML, loads CSS and JavaScript, and renders everything from scratch.

This works, but it has problems:

  1. Slow - Network latency, server processing, page parse time
  2. Jarring - The page flickers and reloads, losing scroll position
  3. State loss - Any client-side state (form inputs, scroll position, animations) is lost
  4. Bandwidth waste - Even if two pages share a layout, both are fully redownloaded

Client-side routing solves this by:

  • Keeping the application loaded in memory
  • Changing only what needs to change
  • Preserving state across "page" transitions
  • Making navigation instant

# TypeScript Version: The Problem Illustrated

typescript
// Server-side navigation (traditional)
// When you click a link:
// 1. Full page reload
// 2. All state lost
// 3. CSS re-parsed
// 4. JS re-executed
// 5. Components re-mounted

// Client-side routing
// When you click a link:
// 1. URL changes (browser history updated)
// 2. Component re-renders
// 3. Everything else stays in memory
// 4. Navigation is instant

interface NavigationComparison {
  serverSide: {
    time: number; // 500-2000ms
    stateLoss: boolean; // true
    renderTime: number; // Full page
  };
  clientSide: {
    time: number; // 10-100ms
    stateLoss: boolean; // false
    renderTime: number; // Only changed components
  };
}

export function NavigationComparison() {
  const comparison: NavigationComparison = {
    serverSide: {
      time: 1000,
      stateLoss: true,
      renderTime: 2000,
    },
    clientSide: {
      time: 50,
      stateLoss: false,
      renderTime: 50,
    },
  };

  return (
    <div>
      <p>Server-side navigation: {comparison.serverSide.time}ms</p>
      <p>Client-side navigation: {comparison.clientSide.time}ms</p>
      <p>Client-side is {comparison.serverSide.time / comparison.clientSide.time}x faster</p>
    </div>
  );
}

# JavaScript Version

javascript
export function NavigationComparison() {
  const comparison = {
    serverSide: {
      time: 1000,
      stateLoss: true,
      renderTime: 2000,
    },
    clientSide: {
      time: 50,
      stateLoss: false,
      renderTime: 50,
    },
  };

  return (
    <div>
      <p>Server-side navigation: {comparison.serverSide.time}ms</p>
      <p>Client-side navigation: {comparison.clientSide.time}ms</p>
      <p>Client-side is {(comparison.serverSide.time / comparison.clientSide.time).toFixed(0)}x faster</p>
    </div>
  );
}

# Browser History API: The Foundation {#history-api}

Client-side routing is built on the History API. This JavaScript API lets you manipulate the browser's session history (the list of URLs visited in the browser tab) and change the URL without triggering a page reload.

# The Three Key Methods

1. history.pushState() - Adds an entry to the history stack and changes the URL

typescript
// Changes URL to /products without reloading
history.pushState(
  { page: 'products' }, // State object
  'Products',            // Title (mostly ignored)
  '/products'            // New URL
);

2. history.replaceState() - Replaces the current history entry

typescript
// Changes URL without adding to back button history
history.replaceState(
  { page: 'login' },
  'Login',
  '/login'
);

3. window.addEventListener('popstate', ...) - Listens for back/forward button

typescript
window.addEventListener('popstate', (event) => {
  // Back/forward button was clicked
  const state = event.state;
  console.log('Navigated to:', state);
});

# TypeScript Version: History API Fundamentals

typescript
interface HistoryState {
  page: string;
  data?: Record<string, any>;
}

export function HistoryAPIExample() {
  const handleNavigate = (page: string, path: string, data?: Record<string, any>) => {
    // 1. Create state object
    const state: HistoryState = { page, data };

    // 2. Push to history (changes URL)
    history.pushState(state, page, path);

    // 3. Update UI (normally done in useEffect listening to popstate)
    console.log(`Navigated to ${page}`);
  };

  const handleBack = () => {
    // Browser's back button calls this automatically via popstate event
    history.back();
  };

  const handleForward = () => {
    history.forward();
  };

  const handleGoToSpecific = (n: number) => {
    history.go(n); // Positive = forward, negative = back
  };

  return (
    <div>
      <button onClick={() => handleNavigate('products', '/products')}>
        Products
      </button>
      <button onClick={() => handleNavigate('about', '/about')}>
        About
      </button>
      <button onClick={handleBack}>Back</button>
      <button onClick={handleForward}>Forward</button>
    </div>
  );
}

// History Stack Example:
// [Home] → [Products] → [Product Detail] ← [About]
//                              ↑
//                         You are here

// Back button: Takes you to [Product Detail]
// Forward button: Takes you to [About]

# JavaScript Version

javascript
export function HistoryAPIExample() {
  const handleNavigate = (page, path, data) => {
    const state = { page, data };
    history.pushState(state, page, path);
    console.log(`Navigated to ${page}`);
  };

  const handleBack = () => {
    history.back();
  };

  const handleForward = () => {
    history.forward();
  };

  return (
    <div>
      <button onClick={() => handleNavigate('products', '/products')}>
        Products
      </button>
      <button onClick={() => handleNavigate('about', '/about')}>
        About
      </button>
      <button onClick={handleBack}>Back</button>
      <button onClick={handleForward}>Forward</button>
    </div>
  );
}

# URL Synchronization: Watching for Changes {#url-synchronization}

Client-side routing works by syncing the URL to your component state. When the URL changes (either by user action or navigation), the router detects it and updates which component to render.

# The Core Loop

  1. User action - Click a <Link> or use navigate()
  2. URL update - Call history.pushState() to change the URL
  3. Event listener - popstate event fires (if user clicked back/forward) or you can detect immediately
  4. Component update - Determine which component matches the new URL
  5. Render - React re-renders with the new component

# TypeScript Version: URL Synchronization

typescript
import { useState, useEffect, useCallback } from 'react';

interface Route {
  path: string;
  component: React.ComponentType;
}

interface SimpleRouterProps {
  routes: Route[];
}

export function SimpleRouter({ routes }: SimpleRouterProps) {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  // Listen for URL changes (back/forward buttons)
  useEffect(() => {
    const handlePopState = () => {
      setCurrentPath(window.location.pathname);
    };

    window.addEventListener('popstate', handlePopState);
    return () => window.removeEventListener('popstate', handlePopState);
  }, []);

  // Navigate programmatically
  const navigate = useCallback((path: string) => {
    // 1. Update browser history
    history.pushState(null, '', path);

    // 2. Update component state (this is what triggers re-render)
    setCurrentPath(path);
  }, []);

  // Find matching route
  const matchedRoute = routes.find(route => route.path === currentPath);

  if (!matchedRoute) {
    return <div>404 - Not Found</div>;
  }

  const Component = matchedRoute.component;
  return <Component />;
}

// Usage
function HomePage() {
  return <div>Home Page</div>;
}

function ProductsPage() {
  return <div>Products Page</div>;
}

const routes: Route[] = [
  { path: '/', component: HomePage },
  { path: '/products', component: ProductsPage },
];

export function App() {
  return <SimpleRouter routes={routes} />;
}

# JavaScript Version

javascript
import { useState, useEffect, useCallback } from 'react';

export function SimpleRouter({ routes }) {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  useEffect(() => {
    const handlePopState = () => {
      setCurrentPath(window.location.pathname);
    };

    window.addEventListener('popstate', handlePopState);
    return () => window.removeEventListener('popstate', handlePopState);
  }, []);

  const navigate = useCallback((path) => {
    history.pushState(null, '', path);
    setCurrentPath(path);
  }, []);

  const matchedRoute = routes.find(route => route.path === currentPath);

  if (!matchedRoute) {
    return <div>404 - Not Found</div>;
  }

  const Component = matchedRoute.component;
  return <Component />;
}

function HomePage() {
  return <div>Home Page</div>;
}

function ProductsPage() {
  return <div>Products Page</div>;
}

const routes = [
  { path: '/', component: HomePage },
  { path: '/products', component: ProductsPage },
];

export function App() {
  return <SimpleRouter routes={routes} />;
}

Key insight: The magic is simply detecting when currentPath changes and re-rendering the matching component. The browser handles the URL and history automatically.

# Route Matching: Finding the Right Component {#route-matching}

The core challenge of routing is matching a URL to a route definition. URLs can have parameters, wildcards, and nested segments. Matching must be fast and reliable.

# Simple Matching: Exact Paths

typescript
// Simplest case: exact path matching
function matchRoute(pathname: string, routes: Route[]): Route | null {
  return routes.find(route => route.path === pathname) || null;
}

// /products matches /products ✓
// /products/123 doesn't match /products ✗

# Pattern Matching: Parameterized Routes

Most routers support parameterized routes like /products/:id. Implementing this requires parsing the pattern and extracting parameters.

# TypeScript Version: Route Matching with Parameters

);
// Extract param names const paramNames = segments .filter(s => s.startsWith(':')) .map(s => s.slice(1)); // Remove the ':' return { path: routePath, pattern, paramNames }; } // Match URL and extract parameters function matchRoute( pathname: string, compiledRoute: RoutePattern ): Params | null { const match = pathname.match(compiledRoute.pattern); if (!match) { return null; } // match[0] is the full match, match[1], match[2], etc. are groups const params: Params = {}; compiledRoute.paramNames.forEach((paramName, index) => { params[paramName] = match[index + 1]; }); return params; } // Usage Example const productRoute = compileRoute('/products/:id'); const match = matchRoute('/products/123', productRoute); console.log(match); // { id: "123" } // Real-world matching with multiple routes const routes = [ { path: '/', component: HomePage }, { path: '/products', component: ProductsPage }, { path: '/products/:id', component: ProductDetailPage }, { path: '/products/:id/reviews/:reviewId', component: ReviewPage }, ]; const compiledRoutes = routes.map(route => ({ ...route, ...compileRoute(route.path), })); function findMatchingRoute(pathname: string) { for (const route of compiledRoutes) { const params = matchRoute(pathname, route); if (params !== null) { return { route, params }; } } return null; } // Test routing console.log(findMatchingRoute('/products')); // ProductsPage, no params console.log(findMatchingRoute('/products/123')); // ProductDetailPage, { id: '123' } console.log(findMatchingRoute('/products/123/reviews/456')); // ReviewPage, { id: '123', reviewId: '456' }

# JavaScript Version

javascript
function compileRoute(routePath) {
  const segments = routePath.split('/').filter(Boolean);

  const patternSegments = segments.map(segment => {
    if (segment.startsWith(':')) {
      return '([^/]+)';
    }
    return segment;
  });

  const pattern = new RegExp(`^/${patternSegments.join('/')}/?

# How Client-Side Routing Works: From URLs to Components

When you click a link in a React application and the page changes instantly without a full reload, that's client-side routing at work. But what's actually happening under the hood? How does the browser know to render a different component when the URL changes? And why does the back button work without you implementing anything?

Understanding client-side routing mechanics is crucial for debugging navigation issues, building custom routers, and appreciating why libraries like React Router exist. Let's tear open the engine and see how it all works.

# Table of Contents

  1. The Problem Client-Side Routing Solves
  2. Browser History API: The Foundation
  3. URL Synchronization: Watching for Changes
  4. Route Matching: Finding the Right Component
  5. Building a Simple Router from Scratch
  6. Advanced Routing: Nested Routes and Parameters
  7. Handling Navigation Events
  8. Performance Considerations
  9. Real-World Routing Patterns
  10. FAQ

# The Problem Client-Side Routing Solves {#the-problem}

In traditional server-side applications, every navigation triggers a full HTTP request. The browser unloads the current page, requests a new one from the server, parses the HTML, loads CSS and JavaScript, and renders everything from scratch.

This works, but it has problems:

  1. Slow - Network latency, server processing, page parse time
  2. Jarring - The page flickers and reloads, losing scroll position
  3. State loss - Any client-side state (form inputs, scroll position, animations) is lost
  4. Bandwidth waste - Even if two pages share a layout, both are fully redownloaded

Client-side routing solves this by:

  • Keeping the application loaded in memory
  • Changing only what needs to change
  • Preserving state across "page" transitions
  • Making navigation instant

# TypeScript Version: The Problem Illustrated

typescript
// Server-side navigation (traditional)
// When you click a link:
// 1. Full page reload
// 2. All state lost
// 3. CSS re-parsed
// 4. JS re-executed
// 5. Components re-mounted

// Client-side routing
// When you click a link:
// 1. URL changes (browser history updated)
// 2. Component re-renders
// 3. Everything else stays in memory
// 4. Navigation is instant

interface NavigationComparison {
  serverSide: {
    time: number; // 500-2000ms
    stateLoss: boolean; // true
    renderTime: number; // Full page
  };
  clientSide: {
    time: number; // 10-100ms
    stateLoss: boolean; // false
    renderTime: number; // Only changed components
  };
}

export function NavigationComparison() {
  const comparison: NavigationComparison = {
    serverSide: {
      time: 1000,
      stateLoss: true,
      renderTime: 2000,
    },
    clientSide: {
      time: 50,
      stateLoss: false,
      renderTime: 50,
    },
  };

  return (
    <div>
      <p>Server-side navigation: {comparison.serverSide.time}ms</p>
      <p>Client-side navigation: {comparison.clientSide.time}ms</p>
      <p>Client-side is {comparison.serverSide.time / comparison.clientSide.time}x faster</p>
    </div>
  );
}

# JavaScript Version

javascript
export function NavigationComparison() {
  const comparison = {
    serverSide: {
      time: 1000,
      stateLoss: true,
      renderTime: 2000,
    },
    clientSide: {
      time: 50,
      stateLoss: false,
      renderTime: 50,
    },
  };

  return (
    <div>
      <p>Server-side navigation: {comparison.serverSide.time}ms</p>
      <p>Client-side navigation: {comparison.clientSide.time}ms</p>
      <p>Client-side is {(comparison.serverSide.time / comparison.clientSide.time).toFixed(0)}x faster</p>
    </div>
  );
}

# Browser History API: The Foundation {#history-api}

Client-side routing is built on the History API. This JavaScript API lets you manipulate the browser's session history (the list of URLs visited in the browser tab) and change the URL without triggering a page reload.

# The Three Key Methods

1. history.pushState() - Adds an entry to the history stack and changes the URL

typescript
// Changes URL to /products without reloading
history.pushState(
  { page: 'products' }, // State object
  'Products',            // Title (mostly ignored)
  '/products'            // New URL
);

2. history.replaceState() - Replaces the current history entry

typescript
// Changes URL without adding to back button history
history.replaceState(
  { page: 'login' },
  'Login',
  '/login'
);

3. window.addEventListener('popstate', ...) - Listens for back/forward button

typescript
window.addEventListener('popstate', (event) => {
  // Back/forward button was clicked
  const state = event.state;
  console.log('Navigated to:', state);
});

# TypeScript Version: History API Fundamentals

typescript
interface HistoryState {
  page: string;
  data?: Record<string, any>;
}

export function HistoryAPIExample() {
  const handleNavigate = (page: string, path: string, data?: Record<string, any>) => {
    // 1. Create state object
    const state: HistoryState = { page, data };

    // 2. Push to history (changes URL)
    history.pushState(state, page, path);

    // 3. Update UI (normally done in useEffect listening to popstate)
    console.log(`Navigated to ${page}`);
  };

  const handleBack = () => {
    // Browser's back button calls this automatically via popstate event
    history.back();
  };

  const handleForward = () => {
    history.forward();
  };

  const handleGoToSpecific = (n: number) => {
    history.go(n); // Positive = forward, negative = back
  };

  return (
    <div>
      <button onClick={() => handleNavigate('products', '/products')}>
        Products
      </button>
      <button onClick={() => handleNavigate('about', '/about')}>
        About
      </button>
      <button onClick={handleBack}>Back</button>
      <button onClick={handleForward}>Forward</button>
    </div>
  );
}

// History Stack Example:
// [Home] → [Products] → [Product Detail] ← [About]
//                              ↑
//                         You are here

// Back button: Takes you to [Product Detail]
// Forward button: Takes you to [About]

# JavaScript Version

javascript
export function HistoryAPIExample() {
  const handleNavigate = (page, path, data) => {
    const state = { page, data };
    history.pushState(state, page, path);
    console.log(`Navigated to ${page}`);
  };

  const handleBack = () => {
    history.back();
  };

  const handleForward = () => {
    history.forward();
  };

  return (
    <div>
      <button onClick={() => handleNavigate('products', '/products')}>
        Products
      </button>
      <button onClick={() => handleNavigate('about', '/about')}>
        About
      </button>
      <button onClick={handleBack}>Back</button>
      <button onClick={handleForward}>Forward</button>
    </div>
  );
}

# URL Synchronization: Watching for Changes {#url-synchronization}

Client-side routing works by syncing the URL to your component state. When the URL changes (either by user action or navigation), the router detects it and updates which component to render.

# The Core Loop

  1. User action - Click a <Link> or use navigate()
  2. URL update - Call history.pushState() to change the URL
  3. Event listener - popstate event fires (if user clicked back/forward) or you can detect immediately
  4. Component update - Determine which component matches the new URL
  5. Render - React re-renders with the new component

# TypeScript Version: URL Synchronization

typescript
import { useState, useEffect, useCallback } from 'react';

interface Route {
  path: string;
  component: React.ComponentType;
}

interface SimpleRouterProps {
  routes: Route[];
}

export function SimpleRouter({ routes }: SimpleRouterProps) {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  // Listen for URL changes (back/forward buttons)
  useEffect(() => {
    const handlePopState = () => {
      setCurrentPath(window.location.pathname);
    };

    window.addEventListener('popstate', handlePopState);
    return () => window.removeEventListener('popstate', handlePopState);
  }, []);

  // Navigate programmatically
  const navigate = useCallback((path: string) => {
    // 1. Update browser history
    history.pushState(null, '', path);

    // 2. Update component state (this is what triggers re-render)
    setCurrentPath(path);
  }, []);

  // Find matching route
  const matchedRoute = routes.find(route => route.path === currentPath);

  if (!matchedRoute) {
    return <div>404 - Not Found</div>;
  }

  const Component = matchedRoute.component;
  return <Component />;
}

// Usage
function HomePage() {
  return <div>Home Page</div>;
}

function ProductsPage() {
  return <div>Products Page</div>;
}

const routes: Route[] = [
  { path: '/', component: HomePage },
  { path: '/products', component: ProductsPage },
];

export function App() {
  return <SimpleRouter routes={routes} />;
}

# JavaScript Version

javascript
import { useState, useEffect, useCallback } from 'react';

export function SimpleRouter({ routes }) {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  useEffect(() => {
    const handlePopState = () => {
      setCurrentPath(window.location.pathname);
    };

    window.addEventListener('popstate', handlePopState);
    return () => window.removeEventListener('popstate', handlePopState);
  }, []);

  const navigate = useCallback((path) => {
    history.pushState(null, '', path);
    setCurrentPath(path);
  }, []);

  const matchedRoute = routes.find(route => route.path === currentPath);

  if (!matchedRoute) {
    return <div>404 - Not Found</div>;
  }

  const Component = matchedRoute.component;
  return <Component />;
}

function HomePage() {
  return <div>Home Page</div>;
}

function ProductsPage() {
  return <div>Products Page</div>;
}

const routes = [
  { path: '/', component: HomePage },
  { path: '/products', component: ProductsPage },
];

export function App() {
  return <SimpleRouter routes={routes} />;
}

Key insight: The magic is simply detecting when currentPath changes and re-rendering the matching component. The browser handles the URL and history automatically.

# Route Matching: Finding the Right Component {#route-matching}

The core challenge of routing is matching a URL to a route definition. URLs can have parameters, wildcards, and nested segments. Matching must be fast and reliable.

# Simple Matching: Exact Paths

typescript
// Simplest case: exact path matching
function matchRoute(pathname: string, routes: Route[]): Route | null {
  return routes.find(route => route.path === pathname) || null;
}

// /products matches /products ✓
// /products/123 doesn't match /products ✗

# Pattern Matching: Parameterized Routes

Most routers support parameterized routes like /products/:id. Implementing this requires parsing the pattern and extracting parameters.

# TypeScript Version: Route Matching with Parameters

typescript
interface RoutePattern {
  path: string; // e.g., "/products/:id/reviews/:reviewId"
  pattern: RegExp; // Compiled pattern for matching
  paramNames: string[]; // ["id", "reviewId"]
}

interface Params {
  [key: string]: string;
}

// Compile a route pattern into a regex
function compileRoute(routePath: string): RoutePattern {
  // Convert "/products/:id" → regex + param names

  // Split by segments
  const segments = routePath.split('/').filter(Boolean);

  // Replace :param with capture groups
  const patternSegments = segments.map(segment => {
    if (segment.startsWith(':')) {
      return '([^/]+)'; // Capture non-slash characters
    }
    return segment; // Literal segment
  });

  // Create regex
  const pattern = new RegExp(`^/${patternSegments.join('/')}/?

# How Client-Side Routing Works: From URLs to Components

When you click a link in a React application and the page changes instantly without a full reload, that's client-side routing at work. But what's actually happening under the hood? How does the browser know to render a different component when the URL changes? And why does the back button work without you implementing anything?

Understanding client-side routing mechanics is crucial for debugging navigation issues, building custom routers, and appreciating why libraries like React Router exist. Let's tear open the engine and see how it all works.

# Table of Contents

  1. The Problem Client-Side Routing Solves
  2. Browser History API: The Foundation
  3. URL Synchronization: Watching for Changes
  4. Route Matching: Finding the Right Component
  5. Building a Simple Router from Scratch
  6. Advanced Routing: Nested Routes and Parameters
  7. Handling Navigation Events
  8. Performance Considerations
  9. Real-World Routing Patterns
  10. FAQ

# The Problem Client-Side Routing Solves {#the-problem}

In traditional server-side applications, every navigation triggers a full HTTP request. The browser unloads the current page, requests a new one from the server, parses the HTML, loads CSS and JavaScript, and renders everything from scratch.

This works, but it has problems:

  1. Slow - Network latency, server processing, page parse time
  2. Jarring - The page flickers and reloads, losing scroll position
  3. State loss - Any client-side state (form inputs, scroll position, animations) is lost
  4. Bandwidth waste - Even if two pages share a layout, both are fully redownloaded

Client-side routing solves this by:

  • Keeping the application loaded in memory
  • Changing only what needs to change
  • Preserving state across "page" transitions
  • Making navigation instant

# TypeScript Version: The Problem Illustrated

typescript
// Server-side navigation (traditional)
// When you click a link:
// 1. Full page reload
// 2. All state lost
// 3. CSS re-parsed
// 4. JS re-executed
// 5. Components re-mounted

// Client-side routing
// When you click a link:
// 1. URL changes (browser history updated)
// 2. Component re-renders
// 3. Everything else stays in memory
// 4. Navigation is instant

interface NavigationComparison {
  serverSide: {
    time: number; // 500-2000ms
    stateLoss: boolean; // true
    renderTime: number; // Full page
  };
  clientSide: {
    time: number; // 10-100ms
    stateLoss: boolean; // false
    renderTime: number; // Only changed components
  };
}

export function NavigationComparison() {
  const comparison: NavigationComparison = {
    serverSide: {
      time: 1000,
      stateLoss: true,
      renderTime: 2000,
    },
    clientSide: {
      time: 50,
      stateLoss: false,
      renderTime: 50,
    },
  };

  return (
    <div>
      <p>Server-side navigation: {comparison.serverSide.time}ms</p>
      <p>Client-side navigation: {comparison.clientSide.time}ms</p>
      <p>Client-side is {comparison.serverSide.time / comparison.clientSide.time}x faster</p>
    </div>
  );
}

# JavaScript Version

javascript
export function NavigationComparison() {
  const comparison = {
    serverSide: {
      time: 1000,
      stateLoss: true,
      renderTime: 2000,
    },
    clientSide: {
      time: 50,
      stateLoss: false,
      renderTime: 50,
    },
  };

  return (
    <div>
      <p>Server-side navigation: {comparison.serverSide.time}ms</p>
      <p>Client-side navigation: {comparison.clientSide.time}ms</p>
      <p>Client-side is {(comparison.serverSide.time / comparison.clientSide.time).toFixed(0)}x faster</p>
    </div>
  );
}

# Browser History API: The Foundation {#history-api}

Client-side routing is built on the History API. This JavaScript API lets you manipulate the browser's session history (the list of URLs visited in the browser tab) and change the URL without triggering a page reload.

# The Three Key Methods

1. history.pushState() - Adds an entry to the history stack and changes the URL

typescript
// Changes URL to /products without reloading
history.pushState(
  { page: 'products' }, // State object
  'Products',            // Title (mostly ignored)
  '/products'            // New URL
);

2. history.replaceState() - Replaces the current history entry

typescript
// Changes URL without adding to back button history
history.replaceState(
  { page: 'login' },
  'Login',
  '/login'
);

3. window.addEventListener('popstate', ...) - Listens for back/forward button

typescript
window.addEventListener('popstate', (event) => {
  // Back/forward button was clicked
  const state = event.state;
  console.log('Navigated to:', state);
});

# TypeScript Version: History API Fundamentals

typescript
interface HistoryState {
  page: string;
  data?: Record<string, any>;
}

export function HistoryAPIExample() {
  const handleNavigate = (page: string, path: string, data?: Record<string, any>) => {
    // 1. Create state object
    const state: HistoryState = { page, data };

    // 2. Push to history (changes URL)
    history.pushState(state, page, path);

    // 3. Update UI (normally done in useEffect listening to popstate)
    console.log(`Navigated to ${page}`);
  };

  const handleBack = () => {
    // Browser's back button calls this automatically via popstate event
    history.back();
  };

  const handleForward = () => {
    history.forward();
  };

  const handleGoToSpecific = (n: number) => {
    history.go(n); // Positive = forward, negative = back
  };

  return (
    <div>
      <button onClick={() => handleNavigate('products', '/products')}>
        Products
      </button>
      <button onClick={() => handleNavigate('about', '/about')}>
        About
      </button>
      <button onClick={handleBack}>Back</button>
      <button onClick={handleForward}>Forward</button>
    </div>
  );
}

// History Stack Example:
// [Home] → [Products] → [Product Detail] ← [About]
//                              ↑
//                         You are here

// Back button: Takes you to [Product Detail]
// Forward button: Takes you to [About]

# JavaScript Version

javascript
export function HistoryAPIExample() {
  const handleNavigate = (page, path, data) => {
    const state = { page, data };
    history.pushState(state, page, path);
    console.log(`Navigated to ${page}`);
  };

  const handleBack = () => {
    history.back();
  };

  const handleForward = () => {
    history.forward();
  };

  return (
    <div>
      <button onClick={() => handleNavigate('products', '/products')}>
        Products
      </button>
      <button onClick={() => handleNavigate('about', '/about')}>
        About
      </button>
      <button onClick={handleBack}>Back</button>
      <button onClick={handleForward}>Forward</button>
    </div>
  );
}

# URL Synchronization: Watching for Changes {#url-synchronization}

Client-side routing works by syncing the URL to your component state. When the URL changes (either by user action or navigation), the router detects it and updates which component to render.

# The Core Loop

  1. User action - Click a <Link> or use navigate()
  2. URL update - Call history.pushState() to change the URL
  3. Event listener - popstate event fires (if user clicked back/forward) or you can detect immediately
  4. Component update - Determine which component matches the new URL
  5. Render - React re-renders with the new component

# TypeScript Version: URL Synchronization

typescript
import { useState, useEffect, useCallback } from 'react';

interface Route {
  path: string;
  component: React.ComponentType;
}

interface SimpleRouterProps {
  routes: Route[];
}

export function SimpleRouter({ routes }: SimpleRouterProps) {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  // Listen for URL changes (back/forward buttons)
  useEffect(() => {
    const handlePopState = () => {
      setCurrentPath(window.location.pathname);
    };

    window.addEventListener('popstate', handlePopState);
    return () => window.removeEventListener('popstate', handlePopState);
  }, []);

  // Navigate programmatically
  const navigate = useCallback((path: string) => {
    // 1. Update browser history
    history.pushState(null, '', path);

    // 2. Update component state (this is what triggers re-render)
    setCurrentPath(path);
  }, []);

  // Find matching route
  const matchedRoute = routes.find(route => route.path === currentPath);

  if (!matchedRoute) {
    return <div>404 - Not Found</div>;
  }

  const Component = matchedRoute.component;
  return <Component />;
}

// Usage
function HomePage() {
  return <div>Home Page</div>;
}

function ProductsPage() {
  return <div>Products Page</div>;
}

const routes: Route[] = [
  { path: '/', component: HomePage },
  { path: '/products', component: ProductsPage },
];

export function App() {
  return <SimpleRouter routes={routes} />;
}

# JavaScript Version

javascript
import { useState, useEffect, useCallback } from 'react';

export function SimpleRouter({ routes }) {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  useEffect(() => {
    const handlePopState = () => {
      setCurrentPath(window.location.pathname);
    };

    window.addEventListener('popstate', handlePopState);
    return () => window.removeEventListener('popstate', handlePopState);
  }, []);

  const navigate = useCallback((path) => {
    history.pushState(null, '', path);
    setCurrentPath(path);
  }, []);

  const matchedRoute = routes.find(route => route.path === currentPath);

  if (!matchedRoute) {
    return <div>404 - Not Found</div>;
  }

  const Component = matchedRoute.component;
  return <Component />;
}

function HomePage() {
  return <div>Home Page</div>;
}

function ProductsPage() {
  return <div>Products Page</div>;
}

const routes = [
  { path: '/', component: HomePage },
  { path: '/products', component: ProductsPage },
];

export function App() {
  return <SimpleRouter routes={routes} />;
}

Key insight: The magic is simply detecting when currentPath changes and re-rendering the matching component. The browser handles the URL and history automatically.

# Route Matching: Finding the Right Component {#route-matching}

The core challenge of routing is matching a URL to a route definition. URLs can have parameters, wildcards, and nested segments. Matching must be fast and reliable.

# Simple Matching: Exact Paths

typescript
// Simplest case: exact path matching
function matchRoute(pathname: string, routes: Route[]): Route | null {
  return routes.find(route => route.path === pathname) || null;
}

// /products matches /products ✓
// /products/123 doesn't match /products ✗

# Pattern Matching: Parameterized Routes

Most routers support parameterized routes like /products/:id. Implementing this requires parsing the pattern and extracting parameters.

# TypeScript Version: Route Matching with Parameters

);
// Extract param names const paramNames = segments .filter(s => s.startsWith(':')) .map(s => s.slice(1)); // Remove the ':' return { path: routePath, pattern, paramNames }; } // Match URL and extract parameters function matchRoute( pathname: string, compiledRoute: RoutePattern ): Params | null { const match = pathname.match(compiledRoute.pattern); if (!match) { return null; } // match[0] is the full match, match[1], match[2], etc. are groups const params: Params = {}; compiledRoute.paramNames.forEach((paramName, index) => { params[paramName] = match[index + 1]; }); return params; } // Usage Example const productRoute = compileRoute('/products/:id'); const match = matchRoute('/products/123', productRoute); console.log(match); // { id: "123" } // Real-world matching with multiple routes const routes = [ { path: '/', component: HomePage }, { path: '/products', component: ProductsPage }, { path: '/products/:id', component: ProductDetailPage }, { path: '/products/:id/reviews/:reviewId', component: ReviewPage }, ]; const compiledRoutes = routes.map(route => ({ ...route, ...compileRoute(route.path), })); function findMatchingRoute(pathname: string) { for (const route of compiledRoutes) { const params = matchRoute(pathname, route); if (params !== null) { return { route, params }; } } return null; } // Test routing console.log(findMatchingRoute('/products')); // ProductsPage, no params console.log(findMatchingRoute('/products/123')); // ProductDetailPage, { id: '123' } console.log(findMatchingRoute('/products/123/reviews/456')); // ReviewPage, { id: '123', reviewId: '456' }

# JavaScript Version

);
const paramNames = segments .filter(s => s.startsWith(':')) .map(s => s.slice(1)); return { path: routePath, pattern, paramNames }; } function matchRoute(pathname, compiledRoute) { const match = pathname.match(compiledRoute.pattern); if (!match) { return null; } const params = {}; compiledRoute.paramNames.forEach((paramName, index) => { params[paramName] = match[index + 1]; }); return params; } const productRoute = compileRoute('/products/:id'); const match = matchRoute('/products/123', productRoute); console.log(match); // { id: '123' }

# Building a Simple Router from Scratch {#building-router}

Now let's build a functional router that combines everything: History API, URL synchronization, and route matching.

# TypeScript Version: Custom Router Implementation

typescript
import {
  ReactNode,
  createContext,
  useContext,
  useState,
  useEffect,
  useCallback,
} from 'react';

interface Route {
  path: string;
  element: React.ComponentType;
}

interface RouterContextType {
  currentPath: string;
  navigate: (path: string) => void;
  params: Record<string, string>;
}

const RouterContext = createContext<RouterContextType | undefined>(undefined);

// Route matching logic
function compileRoute(routePath: string) {
  const segments = routePath.split('/').filter(Boolean);
  const patternSegments = segments.map(segment =>
    segment.startsWith(':') ? '([^/]+)' : segment
  );
  const pattern = new RegExp(`^/${patternSegments.join('/')}/?

# How Client-Side Routing Works: From URLs to Components

When you click a link in a React application and the page changes instantly without a full reload, that's client-side routing at work. But what's actually happening under the hood? How does the browser know to render a different component when the URL changes? And why does the back button work without you implementing anything?

Understanding client-side routing mechanics is crucial for debugging navigation issues, building custom routers, and appreciating why libraries like React Router exist. Let's tear open the engine and see how it all works.

# Table of Contents

  1. The Problem Client-Side Routing Solves
  2. Browser History API: The Foundation
  3. URL Synchronization: Watching for Changes
  4. Route Matching: Finding the Right Component
  5. Building a Simple Router from Scratch
  6. Advanced Routing: Nested Routes and Parameters
  7. Handling Navigation Events
  8. Performance Considerations
  9. Real-World Routing Patterns
  10. FAQ

# The Problem Client-Side Routing Solves {#the-problem}

In traditional server-side applications, every navigation triggers a full HTTP request. The browser unloads the current page, requests a new one from the server, parses the HTML, loads CSS and JavaScript, and renders everything from scratch.

This works, but it has problems:

  1. Slow - Network latency, server processing, page parse time
  2. Jarring - The page flickers and reloads, losing scroll position
  3. State loss - Any client-side state (form inputs, scroll position, animations) is lost
  4. Bandwidth waste - Even if two pages share a layout, both are fully redownloaded

Client-side routing solves this by:

  • Keeping the application loaded in memory
  • Changing only what needs to change
  • Preserving state across "page" transitions
  • Making navigation instant

# TypeScript Version: The Problem Illustrated

typescript
// Server-side navigation (traditional)
// When you click a link:
// 1. Full page reload
// 2. All state lost
// 3. CSS re-parsed
// 4. JS re-executed
// 5. Components re-mounted

// Client-side routing
// When you click a link:
// 1. URL changes (browser history updated)
// 2. Component re-renders
// 3. Everything else stays in memory
// 4. Navigation is instant

interface NavigationComparison {
  serverSide: {
    time: number; // 500-2000ms
    stateLoss: boolean; // true
    renderTime: number; // Full page
  };
  clientSide: {
    time: number; // 10-100ms
    stateLoss: boolean; // false
    renderTime: number; // Only changed components
  };
}

export function NavigationComparison() {
  const comparison: NavigationComparison = {
    serverSide: {
      time: 1000,
      stateLoss: true,
      renderTime: 2000,
    },
    clientSide: {
      time: 50,
      stateLoss: false,
      renderTime: 50,
    },
  };

  return (
    <div>
      <p>Server-side navigation: {comparison.serverSide.time}ms</p>
      <p>Client-side navigation: {comparison.clientSide.time}ms</p>
      <p>Client-side is {comparison.serverSide.time / comparison.clientSide.time}x faster</p>
    </div>
  );
}

# JavaScript Version

javascript
export function NavigationComparison() {
  const comparison = {
    serverSide: {
      time: 1000,
      stateLoss: true,
      renderTime: 2000,
    },
    clientSide: {
      time: 50,
      stateLoss: false,
      renderTime: 50,
    },
  };

  return (
    <div>
      <p>Server-side navigation: {comparison.serverSide.time}ms</p>
      <p>Client-side navigation: {comparison.clientSide.time}ms</p>
      <p>Client-side is {(comparison.serverSide.time / comparison.clientSide.time).toFixed(0)}x faster</p>
    </div>
  );
}

# Browser History API: The Foundation {#history-api}

Client-side routing is built on the History API. This JavaScript API lets you manipulate the browser's session history (the list of URLs visited in the browser tab) and change the URL without triggering a page reload.

# The Three Key Methods

1. history.pushState() - Adds an entry to the history stack and changes the URL

typescript
// Changes URL to /products without reloading
history.pushState(
  { page: 'products' }, // State object
  'Products',            // Title (mostly ignored)
  '/products'            // New URL
);

2. history.replaceState() - Replaces the current history entry

typescript
// Changes URL without adding to back button history
history.replaceState(
  { page: 'login' },
  'Login',
  '/login'
);

3. window.addEventListener('popstate', ...) - Listens for back/forward button

typescript
window.addEventListener('popstate', (event) => {
  // Back/forward button was clicked
  const state = event.state;
  console.log('Navigated to:', state);
});

# TypeScript Version: History API Fundamentals

typescript
interface HistoryState {
  page: string;
  data?: Record<string, any>;
}

export function HistoryAPIExample() {
  const handleNavigate = (page: string, path: string, data?: Record<string, any>) => {
    // 1. Create state object
    const state: HistoryState = { page, data };

    // 2. Push to history (changes URL)
    history.pushState(state, page, path);

    // 3. Update UI (normally done in useEffect listening to popstate)
    console.log(`Navigated to ${page}`);
  };

  const handleBack = () => {
    // Browser's back button calls this automatically via popstate event
    history.back();
  };

  const handleForward = () => {
    history.forward();
  };

  const handleGoToSpecific = (n: number) => {
    history.go(n); // Positive = forward, negative = back
  };

  return (
    <div>
      <button onClick={() => handleNavigate('products', '/products')}>
        Products
      </button>
      <button onClick={() => handleNavigate('about', '/about')}>
        About
      </button>
      <button onClick={handleBack}>Back</button>
      <button onClick={handleForward}>Forward</button>
    </div>
  );
}

// History Stack Example:
// [Home] → [Products] → [Product Detail] ← [About]
//                              ↑
//                         You are here

// Back button: Takes you to [Product Detail]
// Forward button: Takes you to [About]

# JavaScript Version

javascript
export function HistoryAPIExample() {
  const handleNavigate = (page, path, data) => {
    const state = { page, data };
    history.pushState(state, page, path);
    console.log(`Navigated to ${page}`);
  };

  const handleBack = () => {
    history.back();
  };

  const handleForward = () => {
    history.forward();
  };

  return (
    <div>
      <button onClick={() => handleNavigate('products', '/products')}>
        Products
      </button>
      <button onClick={() => handleNavigate('about', '/about')}>
        About
      </button>
      <button onClick={handleBack}>Back</button>
      <button onClick={handleForward}>Forward</button>
    </div>
  );
}

# URL Synchronization: Watching for Changes {#url-synchronization}

Client-side routing works by syncing the URL to your component state. When the URL changes (either by user action or navigation), the router detects it and updates which component to render.

# The Core Loop

  1. User action - Click a <Link> or use navigate()
  2. URL update - Call history.pushState() to change the URL
  3. Event listener - popstate event fires (if user clicked back/forward) or you can detect immediately
  4. Component update - Determine which component matches the new URL
  5. Render - React re-renders with the new component

# TypeScript Version: URL Synchronization

typescript
import { useState, useEffect, useCallback } from 'react';

interface Route {
  path: string;
  component: React.ComponentType;
}

interface SimpleRouterProps {
  routes: Route[];
}

export function SimpleRouter({ routes }: SimpleRouterProps) {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  // Listen for URL changes (back/forward buttons)
  useEffect(() => {
    const handlePopState = () => {
      setCurrentPath(window.location.pathname);
    };

    window.addEventListener('popstate', handlePopState);
    return () => window.removeEventListener('popstate', handlePopState);
  }, []);

  // Navigate programmatically
  const navigate = useCallback((path: string) => {
    // 1. Update browser history
    history.pushState(null, '', path);

    // 2. Update component state (this is what triggers re-render)
    setCurrentPath(path);
  }, []);

  // Find matching route
  const matchedRoute = routes.find(route => route.path === currentPath);

  if (!matchedRoute) {
    return <div>404 - Not Found</div>;
  }

  const Component = matchedRoute.component;
  return <Component />;
}

// Usage
function HomePage() {
  return <div>Home Page</div>;
}

function ProductsPage() {
  return <div>Products Page</div>;
}

const routes: Route[] = [
  { path: '/', component: HomePage },
  { path: '/products', component: ProductsPage },
];

export function App() {
  return <SimpleRouter routes={routes} />;
}

# JavaScript Version

javascript
import { useState, useEffect, useCallback } from 'react';

export function SimpleRouter({ routes }) {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  useEffect(() => {
    const handlePopState = () => {
      setCurrentPath(window.location.pathname);
    };

    window.addEventListener('popstate', handlePopState);
    return () => window.removeEventListener('popstate', handlePopState);
  }, []);

  const navigate = useCallback((path) => {
    history.pushState(null, '', path);
    setCurrentPath(path);
  }, []);

  const matchedRoute = routes.find(route => route.path === currentPath);

  if (!matchedRoute) {
    return <div>404 - Not Found</div>;
  }

  const Component = matchedRoute.component;
  return <Component />;
}

function HomePage() {
  return <div>Home Page</div>;
}

function ProductsPage() {
  return <div>Products Page</div>;
}

const routes = [
  { path: '/', component: HomePage },
  { path: '/products', component: ProductsPage },
];

export function App() {
  return <SimpleRouter routes={routes} />;
}

Key insight: The magic is simply detecting when currentPath changes and re-rendering the matching component. The browser handles the URL and history automatically.

# Route Matching: Finding the Right Component {#route-matching}

The core challenge of routing is matching a URL to a route definition. URLs can have parameters, wildcards, and nested segments. Matching must be fast and reliable.

# Simple Matching: Exact Paths

typescript
// Simplest case: exact path matching
function matchRoute(pathname: string, routes: Route[]): Route | null {
  return routes.find(route => route.path === pathname) || null;
}

// /products matches /products ✓
// /products/123 doesn't match /products ✗

# Pattern Matching: Parameterized Routes

Most routers support parameterized routes like /products/:id. Implementing this requires parsing the pattern and extracting parameters.

# TypeScript Version: Route Matching with Parameters

typescript
interface RoutePattern {
  path: string; // e.g., "/products/:id/reviews/:reviewId"
  pattern: RegExp; // Compiled pattern for matching
  paramNames: string[]; // ["id", "reviewId"]
}

interface Params {
  [key: string]: string;
}

// Compile a route pattern into a regex
function compileRoute(routePath: string): RoutePattern {
  // Convert "/products/:id" → regex + param names

  // Split by segments
  const segments = routePath.split('/').filter(Boolean);

  // Replace :param with capture groups
  const patternSegments = segments.map(segment => {
    if (segment.startsWith(':')) {
      return '([^/]+)'; // Capture non-slash characters
    }
    return segment; // Literal segment
  });

  // Create regex
  const pattern = new RegExp(`^/${patternSegments.join('/')}/?

# How Client-Side Routing Works: From URLs to Components

When you click a link in a React application and the page changes instantly without a full reload, that's client-side routing at work. But what's actually happening under the hood? How does the browser know to render a different component when the URL changes? And why does the back button work without you implementing anything?

Understanding client-side routing mechanics is crucial for debugging navigation issues, building custom routers, and appreciating why libraries like React Router exist. Let's tear open the engine and see how it all works.

# Table of Contents

  1. The Problem Client-Side Routing Solves
  2. Browser History API: The Foundation
  3. URL Synchronization: Watching for Changes
  4. Route Matching: Finding the Right Component
  5. Building a Simple Router from Scratch
  6. Advanced Routing: Nested Routes and Parameters
  7. Handling Navigation Events
  8. Performance Considerations
  9. Real-World Routing Patterns
  10. FAQ

# The Problem Client-Side Routing Solves {#the-problem}

In traditional server-side applications, every navigation triggers a full HTTP request. The browser unloads the current page, requests a new one from the server, parses the HTML, loads CSS and JavaScript, and renders everything from scratch.

This works, but it has problems:

  1. Slow - Network latency, server processing, page parse time
  2. Jarring - The page flickers and reloads, losing scroll position
  3. State loss - Any client-side state (form inputs, scroll position, animations) is lost
  4. Bandwidth waste - Even if two pages share a layout, both are fully redownloaded

Client-side routing solves this by:

  • Keeping the application loaded in memory
  • Changing only what needs to change
  • Preserving state across "page" transitions
  • Making navigation instant

# TypeScript Version: The Problem Illustrated

typescript
// Server-side navigation (traditional)
// When you click a link:
// 1. Full page reload
// 2. All state lost
// 3. CSS re-parsed
// 4. JS re-executed
// 5. Components re-mounted

// Client-side routing
// When you click a link:
// 1. URL changes (browser history updated)
// 2. Component re-renders
// 3. Everything else stays in memory
// 4. Navigation is instant

interface NavigationComparison {
  serverSide: {
    time: number; // 500-2000ms
    stateLoss: boolean; // true
    renderTime: number; // Full page
  };
  clientSide: {
    time: number; // 10-100ms
    stateLoss: boolean; // false
    renderTime: number; // Only changed components
  };
}

export function NavigationComparison() {
  const comparison: NavigationComparison = {
    serverSide: {
      time: 1000,
      stateLoss: true,
      renderTime: 2000,
    },
    clientSide: {
      time: 50,
      stateLoss: false,
      renderTime: 50,
    },
  };

  return (
    <div>
      <p>Server-side navigation: {comparison.serverSide.time}ms</p>
      <p>Client-side navigation: {comparison.clientSide.time}ms</p>
      <p>Client-side is {comparison.serverSide.time / comparison.clientSide.time}x faster</p>
    </div>
  );
}

# JavaScript Version

javascript
export function NavigationComparison() {
  const comparison = {
    serverSide: {
      time: 1000,
      stateLoss: true,
      renderTime: 2000,
    },
    clientSide: {
      time: 50,
      stateLoss: false,
      renderTime: 50,
    },
  };

  return (
    <div>
      <p>Server-side navigation: {comparison.serverSide.time}ms</p>
      <p>Client-side navigation: {comparison.clientSide.time}ms</p>
      <p>Client-side is {(comparison.serverSide.time / comparison.clientSide.time).toFixed(0)}x faster</p>
    </div>
  );
}

# Browser History API: The Foundation {#history-api}

Client-side routing is built on the History API. This JavaScript API lets you manipulate the browser's session history (the list of URLs visited in the browser tab) and change the URL without triggering a page reload.

# The Three Key Methods

1. history.pushState() - Adds an entry to the history stack and changes the URL

typescript
// Changes URL to /products without reloading
history.pushState(
  { page: 'products' }, // State object
  'Products',            // Title (mostly ignored)
  '/products'            // New URL
);

2. history.replaceState() - Replaces the current history entry

typescript
// Changes URL without adding to back button history
history.replaceState(
  { page: 'login' },
  'Login',
  '/login'
);

3. window.addEventListener('popstate', ...) - Listens for back/forward button

typescript
window.addEventListener('popstate', (event) => {
  // Back/forward button was clicked
  const state = event.state;
  console.log('Navigated to:', state);
});

# TypeScript Version: History API Fundamentals

typescript
interface HistoryState {
  page: string;
  data?: Record<string, any>;
}

export function HistoryAPIExample() {
  const handleNavigate = (page: string, path: string, data?: Record<string, any>) => {
    // 1. Create state object
    const state: HistoryState = { page, data };

    // 2. Push to history (changes URL)
    history.pushState(state, page, path);

    // 3. Update UI (normally done in useEffect listening to popstate)
    console.log(`Navigated to ${page}`);
  };

  const handleBack = () => {
    // Browser's back button calls this automatically via popstate event
    history.back();
  };

  const handleForward = () => {
    history.forward();
  };

  const handleGoToSpecific = (n: number) => {
    history.go(n); // Positive = forward, negative = back
  };

  return (
    <div>
      <button onClick={() => handleNavigate('products', '/products')}>
        Products
      </button>
      <button onClick={() => handleNavigate('about', '/about')}>
        About
      </button>
      <button onClick={handleBack}>Back</button>
      <button onClick={handleForward}>Forward</button>
    </div>
  );
}

// History Stack Example:
// [Home] → [Products] → [Product Detail] ← [About]
//                              ↑
//                         You are here

// Back button: Takes you to [Product Detail]
// Forward button: Takes you to [About]

# JavaScript Version

javascript
export function HistoryAPIExample() {
  const handleNavigate = (page, path, data) => {
    const state = { page, data };
    history.pushState(state, page, path);
    console.log(`Navigated to ${page}`);
  };

  const handleBack = () => {
    history.back();
  };

  const handleForward = () => {
    history.forward();
  };

  return (
    <div>
      <button onClick={() => handleNavigate('products', '/products')}>
        Products
      </button>
      <button onClick={() => handleNavigate('about', '/about')}>
        About
      </button>
      <button onClick={handleBack}>Back</button>
      <button onClick={handleForward}>Forward</button>
    </div>
  );
}

# URL Synchronization: Watching for Changes {#url-synchronization}

Client-side routing works by syncing the URL to your component state. When the URL changes (either by user action or navigation), the router detects it and updates which component to render.

# The Core Loop

  1. User action - Click a <Link> or use navigate()
  2. URL update - Call history.pushState() to change the URL
  3. Event listener - popstate event fires (if user clicked back/forward) or you can detect immediately
  4. Component update - Determine which component matches the new URL
  5. Render - React re-renders with the new component

# TypeScript Version: URL Synchronization

typescript
import { useState, useEffect, useCallback } from 'react';

interface Route {
  path: string;
  component: React.ComponentType;
}

interface SimpleRouterProps {
  routes: Route[];
}

export function SimpleRouter({ routes }: SimpleRouterProps) {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  // Listen for URL changes (back/forward buttons)
  useEffect(() => {
    const handlePopState = () => {
      setCurrentPath(window.location.pathname);
    };

    window.addEventListener('popstate', handlePopState);
    return () => window.removeEventListener('popstate', handlePopState);
  }, []);

  // Navigate programmatically
  const navigate = useCallback((path: string) => {
    // 1. Update browser history
    history.pushState(null, '', path);

    // 2. Update component state (this is what triggers re-render)
    setCurrentPath(path);
  }, []);

  // Find matching route
  const matchedRoute = routes.find(route => route.path === currentPath);

  if (!matchedRoute) {
    return <div>404 - Not Found</div>;
  }

  const Component = matchedRoute.component;
  return <Component />;
}

// Usage
function HomePage() {
  return <div>Home Page</div>;
}

function ProductsPage() {
  return <div>Products Page</div>;
}

const routes: Route[] = [
  { path: '/', component: HomePage },
  { path: '/products', component: ProductsPage },
];

export function App() {
  return <SimpleRouter routes={routes} />;
}

# JavaScript Version

javascript
import { useState, useEffect, useCallback } from 'react';

export function SimpleRouter({ routes }) {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  useEffect(() => {
    const handlePopState = () => {
      setCurrentPath(window.location.pathname);
    };

    window.addEventListener('popstate', handlePopState);
    return () => window.removeEventListener('popstate', handlePopState);
  }, []);

  const navigate = useCallback((path) => {
    history.pushState(null, '', path);
    setCurrentPath(path);
  }, []);

  const matchedRoute = routes.find(route => route.path === currentPath);

  if (!matchedRoute) {
    return <div>404 - Not Found</div>;
  }

  const Component = matchedRoute.component;
  return <Component />;
}

function HomePage() {
  return <div>Home Page</div>;
}

function ProductsPage() {
  return <div>Products Page</div>;
}

const routes = [
  { path: '/', component: HomePage },
  { path: '/products', component: ProductsPage },
];

export function App() {
  return <SimpleRouter routes={routes} />;
}

Key insight: The magic is simply detecting when currentPath changes and re-rendering the matching component. The browser handles the URL and history automatically.

# Route Matching: Finding the Right Component {#route-matching}

The core challenge of routing is matching a URL to a route definition. URLs can have parameters, wildcards, and nested segments. Matching must be fast and reliable.

# Simple Matching: Exact Paths

typescript
// Simplest case: exact path matching
function matchRoute(pathname: string, routes: Route[]): Route | null {
  return routes.find(route => route.path === pathname) || null;
}

// /products matches /products ✓
// /products/123 doesn't match /products ✗

# Pattern Matching: Parameterized Routes

Most routers support parameterized routes like /products/:id. Implementing this requires parsing the pattern and extracting parameters.

# TypeScript Version: Route Matching with Parameters

);
// Extract param names const paramNames = segments .filter(s => s.startsWith(':')) .map(s => s.slice(1)); // Remove the ':' return { path: routePath, pattern, paramNames }; } // Match URL and extract parameters function matchRoute( pathname: string, compiledRoute: RoutePattern ): Params | null { const match = pathname.match(compiledRoute.pattern); if (!match) { return null; } // match[0] is the full match, match[1], match[2], etc. are groups const params: Params = {}; compiledRoute.paramNames.forEach((paramName, index) => { params[paramName] = match[index + 1]; }); return params; } // Usage Example const productRoute = compileRoute('/products/:id'); const match = matchRoute('/products/123', productRoute); console.log(match); // { id: "123" } // Real-world matching with multiple routes const routes = [ { path: '/', component: HomePage }, { path: '/products', component: ProductsPage }, { path: '/products/:id', component: ProductDetailPage }, { path: '/products/:id/reviews/:reviewId', component: ReviewPage }, ]; const compiledRoutes = routes.map(route => ({ ...route, ...compileRoute(route.path), })); function findMatchingRoute(pathname: string) { for (const route of compiledRoutes) { const params = matchRoute(pathname, route); if (params !== null) { return { route, params }; } } return null; } // Test routing console.log(findMatchingRoute('/products')); // ProductsPage, no params console.log(findMatchingRoute('/products/123')); // ProductDetailPage, { id: '123' } console.log(findMatchingRoute('/products/123/reviews/456')); // ReviewPage, { id: '123', reviewId: '456' }

# JavaScript Version

javascript
function compileRoute(routePath) {
  const segments = routePath.split('/').filter(Boolean);

  const patternSegments = segments.map(segment => {
    if (segment.startsWith(':')) {
      return '([^/]+)';
    }
    return segment;
  });

  const pattern = new RegExp(`^/${patternSegments.join('/')}/?

# How Client-Side Routing Works: From URLs to Components

When you click a link in a React application and the page changes instantly without a full reload, that's client-side routing at work. But what's actually happening under the hood? How does the browser know to render a different component when the URL changes? And why does the back button work without you implementing anything?

Understanding client-side routing mechanics is crucial for debugging navigation issues, building custom routers, and appreciating why libraries like React Router exist. Let's tear open the engine and see how it all works.

# Table of Contents

  1. The Problem Client-Side Routing Solves
  2. Browser History API: The Foundation
  3. URL Synchronization: Watching for Changes
  4. Route Matching: Finding the Right Component
  5. Building a Simple Router from Scratch
  6. Advanced Routing: Nested Routes and Parameters
  7. Handling Navigation Events
  8. Performance Considerations
  9. Real-World Routing Patterns
  10. FAQ

# The Problem Client-Side Routing Solves {#the-problem}

In traditional server-side applications, every navigation triggers a full HTTP request. The browser unloads the current page, requests a new one from the server, parses the HTML, loads CSS and JavaScript, and renders everything from scratch.

This works, but it has problems:

  1. Slow - Network latency, server processing, page parse time
  2. Jarring - The page flickers and reloads, losing scroll position
  3. State loss - Any client-side state (form inputs, scroll position, animations) is lost
  4. Bandwidth waste - Even if two pages share a layout, both are fully redownloaded

Client-side routing solves this by:

  • Keeping the application loaded in memory
  • Changing only what needs to change
  • Preserving state across "page" transitions
  • Making navigation instant

# TypeScript Version: The Problem Illustrated

typescript
// Server-side navigation (traditional)
// When you click a link:
// 1. Full page reload
// 2. All state lost
// 3. CSS re-parsed
// 4. JS re-executed
// 5. Components re-mounted

// Client-side routing
// When you click a link:
// 1. URL changes (browser history updated)
// 2. Component re-renders
// 3. Everything else stays in memory
// 4. Navigation is instant

interface NavigationComparison {
  serverSide: {
    time: number; // 500-2000ms
    stateLoss: boolean; // true
    renderTime: number; // Full page
  };
  clientSide: {
    time: number; // 10-100ms
    stateLoss: boolean; // false
    renderTime: number; // Only changed components
  };
}

export function NavigationComparison() {
  const comparison: NavigationComparison = {
    serverSide: {
      time: 1000,
      stateLoss: true,
      renderTime: 2000,
    },
    clientSide: {
      time: 50,
      stateLoss: false,
      renderTime: 50,
    },
  };

  return (
    <div>
      <p>Server-side navigation: {comparison.serverSide.time}ms</p>
      <p>Client-side navigation: {comparison.clientSide.time}ms</p>
      <p>Client-side is {comparison.serverSide.time / comparison.clientSide.time}x faster</p>
    </div>
  );
}

# JavaScript Version

javascript
export function NavigationComparison() {
  const comparison = {
    serverSide: {
      time: 1000,
      stateLoss: true,
      renderTime: 2000,
    },
    clientSide: {
      time: 50,
      stateLoss: false,
      renderTime: 50,
    },
  };

  return (
    <div>
      <p>Server-side navigation: {comparison.serverSide.time}ms</p>
      <p>Client-side navigation: {comparison.clientSide.time}ms</p>
      <p>Client-side is {(comparison.serverSide.time / comparison.clientSide.time).toFixed(0)}x faster</p>
    </div>
  );
}

# Browser History API: The Foundation {#history-api}

Client-side routing is built on the History API. This JavaScript API lets you manipulate the browser's session history (the list of URLs visited in the browser tab) and change the URL without triggering a page reload.

# The Three Key Methods

1. history.pushState() - Adds an entry to the history stack and changes the URL

typescript
// Changes URL to /products without reloading
history.pushState(
  { page: 'products' }, // State object
  'Products',            // Title (mostly ignored)
  '/products'            // New URL
);

2. history.replaceState() - Replaces the current history entry

typescript
// Changes URL without adding to back button history
history.replaceState(
  { page: 'login' },
  'Login',
  '/login'
);

3. window.addEventListener('popstate', ...) - Listens for back/forward button

typescript
window.addEventListener('popstate', (event) => {
  // Back/forward button was clicked
  const state = event.state;
  console.log('Navigated to:', state);
});

# TypeScript Version: History API Fundamentals

typescript
interface HistoryState {
  page: string;
  data?: Record<string, any>;
}

export function HistoryAPIExample() {
  const handleNavigate = (page: string, path: string, data?: Record<string, any>) => {
    // 1. Create state object
    const state: HistoryState = { page, data };

    // 2. Push to history (changes URL)
    history.pushState(state, page, path);

    // 3. Update UI (normally done in useEffect listening to popstate)
    console.log(`Navigated to ${page}`);
  };

  const handleBack = () => {
    // Browser's back button calls this automatically via popstate event
    history.back();
  };

  const handleForward = () => {
    history.forward();
  };

  const handleGoToSpecific = (n: number) => {
    history.go(n); // Positive = forward, negative = back
  };

  return (
    <div>
      <button onClick={() => handleNavigate('products', '/products')}>
        Products
      </button>
      <button onClick={() => handleNavigate('about', '/about')}>
        About
      </button>
      <button onClick={handleBack}>Back</button>
      <button onClick={handleForward}>Forward</button>
    </div>
  );
}

// History Stack Example:
// [Home] → [Products] → [Product Detail] ← [About]
//                              ↑
//                         You are here

// Back button: Takes you to [Product Detail]
// Forward button: Takes you to [About]

# JavaScript Version

javascript
export function HistoryAPIExample() {
  const handleNavigate = (page, path, data) => {
    const state = { page, data };
    history.pushState(state, page, path);
    console.log(`Navigated to ${page}`);
  };

  const handleBack = () => {
    history.back();
  };

  const handleForward = () => {
    history.forward();
  };

  return (
    <div>
      <button onClick={() => handleNavigate('products', '/products')}>
        Products
      </button>
      <button onClick={() => handleNavigate('about', '/about')}>
        About
      </button>
      <button onClick={handleBack}>Back</button>
      <button onClick={handleForward}>Forward</button>
    </div>
  );
}

# URL Synchronization: Watching for Changes {#url-synchronization}

Client-side routing works by syncing the URL to your component state. When the URL changes (either by user action or navigation), the router detects it and updates which component to render.

# The Core Loop

  1. User action - Click a <Link> or use navigate()
  2. URL update - Call history.pushState() to change the URL
  3. Event listener - popstate event fires (if user clicked back/forward) or you can detect immediately
  4. Component update - Determine which component matches the new URL
  5. Render - React re-renders with the new component

# TypeScript Version: URL Synchronization

typescript
import { useState, useEffect, useCallback } from 'react';

interface Route {
  path: string;
  component: React.ComponentType;
}

interface SimpleRouterProps {
  routes: Route[];
}

export function SimpleRouter({ routes }: SimpleRouterProps) {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  // Listen for URL changes (back/forward buttons)
  useEffect(() => {
    const handlePopState = () => {
      setCurrentPath(window.location.pathname);
    };

    window.addEventListener('popstate', handlePopState);
    return () => window.removeEventListener('popstate', handlePopState);
  }, []);

  // Navigate programmatically
  const navigate = useCallback((path: string) => {
    // 1. Update browser history
    history.pushState(null, '', path);

    // 2. Update component state (this is what triggers re-render)
    setCurrentPath(path);
  }, []);

  // Find matching route
  const matchedRoute = routes.find(route => route.path === currentPath);

  if (!matchedRoute) {
    return <div>404 - Not Found</div>;
  }

  const Component = matchedRoute.component;
  return <Component />;
}

// Usage
function HomePage() {
  return <div>Home Page</div>;
}

function ProductsPage() {
  return <div>Products Page</div>;
}

const routes: Route[] = [
  { path: '/', component: HomePage },
  { path: '/products', component: ProductsPage },
];

export function App() {
  return <SimpleRouter routes={routes} />;
}

# JavaScript Version

javascript
import { useState, useEffect, useCallback } from 'react';

export function SimpleRouter({ routes }) {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  useEffect(() => {
    const handlePopState = () => {
      setCurrentPath(window.location.pathname);
    };

    window.addEventListener('popstate', handlePopState);
    return () => window.removeEventListener('popstate', handlePopState);
  }, []);

  const navigate = useCallback((path) => {
    history.pushState(null, '', path);
    setCurrentPath(path);
  }, []);

  const matchedRoute = routes.find(route => route.path === currentPath);

  if (!matchedRoute) {
    return <div>404 - Not Found</div>;
  }

  const Component = matchedRoute.component;
  return <Component />;
}

function HomePage() {
  return <div>Home Page</div>;
}

function ProductsPage() {
  return <div>Products Page</div>;
}

const routes = [
  { path: '/', component: HomePage },
  { path: '/products', component: ProductsPage },
];

export function App() {
  return <SimpleRouter routes={routes} />;
}

Key insight: The magic is simply detecting when currentPath changes and re-rendering the matching component. The browser handles the URL and history automatically.

# Route Matching: Finding the Right Component {#route-matching}

The core challenge of routing is matching a URL to a route definition. URLs can have parameters, wildcards, and nested segments. Matching must be fast and reliable.

# Simple Matching: Exact Paths

typescript
// Simplest case: exact path matching
function matchRoute(pathname: string, routes: Route[]): Route | null {
  return routes.find(route => route.path === pathname) || null;
}

// /products matches /products ✓
// /products/123 doesn't match /products ✗

# Pattern Matching: Parameterized Routes

Most routers support parameterized routes like /products/:id. Implementing this requires parsing the pattern and extracting parameters.

# TypeScript Version: Route Matching with Parameters

typescript
interface RoutePattern {
  path: string; // e.g., "/products/:id/reviews/:reviewId"
  pattern: RegExp; // Compiled pattern for matching
  paramNames: string[]; // ["id", "reviewId"]
}

interface Params {
  [key: string]: string;
}

// Compile a route pattern into a regex
function compileRoute(routePath: string): RoutePattern {
  // Convert "/products/:id" → regex + param names

  // Split by segments
  const segments = routePath.split('/').filter(Boolean);

  // Replace :param with capture groups
  const patternSegments = segments.map(segment => {
    if (segment.startsWith(':')) {
      return '([^/]+)'; // Capture non-slash characters
    }
    return segment; // Literal segment
  });

  // Create regex
  const pattern = new RegExp(`^/${patternSegments.join('/')}/?

# How Client-Side Routing Works: From URLs to Components

When you click a link in a React application and the page changes instantly without a full reload, that's client-side routing at work. But what's actually happening under the hood? How does the browser know to render a different component when the URL changes? And why does the back button work without you implementing anything?

Understanding client-side routing mechanics is crucial for debugging navigation issues, building custom routers, and appreciating why libraries like React Router exist. Let's tear open the engine and see how it all works.

# Table of Contents

  1. The Problem Client-Side Routing Solves
  2. Browser History API: The Foundation
  3. URL Synchronization: Watching for Changes
  4. Route Matching: Finding the Right Component
  5. Building a Simple Router from Scratch
  6. Advanced Routing: Nested Routes and Parameters
  7. Handling Navigation Events
  8. Performance Considerations
  9. Real-World Routing Patterns
  10. FAQ

# The Problem Client-Side Routing Solves {#the-problem}

In traditional server-side applications, every navigation triggers a full HTTP request. The browser unloads the current page, requests a new one from the server, parses the HTML, loads CSS and JavaScript, and renders everything from scratch.

This works, but it has problems:

  1. Slow - Network latency, server processing, page parse time
  2. Jarring - The page flickers and reloads, losing scroll position
  3. State loss - Any client-side state (form inputs, scroll position, animations) is lost
  4. Bandwidth waste - Even if two pages share a layout, both are fully redownloaded

Client-side routing solves this by:

  • Keeping the application loaded in memory
  • Changing only what needs to change
  • Preserving state across "page" transitions
  • Making navigation instant

# TypeScript Version: The Problem Illustrated

typescript
// Server-side navigation (traditional)
// When you click a link:
// 1. Full page reload
// 2. All state lost
// 3. CSS re-parsed
// 4. JS re-executed
// 5. Components re-mounted

// Client-side routing
// When you click a link:
// 1. URL changes (browser history updated)
// 2. Component re-renders
// 3. Everything else stays in memory
// 4. Navigation is instant

interface NavigationComparison {
  serverSide: {
    time: number; // 500-2000ms
    stateLoss: boolean; // true
    renderTime: number; // Full page
  };
  clientSide: {
    time: number; // 10-100ms
    stateLoss: boolean; // false
    renderTime: number; // Only changed components
  };
}

export function NavigationComparison() {
  const comparison: NavigationComparison = {
    serverSide: {
      time: 1000,
      stateLoss: true,
      renderTime: 2000,
    },
    clientSide: {
      time: 50,
      stateLoss: false,
      renderTime: 50,
    },
  };

  return (
    <div>
      <p>Server-side navigation: {comparison.serverSide.time}ms</p>
      <p>Client-side navigation: {comparison.clientSide.time}ms</p>
      <p>Client-side is {comparison.serverSide.time / comparison.clientSide.time}x faster</p>
    </div>
  );
}

# JavaScript Version

javascript
export function NavigationComparison() {
  const comparison = {
    serverSide: {
      time: 1000,
      stateLoss: true,
      renderTime: 2000,
    },
    clientSide: {
      time: 50,
      stateLoss: false,
      renderTime: 50,
    },
  };

  return (
    <div>
      <p>Server-side navigation: {comparison.serverSide.time}ms</p>
      <p>Client-side navigation: {comparison.clientSide.time}ms</p>
      <p>Client-side is {(comparison.serverSide.time / comparison.clientSide.time).toFixed(0)}x faster</p>
    </div>
  );
}

# Browser History API: The Foundation {#history-api}

Client-side routing is built on the History API. This JavaScript API lets you manipulate the browser's session history (the list of URLs visited in the browser tab) and change the URL without triggering a page reload.

# The Three Key Methods

1. history.pushState() - Adds an entry to the history stack and changes the URL

typescript
// Changes URL to /products without reloading
history.pushState(
  { page: 'products' }, // State object
  'Products',            // Title (mostly ignored)
  '/products'            // New URL
);

2. history.replaceState() - Replaces the current history entry

typescript
// Changes URL without adding to back button history
history.replaceState(
  { page: 'login' },
  'Login',
  '/login'
);

3. window.addEventListener('popstate', ...) - Listens for back/forward button

typescript
window.addEventListener('popstate', (event) => {
  // Back/forward button was clicked
  const state = event.state;
  console.log('Navigated to:', state);
});

# TypeScript Version: History API Fundamentals

typescript
interface HistoryState {
  page: string;
  data?: Record<string, any>;
}

export function HistoryAPIExample() {
  const handleNavigate = (page: string, path: string, data?: Record<string, any>) => {
    // 1. Create state object
    const state: HistoryState = { page, data };

    // 2. Push to history (changes URL)
    history.pushState(state, page, path);

    // 3. Update UI (normally done in useEffect listening to popstate)
    console.log(`Navigated to ${page}`);
  };

  const handleBack = () => {
    // Browser's back button calls this automatically via popstate event
    history.back();
  };

  const handleForward = () => {
    history.forward();
  };

  const handleGoToSpecific = (n: number) => {
    history.go(n); // Positive = forward, negative = back
  };

  return (
    <div>
      <button onClick={() => handleNavigate('products', '/products')}>
        Products
      </button>
      <button onClick={() => handleNavigate('about', '/about')}>
        About
      </button>
      <button onClick={handleBack}>Back</button>
      <button onClick={handleForward}>Forward</button>
    </div>
  );
}

// History Stack Example:
// [Home] → [Products] → [Product Detail] ← [About]
//                              ↑
//                         You are here

// Back button: Takes you to [Product Detail]
// Forward button: Takes you to [About]

# JavaScript Version

javascript
export function HistoryAPIExample() {
  const handleNavigate = (page, path, data) => {
    const state = { page, data };
    history.pushState(state, page, path);
    console.log(`Navigated to ${page}`);
  };

  const handleBack = () => {
    history.back();
  };

  const handleForward = () => {
    history.forward();
  };

  return (
    <div>
      <button onClick={() => handleNavigate('products', '/products')}>
        Products
      </button>
      <button onClick={() => handleNavigate('about', '/about')}>
        About
      </button>
      <button onClick={handleBack}>Back</button>
      <button onClick={handleForward}>Forward</button>
    </div>
  );
}

# URL Synchronization: Watching for Changes {#url-synchronization}

Client-side routing works by syncing the URL to your component state. When the URL changes (either by user action or navigation), the router detects it and updates which component to render.

# The Core Loop

  1. User action - Click a <Link> or use navigate()
  2. URL update - Call history.pushState() to change the URL
  3. Event listener - popstate event fires (if user clicked back/forward) or you can detect immediately
  4. Component update - Determine which component matches the new URL
  5. Render - React re-renders with the new component

# TypeScript Version: URL Synchronization

typescript
import { useState, useEffect, useCallback } from 'react';

interface Route {
  path: string;
  component: React.ComponentType;
}

interface SimpleRouterProps {
  routes: Route[];
}

export function SimpleRouter({ routes }: SimpleRouterProps) {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  // Listen for URL changes (back/forward buttons)
  useEffect(() => {
    const handlePopState = () => {
      setCurrentPath(window.location.pathname);
    };

    window.addEventListener('popstate', handlePopState);
    return () => window.removeEventListener('popstate', handlePopState);
  }, []);

  // Navigate programmatically
  const navigate = useCallback((path: string) => {
    // 1. Update browser history
    history.pushState(null, '', path);

    // 2. Update component state (this is what triggers re-render)
    setCurrentPath(path);
  }, []);

  // Find matching route
  const matchedRoute = routes.find(route => route.path === currentPath);

  if (!matchedRoute) {
    return <div>404 - Not Found</div>;
  }

  const Component = matchedRoute.component;
  return <Component />;
}

// Usage
function HomePage() {
  return <div>Home Page</div>;
}

function ProductsPage() {
  return <div>Products Page</div>;
}

const routes: Route[] = [
  { path: '/', component: HomePage },
  { path: '/products', component: ProductsPage },
];

export function App() {
  return <SimpleRouter routes={routes} />;
}

# JavaScript Version

javascript
import { useState, useEffect, useCallback } from 'react';

export function SimpleRouter({ routes }) {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  useEffect(() => {
    const handlePopState = () => {
      setCurrentPath(window.location.pathname);
    };

    window.addEventListener('popstate', handlePopState);
    return () => window.removeEventListener('popstate', handlePopState);
  }, []);

  const navigate = useCallback((path) => {
    history.pushState(null, '', path);
    setCurrentPath(path);
  }, []);

  const matchedRoute = routes.find(route => route.path === currentPath);

  if (!matchedRoute) {
    return <div>404 - Not Found</div>;
  }

  const Component = matchedRoute.component;
  return <Component />;
}

function HomePage() {
  return <div>Home Page</div>;
}

function ProductsPage() {
  return <div>Products Page</div>;
}

const routes = [
  { path: '/', component: HomePage },
  { path: '/products', component: ProductsPage },
];

export function App() {
  return <SimpleRouter routes={routes} />;
}

Key insight: The magic is simply detecting when currentPath changes and re-rendering the matching component. The browser handles the URL and history automatically.

# Route Matching: Finding the Right Component {#route-matching}

The core challenge of routing is matching a URL to a route definition. URLs can have parameters, wildcards, and nested segments. Matching must be fast and reliable.

# Simple Matching: Exact Paths

typescript
// Simplest case: exact path matching
function matchRoute(pathname: string, routes: Route[]): Route | null {
  return routes.find(route => route.path === pathname) || null;
}

// /products matches /products ✓
// /products/123 doesn't match /products ✗

# Pattern Matching: Parameterized Routes

Most routers support parameterized routes like /products/:id. Implementing this requires parsing the pattern and extracting parameters.

# TypeScript Version: Route Matching with Parameters

);
// Extract param names const paramNames = segments .filter(s => s.startsWith(':')) .map(s => s.slice(1)); // Remove the ':' return { path: routePath, pattern, paramNames }; } // Match URL and extract parameters function matchRoute( pathname: string, compiledRoute: RoutePattern ): Params | null { const match = pathname.match(compiledRoute.pattern); if (!match) { return null; } // match[0] is the full match, match[1], match[2], etc. are groups const params: Params = {}; compiledRoute.paramNames.forEach((paramName, index) => { params[paramName] = match[index + 1]; }); return params; } // Usage Example const productRoute = compileRoute('/products/:id'); const match = matchRoute('/products/123', productRoute); console.log(match); // { id: "123" } // Real-world matching with multiple routes const routes = [ { path: '/', component: HomePage }, { path: '/products', component: ProductsPage }, { path: '/products/:id', component: ProductDetailPage }, { path: '/products/:id/reviews/:reviewId', component: ReviewPage }, ]; const compiledRoutes = routes.map(route => ({ ...route, ...compileRoute(route.path), })); function findMatchingRoute(pathname: string) { for (const route of compiledRoutes) { const params = matchRoute(pathname, route); if (params !== null) { return { route, params }; } } return null; } // Test routing console.log(findMatchingRoute('/products')); // ProductsPage, no params console.log(findMatchingRoute('/products/123')); // ProductDetailPage, { id: '123' } console.log(findMatchingRoute('/products/123/reviews/456')); // ReviewPage, { id: '123', reviewId: '456' }

# JavaScript Version

);
const paramNames = segments .filter(s => s.startsWith(':')) .map(s => s.slice(1)); return { path: routePath, pattern, paramNames }; } function matchRoute(pathname, compiledRoute) { const match = pathname.match(compiledRoute.pattern); if (!match) { return null; } const params = {}; compiledRoute.paramNames.forEach((paramName, index) => { params[paramName] = match[index + 1]; }); return params; } const productRoute = compileRoute('/products/:id'); const match = matchRoute('/products/123', productRoute); console.log(match); // { id: '123' }

# Building a Simple Router from Scratch {#building-router}

Now let's build a functional router that combines everything: History API, URL synchronization, and route matching.

# TypeScript Version: Custom Router Implementation

);
const paramNames = segments .filter(s => s.startsWith(':')) .map(s => s.slice(1)); return { path: routePath, pattern, paramNames }; } function matchRoute( pathname: string, compiledRoute: ReturnType<typeof compileRoute> ): Record<string, string> | null { const match = pathname.match(compiledRoute.pattern); if (!match) return null; const params: Record<string, string> = {}; compiledRoute.paramNames.forEach((paramName, index) => { params[paramName] = match[index + 1]; }); return params; } // Router Provider Component interface RouterProviderProps { routes: Route[]; children?: ReactNode; } export function Router({ routes, children }: RouterProviderProps) { const [currentPath, setCurrentPath] = useState(window.location.pathname); const [params, setParams] = useState<Record<string, string>>({}); // Compile routes once const compiledRoutes = routes.map(route => ({ ...route, ...compileRoute(route.path), })); // Listen for back/forward button useEffect(() => { const handlePopState = () => { setCurrentPath(window.location.pathname); }; window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); }, []); // Find matching route and extract params useEffect(() => { for (const route of compiledRoutes) { const matchedParams = matchRoute(currentPath, route); if (matchedParams !== null) { setParams(matchedParams); return; } } setParams({}); }, [currentPath, compiledRoutes]); const navigate = useCallback((path: string) => { history.pushState(null, '', path); setCurrentPath(path); }, []); const contextValue: RouterContextType = { currentPath, navigate, params, }; // Find component to render const matchedRoute = compiledRoutes.find(route => { const match = matchRoute(currentPath, route); return match !== null; }); if (!matchedRoute) { return <div>404 - Not Found</div>; } const Component = matchedRoute.element; return ( <RouterContext.Provider value={contextValue}> <Component /> {children} </RouterContext.Provider> ); } // Hooks for using the router export function useRouter() { const context = useContext(RouterContext); if (!context) { throw new Error('useRouter must be used within Router'); } return context; } export function useParams(): Record<string, string> { const { params } = useRouter(); return params; } export function useNavigate() { const { navigate } = useRouter(); return navigate; } // Link Component interface LinkProps { to: string; children: ReactNode; className?: string; } export function Link({ to, children, className }: LinkProps) { const navigate = useNavigate(); const { currentPath } = useRouter(); const isActive = currentPath === to; return ( <a href={to} className={`${className} ${isActive ? 'active' : ''}`} onClick={e => { e.preventDefault(); navigate(to); }} > {children} </a> ); } // Example components function HomePage() { return <h1>Welcome Home</h1>; } function ProductsPage() { return <h1>Products List</h1>; } interface ProductDetailPageProps { id?: string; } function ProductDetailPage() { const params = useParams(); return <h1>Product {params.id}</h1>; } // Usage const routes: Route[] = [ { path: '/', element: HomePage }, { path: '/products', element: ProductsPage }, { path: '/products/:id', element: ProductDetailPage }, ]; export function App() { return ( <Router routes={routes}> <nav> <Link to="/">Home</Link> <Link to="/products">Products</Link> <Link to="/products/123">Product 123</Link> </nav> </Router> ); }

# JavaScript Version

javascript
import {
  createContext,
  useContext,
  useState,
  useEffect,
  useCallback,
} from 'react';

const RouterContext = createContext(undefined);

function compileRoute(routePath) {
  const segments = routePath.split('/').filter(Boolean);
  const patternSegments = segments.map(segment =>
    segment.startsWith(':') ? '([^/]+)' : segment
  );
  const pattern = new RegExp(`^/${patternSegments.join('/')}/?

# How Client-Side Routing Works: From URLs to Components

When you click a link in a React application and the page changes instantly without a full reload, that's client-side routing at work. But what's actually happening under the hood? How does the browser know to render a different component when the URL changes? And why does the back button work without you implementing anything?

Understanding client-side routing mechanics is crucial for debugging navigation issues, building custom routers, and appreciating why libraries like React Router exist. Let's tear open the engine and see how it all works.

# Table of Contents

  1. The Problem Client-Side Routing Solves
  2. Browser History API: The Foundation
  3. URL Synchronization: Watching for Changes
  4. Route Matching: Finding the Right Component
  5. Building a Simple Router from Scratch
  6. Advanced Routing: Nested Routes and Parameters
  7. Handling Navigation Events
  8. Performance Considerations
  9. Real-World Routing Patterns
  10. FAQ

# The Problem Client-Side Routing Solves {#the-problem}

In traditional server-side applications, every navigation triggers a full HTTP request. The browser unloads the current page, requests a new one from the server, parses the HTML, loads CSS and JavaScript, and renders everything from scratch.

This works, but it has problems:

  1. Slow - Network latency, server processing, page parse time
  2. Jarring - The page flickers and reloads, losing scroll position
  3. State loss - Any client-side state (form inputs, scroll position, animations) is lost
  4. Bandwidth waste - Even if two pages share a layout, both are fully redownloaded

Client-side routing solves this by:

  • Keeping the application loaded in memory
  • Changing only what needs to change
  • Preserving state across "page" transitions
  • Making navigation instant

# TypeScript Version: The Problem Illustrated

typescript
// Server-side navigation (traditional)
// When you click a link:
// 1. Full page reload
// 2. All state lost
// 3. CSS re-parsed
// 4. JS re-executed
// 5. Components re-mounted

// Client-side routing
// When you click a link:
// 1. URL changes (browser history updated)
// 2. Component re-renders
// 3. Everything else stays in memory
// 4. Navigation is instant

interface NavigationComparison {
  serverSide: {
    time: number; // 500-2000ms
    stateLoss: boolean; // true
    renderTime: number; // Full page
  };
  clientSide: {
    time: number; // 10-100ms
    stateLoss: boolean; // false
    renderTime: number; // Only changed components
  };
}

export function NavigationComparison() {
  const comparison: NavigationComparison = {
    serverSide: {
      time: 1000,
      stateLoss: true,
      renderTime: 2000,
    },
    clientSide: {
      time: 50,
      stateLoss: false,
      renderTime: 50,
    },
  };

  return (
    <div>
      <p>Server-side navigation: {comparison.serverSide.time}ms</p>
      <p>Client-side navigation: {comparison.clientSide.time}ms</p>
      <p>Client-side is {comparison.serverSide.time / comparison.clientSide.time}x faster</p>
    </div>
  );
}

# JavaScript Version

javascript
export function NavigationComparison() {
  const comparison = {
    serverSide: {
      time: 1000,
      stateLoss: true,
      renderTime: 2000,
    },
    clientSide: {
      time: 50,
      stateLoss: false,
      renderTime: 50,
    },
  };

  return (
    <div>
      <p>Server-side navigation: {comparison.serverSide.time}ms</p>
      <p>Client-side navigation: {comparison.clientSide.time}ms</p>
      <p>Client-side is {(comparison.serverSide.time / comparison.clientSide.time).toFixed(0)}x faster</p>
    </div>
  );
}

# Browser History API: The Foundation {#history-api}

Client-side routing is built on the History API. This JavaScript API lets you manipulate the browser's session history (the list of URLs visited in the browser tab) and change the URL without triggering a page reload.

# The Three Key Methods

1. history.pushState() - Adds an entry to the history stack and changes the URL

typescript
// Changes URL to /products without reloading
history.pushState(
  { page: 'products' }, // State object
  'Products',            // Title (mostly ignored)
  '/products'            // New URL
);

2. history.replaceState() - Replaces the current history entry

typescript
// Changes URL without adding to back button history
history.replaceState(
  { page: 'login' },
  'Login',
  '/login'
);

3. window.addEventListener('popstate', ...) - Listens for back/forward button

typescript
window.addEventListener('popstate', (event) => {
  // Back/forward button was clicked
  const state = event.state;
  console.log('Navigated to:', state);
});

# TypeScript Version: History API Fundamentals

typescript
interface HistoryState {
  page: string;
  data?: Record<string, any>;
}

export function HistoryAPIExample() {
  const handleNavigate = (page: string, path: string, data?: Record<string, any>) => {
    // 1. Create state object
    const state: HistoryState = { page, data };

    // 2. Push to history (changes URL)
    history.pushState(state, page, path);

    // 3. Update UI (normally done in useEffect listening to popstate)
    console.log(`Navigated to ${page}`);
  };

  const handleBack = () => {
    // Browser's back button calls this automatically via popstate event
    history.back();
  };

  const handleForward = () => {
    history.forward();
  };

  const handleGoToSpecific = (n: number) => {
    history.go(n); // Positive = forward, negative = back
  };

  return (
    <div>
      <button onClick={() => handleNavigate('products', '/products')}>
        Products
      </button>
      <button onClick={() => handleNavigate('about', '/about')}>
        About
      </button>
      <button onClick={handleBack}>Back</button>
      <button onClick={handleForward}>Forward</button>
    </div>
  );
}

// History Stack Example:
// [Home] → [Products] → [Product Detail] ← [About]
//                              ↑
//                         You are here

// Back button: Takes you to [Product Detail]
// Forward button: Takes you to [About]

# JavaScript Version

javascript
export function HistoryAPIExample() {
  const handleNavigate = (page, path, data) => {
    const state = { page, data };
    history.pushState(state, page, path);
    console.log(`Navigated to ${page}`);
  };

  const handleBack = () => {
    history.back();
  };

  const handleForward = () => {
    history.forward();
  };

  return (
    <div>
      <button onClick={() => handleNavigate('products', '/products')}>
        Products
      </button>
      <button onClick={() => handleNavigate('about', '/about')}>
        About
      </button>
      <button onClick={handleBack}>Back</button>
      <button onClick={handleForward}>Forward</button>
    </div>
  );
}

# URL Synchronization: Watching for Changes {#url-synchronization}

Client-side routing works by syncing the URL to your component state. When the URL changes (either by user action or navigation), the router detects it and updates which component to render.

# The Core Loop

  1. User action - Click a <Link> or use navigate()
  2. URL update - Call history.pushState() to change the URL
  3. Event listener - popstate event fires (if user clicked back/forward) or you can detect immediately
  4. Component update - Determine which component matches the new URL
  5. Render - React re-renders with the new component

# TypeScript Version: URL Synchronization

typescript
import { useState, useEffect, useCallback } from 'react';

interface Route {
  path: string;
  component: React.ComponentType;
}

interface SimpleRouterProps {
  routes: Route[];
}

export function SimpleRouter({ routes }: SimpleRouterProps) {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  // Listen for URL changes (back/forward buttons)
  useEffect(() => {
    const handlePopState = () => {
      setCurrentPath(window.location.pathname);
    };

    window.addEventListener('popstate', handlePopState);
    return () => window.removeEventListener('popstate', handlePopState);
  }, []);

  // Navigate programmatically
  const navigate = useCallback((path: string) => {
    // 1. Update browser history
    history.pushState(null, '', path);

    // 2. Update component state (this is what triggers re-render)
    setCurrentPath(path);
  }, []);

  // Find matching route
  const matchedRoute = routes.find(route => route.path === currentPath);

  if (!matchedRoute) {
    return <div>404 - Not Found</div>;
  }

  const Component = matchedRoute.component;
  return <Component />;
}

// Usage
function HomePage() {
  return <div>Home Page</div>;
}

function ProductsPage() {
  return <div>Products Page</div>;
}

const routes: Route[] = [
  { path: '/', component: HomePage },
  { path: '/products', component: ProductsPage },
];

export function App() {
  return <SimpleRouter routes={routes} />;
}

# JavaScript Version

javascript
import { useState, useEffect, useCallback } from 'react';

export function SimpleRouter({ routes }) {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  useEffect(() => {
    const handlePopState = () => {
      setCurrentPath(window.location.pathname);
    };

    window.addEventListener('popstate', handlePopState);
    return () => window.removeEventListener('popstate', handlePopState);
  }, []);

  const navigate = useCallback((path) => {
    history.pushState(null, '', path);
    setCurrentPath(path);
  }, []);

  const matchedRoute = routes.find(route => route.path === currentPath);

  if (!matchedRoute) {
    return <div>404 - Not Found</div>;
  }

  const Component = matchedRoute.component;
  return <Component />;
}

function HomePage() {
  return <div>Home Page</div>;
}

function ProductsPage() {
  return <div>Products Page</div>;
}

const routes = [
  { path: '/', component: HomePage },
  { path: '/products', component: ProductsPage },
];

export function App() {
  return <SimpleRouter routes={routes} />;
}

Key insight: The magic is simply detecting when currentPath changes and re-rendering the matching component. The browser handles the URL and history automatically.

# Route Matching: Finding the Right Component {#route-matching}

The core challenge of routing is matching a URL to a route definition. URLs can have parameters, wildcards, and nested segments. Matching must be fast and reliable.

# Simple Matching: Exact Paths

typescript
// Simplest case: exact path matching
function matchRoute(pathname: string, routes: Route[]): Route | null {
  return routes.find(route => route.path === pathname) || null;
}

// /products matches /products ✓
// /products/123 doesn't match /products ✗

# Pattern Matching: Parameterized Routes

Most routers support parameterized routes like /products/:id. Implementing this requires parsing the pattern and extracting parameters.

# TypeScript Version: Route Matching with Parameters

typescript
interface RoutePattern {
  path: string; // e.g., "/products/:id/reviews/:reviewId"
  pattern: RegExp; // Compiled pattern for matching
  paramNames: string[]; // ["id", "reviewId"]
}

interface Params {
  [key: string]: string;
}

// Compile a route pattern into a regex
function compileRoute(routePath: string): RoutePattern {
  // Convert "/products/:id" → regex + param names

  // Split by segments
  const segments = routePath.split('/').filter(Boolean);

  // Replace :param with capture groups
  const patternSegments = segments.map(segment => {
    if (segment.startsWith(':')) {
      return '([^/]+)'; // Capture non-slash characters
    }
    return segment; // Literal segment
  });

  // Create regex
  const pattern = new RegExp(`^/${patternSegments.join('/')}/?

# How Client-Side Routing Works: From URLs to Components

When you click a link in a React application and the page changes instantly without a full reload, that's client-side routing at work. But what's actually happening under the hood? How does the browser know to render a different component when the URL changes? And why does the back button work without you implementing anything?

Understanding client-side routing mechanics is crucial for debugging navigation issues, building custom routers, and appreciating why libraries like React Router exist. Let's tear open the engine and see how it all works.

# Table of Contents

  1. The Problem Client-Side Routing Solves
  2. Browser History API: The Foundation
  3. URL Synchronization: Watching for Changes
  4. Route Matching: Finding the Right Component
  5. Building a Simple Router from Scratch
  6. Advanced Routing: Nested Routes and Parameters
  7. Handling Navigation Events
  8. Performance Considerations
  9. Real-World Routing Patterns
  10. FAQ

# The Problem Client-Side Routing Solves {#the-problem}

In traditional server-side applications, every navigation triggers a full HTTP request. The browser unloads the current page, requests a new one from the server, parses the HTML, loads CSS and JavaScript, and renders everything from scratch.

This works, but it has problems:

  1. Slow - Network latency, server processing, page parse time
  2. Jarring - The page flickers and reloads, losing scroll position
  3. State loss - Any client-side state (form inputs, scroll position, animations) is lost
  4. Bandwidth waste - Even if two pages share a layout, both are fully redownloaded

Client-side routing solves this by:

  • Keeping the application loaded in memory
  • Changing only what needs to change
  • Preserving state across "page" transitions
  • Making navigation instant

# TypeScript Version: The Problem Illustrated

typescript
// Server-side navigation (traditional)
// When you click a link:
// 1. Full page reload
// 2. All state lost
// 3. CSS re-parsed
// 4. JS re-executed
// 5. Components re-mounted

// Client-side routing
// When you click a link:
// 1. URL changes (browser history updated)
// 2. Component re-renders
// 3. Everything else stays in memory
// 4. Navigation is instant

interface NavigationComparison {
  serverSide: {
    time: number; // 500-2000ms
    stateLoss: boolean; // true
    renderTime: number; // Full page
  };
  clientSide: {
    time: number; // 10-100ms
    stateLoss: boolean; // false
    renderTime: number; // Only changed components
  };
}

export function NavigationComparison() {
  const comparison: NavigationComparison = {
    serverSide: {
      time: 1000,
      stateLoss: true,
      renderTime: 2000,
    },
    clientSide: {
      time: 50,
      stateLoss: false,
      renderTime: 50,
    },
  };

  return (
    <div>
      <p>Server-side navigation: {comparison.serverSide.time}ms</p>
      <p>Client-side navigation: {comparison.clientSide.time}ms</p>
      <p>Client-side is {comparison.serverSide.time / comparison.clientSide.time}x faster</p>
    </div>
  );
}

# JavaScript Version

javascript
export function NavigationComparison() {
  const comparison = {
    serverSide: {
      time: 1000,
      stateLoss: true,
      renderTime: 2000,
    },
    clientSide: {
      time: 50,
      stateLoss: false,
      renderTime: 50,
    },
  };

  return (
    <div>
      <p>Server-side navigation: {comparison.serverSide.time}ms</p>
      <p>Client-side navigation: {comparison.clientSide.time}ms</p>
      <p>Client-side is {(comparison.serverSide.time / comparison.clientSide.time).toFixed(0)}x faster</p>
    </div>
  );
}

# Browser History API: The Foundation {#history-api}

Client-side routing is built on the History API. This JavaScript API lets you manipulate the browser's session history (the list of URLs visited in the browser tab) and change the URL without triggering a page reload.

# The Three Key Methods

1. history.pushState() - Adds an entry to the history stack and changes the URL

typescript
// Changes URL to /products without reloading
history.pushState(
  { page: 'products' }, // State object
  'Products',            // Title (mostly ignored)
  '/products'            // New URL
);

2. history.replaceState() - Replaces the current history entry

typescript
// Changes URL without adding to back button history
history.replaceState(
  { page: 'login' },
  'Login',
  '/login'
);

3. window.addEventListener('popstate', ...) - Listens for back/forward button

typescript
window.addEventListener('popstate', (event) => {
  // Back/forward button was clicked
  const state = event.state;
  console.log('Navigated to:', state);
});

# TypeScript Version: History API Fundamentals

typescript
interface HistoryState {
  page: string;
  data?: Record<string, any>;
}

export function HistoryAPIExample() {
  const handleNavigate = (page: string, path: string, data?: Record<string, any>) => {
    // 1. Create state object
    const state: HistoryState = { page, data };

    // 2. Push to history (changes URL)
    history.pushState(state, page, path);

    // 3. Update UI (normally done in useEffect listening to popstate)
    console.log(`Navigated to ${page}`);
  };

  const handleBack = () => {
    // Browser's back button calls this automatically via popstate event
    history.back();
  };

  const handleForward = () => {
    history.forward();
  };

  const handleGoToSpecific = (n: number) => {
    history.go(n); // Positive = forward, negative = back
  };

  return (
    <div>
      <button onClick={() => handleNavigate('products', '/products')}>
        Products
      </button>
      <button onClick={() => handleNavigate('about', '/about')}>
        About
      </button>
      <button onClick={handleBack}>Back</button>
      <button onClick={handleForward}>Forward</button>
    </div>
  );
}

// History Stack Example:
// [Home] → [Products] → [Product Detail] ← [About]
//                              ↑
//                         You are here

// Back button: Takes you to [Product Detail]
// Forward button: Takes you to [About]

# JavaScript Version

javascript
export function HistoryAPIExample() {
  const handleNavigate = (page, path, data) => {
    const state = { page, data };
    history.pushState(state, page, path);
    console.log(`Navigated to ${page}`);
  };

  const handleBack = () => {
    history.back();
  };

  const handleForward = () => {
    history.forward();
  };

  return (
    <div>
      <button onClick={() => handleNavigate('products', '/products')}>
        Products
      </button>
      <button onClick={() => handleNavigate('about', '/about')}>
        About
      </button>
      <button onClick={handleBack}>Back</button>
      <button onClick={handleForward}>Forward</button>
    </div>
  );
}

# URL Synchronization: Watching for Changes {#url-synchronization}

Client-side routing works by syncing the URL to your component state. When the URL changes (either by user action or navigation), the router detects it and updates which component to render.

# The Core Loop

  1. User action - Click a <Link> or use navigate()
  2. URL update - Call history.pushState() to change the URL
  3. Event listener - popstate event fires (if user clicked back/forward) or you can detect immediately
  4. Component update - Determine which component matches the new URL
  5. Render - React re-renders with the new component

# TypeScript Version: URL Synchronization

typescript
import { useState, useEffect, useCallback } from 'react';

interface Route {
  path: string;
  component: React.ComponentType;
}

interface SimpleRouterProps {
  routes: Route[];
}

export function SimpleRouter({ routes }: SimpleRouterProps) {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  // Listen for URL changes (back/forward buttons)
  useEffect(() => {
    const handlePopState = () => {
      setCurrentPath(window.location.pathname);
    };

    window.addEventListener('popstate', handlePopState);
    return () => window.removeEventListener('popstate', handlePopState);
  }, []);

  // Navigate programmatically
  const navigate = useCallback((path: string) => {
    // 1. Update browser history
    history.pushState(null, '', path);

    // 2. Update component state (this is what triggers re-render)
    setCurrentPath(path);
  }, []);

  // Find matching route
  const matchedRoute = routes.find(route => route.path === currentPath);

  if (!matchedRoute) {
    return <div>404 - Not Found</div>;
  }

  const Component = matchedRoute.component;
  return <Component />;
}

// Usage
function HomePage() {
  return <div>Home Page</div>;
}

function ProductsPage() {
  return <div>Products Page</div>;
}

const routes: Route[] = [
  { path: '/', component: HomePage },
  { path: '/products', component: ProductsPage },
];

export function App() {
  return <SimpleRouter routes={routes} />;
}

# JavaScript Version

javascript
import { useState, useEffect, useCallback } from 'react';

export function SimpleRouter({ routes }) {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  useEffect(() => {
    const handlePopState = () => {
      setCurrentPath(window.location.pathname);
    };

    window.addEventListener('popstate', handlePopState);
    return () => window.removeEventListener('popstate', handlePopState);
  }, []);

  const navigate = useCallback((path) => {
    history.pushState(null, '', path);
    setCurrentPath(path);
  }, []);

  const matchedRoute = routes.find(route => route.path === currentPath);

  if (!matchedRoute) {
    return <div>404 - Not Found</div>;
  }

  const Component = matchedRoute.component;
  return <Component />;
}

function HomePage() {
  return <div>Home Page</div>;
}

function ProductsPage() {
  return <div>Products Page</div>;
}

const routes = [
  { path: '/', component: HomePage },
  { path: '/products', component: ProductsPage },
];

export function App() {
  return <SimpleRouter routes={routes} />;
}

Key insight: The magic is simply detecting when currentPath changes and re-rendering the matching component. The browser handles the URL and history automatically.

# Route Matching: Finding the Right Component {#route-matching}

The core challenge of routing is matching a URL to a route definition. URLs can have parameters, wildcards, and nested segments. Matching must be fast and reliable.

# Simple Matching: Exact Paths

typescript
// Simplest case: exact path matching
function matchRoute(pathname: string, routes: Route[]): Route | null {
  return routes.find(route => route.path === pathname) || null;
}

// /products matches /products ✓
// /products/123 doesn't match /products ✗

# Pattern Matching: Parameterized Routes

Most routers support parameterized routes like /products/:id. Implementing this requires parsing the pattern and extracting parameters.

# TypeScript Version: Route Matching with Parameters

);
// Extract param names const paramNames = segments .filter(s => s.startsWith(':')) .map(s => s.slice(1)); // Remove the ':' return { path: routePath, pattern, paramNames }; } // Match URL and extract parameters function matchRoute( pathname: string, compiledRoute: RoutePattern ): Params | null { const match = pathname.match(compiledRoute.pattern); if (!match) { return null; } // match[0] is the full match, match[1], match[2], etc. are groups const params: Params = {}; compiledRoute.paramNames.forEach((paramName, index) => { params[paramName] = match[index + 1]; }); return params; } // Usage Example const productRoute = compileRoute('/products/:id'); const match = matchRoute('/products/123', productRoute); console.log(match); // { id: "123" } // Real-world matching with multiple routes const routes = [ { path: '/', component: HomePage }, { path: '/products', component: ProductsPage }, { path: '/products/:id', component: ProductDetailPage }, { path: '/products/:id/reviews/:reviewId', component: ReviewPage }, ]; const compiledRoutes = routes.map(route => ({ ...route, ...compileRoute(route.path), })); function findMatchingRoute(pathname: string) { for (const route of compiledRoutes) { const params = matchRoute(pathname, route); if (params !== null) { return { route, params }; } } return null; } // Test routing console.log(findMatchingRoute('/products')); // ProductsPage, no params console.log(findMatchingRoute('/products/123')); // ProductDetailPage, { id: '123' } console.log(findMatchingRoute('/products/123/reviews/456')); // ReviewPage, { id: '123', reviewId: '456' }

# JavaScript Version

javascript
function compileRoute(routePath) {
  const segments = routePath.split('/').filter(Boolean);

  const patternSegments = segments.map(segment => {
    if (segment.startsWith(':')) {
      return '([^/]+)';
    }
    return segment;
  });

  const pattern = new RegExp(`^/${patternSegments.join('/')}/?

# How Client-Side Routing Works: From URLs to Components

When you click a link in a React application and the page changes instantly without a full reload, that's client-side routing at work. But what's actually happening under the hood? How does the browser know to render a different component when the URL changes? And why does the back button work without you implementing anything?

Understanding client-side routing mechanics is crucial for debugging navigation issues, building custom routers, and appreciating why libraries like React Router exist. Let's tear open the engine and see how it all works.

# Table of Contents

  1. The Problem Client-Side Routing Solves
  2. Browser History API: The Foundation
  3. URL Synchronization: Watching for Changes
  4. Route Matching: Finding the Right Component
  5. Building a Simple Router from Scratch
  6. Advanced Routing: Nested Routes and Parameters
  7. Handling Navigation Events
  8. Performance Considerations
  9. Real-World Routing Patterns
  10. FAQ

# The Problem Client-Side Routing Solves {#the-problem}

In traditional server-side applications, every navigation triggers a full HTTP request. The browser unloads the current page, requests a new one from the server, parses the HTML, loads CSS and JavaScript, and renders everything from scratch.

This works, but it has problems:

  1. Slow - Network latency, server processing, page parse time
  2. Jarring - The page flickers and reloads, losing scroll position
  3. State loss - Any client-side state (form inputs, scroll position, animations) is lost
  4. Bandwidth waste - Even if two pages share a layout, both are fully redownloaded

Client-side routing solves this by:

  • Keeping the application loaded in memory
  • Changing only what needs to change
  • Preserving state across "page" transitions
  • Making navigation instant

# TypeScript Version: The Problem Illustrated

typescript
// Server-side navigation (traditional)
// When you click a link:
// 1. Full page reload
// 2. All state lost
// 3. CSS re-parsed
// 4. JS re-executed
// 5. Components re-mounted

// Client-side routing
// When you click a link:
// 1. URL changes (browser history updated)
// 2. Component re-renders
// 3. Everything else stays in memory
// 4. Navigation is instant

interface NavigationComparison {
  serverSide: {
    time: number; // 500-2000ms
    stateLoss: boolean; // true
    renderTime: number; // Full page
  };
  clientSide: {
    time: number; // 10-100ms
    stateLoss: boolean; // false
    renderTime: number; // Only changed components
  };
}

export function NavigationComparison() {
  const comparison: NavigationComparison = {
    serverSide: {
      time: 1000,
      stateLoss: true,
      renderTime: 2000,
    },
    clientSide: {
      time: 50,
      stateLoss: false,
      renderTime: 50,
    },
  };

  return (
    <div>
      <p>Server-side navigation: {comparison.serverSide.time}ms</p>
      <p>Client-side navigation: {comparison.clientSide.time}ms</p>
      <p>Client-side is {comparison.serverSide.time / comparison.clientSide.time}x faster</p>
    </div>
  );
}

# JavaScript Version

javascript
export function NavigationComparison() {
  const comparison = {
    serverSide: {
      time: 1000,
      stateLoss: true,
      renderTime: 2000,
    },
    clientSide: {
      time: 50,
      stateLoss: false,
      renderTime: 50,
    },
  };

  return (
    <div>
      <p>Server-side navigation: {comparison.serverSide.time}ms</p>
      <p>Client-side navigation: {comparison.clientSide.time}ms</p>
      <p>Client-side is {(comparison.serverSide.time / comparison.clientSide.time).toFixed(0)}x faster</p>
    </div>
  );
}

# Browser History API: The Foundation {#history-api}

Client-side routing is built on the History API. This JavaScript API lets you manipulate the browser's session history (the list of URLs visited in the browser tab) and change the URL without triggering a page reload.

# The Three Key Methods

1. history.pushState() - Adds an entry to the history stack and changes the URL

typescript
// Changes URL to /products without reloading
history.pushState(
  { page: 'products' }, // State object
  'Products',            // Title (mostly ignored)
  '/products'            // New URL
);

2. history.replaceState() - Replaces the current history entry

typescript
// Changes URL without adding to back button history
history.replaceState(
  { page: 'login' },
  'Login',
  '/login'
);

3. window.addEventListener('popstate', ...) - Listens for back/forward button

typescript
window.addEventListener('popstate', (event) => {
  // Back/forward button was clicked
  const state = event.state;
  console.log('Navigated to:', state);
});

# TypeScript Version: History API Fundamentals

typescript
interface HistoryState {
  page: string;
  data?: Record<string, any>;
}

export function HistoryAPIExample() {
  const handleNavigate = (page: string, path: string, data?: Record<string, any>) => {
    // 1. Create state object
    const state: HistoryState = { page, data };

    // 2. Push to history (changes URL)
    history.pushState(state, page, path);

    // 3. Update UI (normally done in useEffect listening to popstate)
    console.log(`Navigated to ${page}`);
  };

  const handleBack = () => {
    // Browser's back button calls this automatically via popstate event
    history.back();
  };

  const handleForward = () => {
    history.forward();
  };

  const handleGoToSpecific = (n: number) => {
    history.go(n); // Positive = forward, negative = back
  };

  return (
    <div>
      <button onClick={() => handleNavigate('products', '/products')}>
        Products
      </button>
      <button onClick={() => handleNavigate('about', '/about')}>
        About
      </button>
      <button onClick={handleBack}>Back</button>
      <button onClick={handleForward}>Forward</button>
    </div>
  );
}

// History Stack Example:
// [Home] → [Products] → [Product Detail] ← [About]
//                              ↑
//                         You are here

// Back button: Takes you to [Product Detail]
// Forward button: Takes you to [About]

# JavaScript Version

javascript
export function HistoryAPIExample() {
  const handleNavigate = (page, path, data) => {
    const state = { page, data };
    history.pushState(state, page, path);
    console.log(`Navigated to ${page}`);
  };

  const handleBack = () => {
    history.back();
  };

  const handleForward = () => {
    history.forward();
  };

  return (
    <div>
      <button onClick={() => handleNavigate('products', '/products')}>
        Products
      </button>
      <button onClick={() => handleNavigate('about', '/about')}>
        About
      </button>
      <button onClick={handleBack}>Back</button>
      <button onClick={handleForward}>Forward</button>
    </div>
  );
}

# URL Synchronization: Watching for Changes {#url-synchronization}

Client-side routing works by syncing the URL to your component state. When the URL changes (either by user action or navigation), the router detects it and updates which component to render.

# The Core Loop

  1. User action - Click a <Link> or use navigate()
  2. URL update - Call history.pushState() to change the URL
  3. Event listener - popstate event fires (if user clicked back/forward) or you can detect immediately
  4. Component update - Determine which component matches the new URL
  5. Render - React re-renders with the new component

# TypeScript Version: URL Synchronization

typescript
import { useState, useEffect, useCallback } from 'react';

interface Route {
  path: string;
  component: React.ComponentType;
}

interface SimpleRouterProps {
  routes: Route[];
}

export function SimpleRouter({ routes }: SimpleRouterProps) {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  // Listen for URL changes (back/forward buttons)
  useEffect(() => {
    const handlePopState = () => {
      setCurrentPath(window.location.pathname);
    };

    window.addEventListener('popstate', handlePopState);
    return () => window.removeEventListener('popstate', handlePopState);
  }, []);

  // Navigate programmatically
  const navigate = useCallback((path: string) => {
    // 1. Update browser history
    history.pushState(null, '', path);

    // 2. Update component state (this is what triggers re-render)
    setCurrentPath(path);
  }, []);

  // Find matching route
  const matchedRoute = routes.find(route => route.path === currentPath);

  if (!matchedRoute) {
    return <div>404 - Not Found</div>;
  }

  const Component = matchedRoute.component;
  return <Component />;
}

// Usage
function HomePage() {
  return <div>Home Page</div>;
}

function ProductsPage() {
  return <div>Products Page</div>;
}

const routes: Route[] = [
  { path: '/', component: HomePage },
  { path: '/products', component: ProductsPage },
];

export function App() {
  return <SimpleRouter routes={routes} />;
}

# JavaScript Version

javascript
import { useState, useEffect, useCallback } from 'react';

export function SimpleRouter({ routes }) {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  useEffect(() => {
    const handlePopState = () => {
      setCurrentPath(window.location.pathname);
    };

    window.addEventListener('popstate', handlePopState);
    return () => window.removeEventListener('popstate', handlePopState);
  }, []);

  const navigate = useCallback((path) => {
    history.pushState(null, '', path);
    setCurrentPath(path);
  }, []);

  const matchedRoute = routes.find(route => route.path === currentPath);

  if (!matchedRoute) {
    return <div>404 - Not Found</div>;
  }

  const Component = matchedRoute.component;
  return <Component />;
}

function HomePage() {
  return <div>Home Page</div>;
}

function ProductsPage() {
  return <div>Products Page</div>;
}

const routes = [
  { path: '/', component: HomePage },
  { path: '/products', component: ProductsPage },
];

export function App() {
  return <SimpleRouter routes={routes} />;
}

Key insight: The magic is simply detecting when currentPath changes and re-rendering the matching component. The browser handles the URL and history automatically.

# Route Matching: Finding the Right Component {#route-matching}

The core challenge of routing is matching a URL to a route definition. URLs can have parameters, wildcards, and nested segments. Matching must be fast and reliable.

# Simple Matching: Exact Paths

typescript
// Simplest case: exact path matching
function matchRoute(pathname: string, routes: Route[]): Route | null {
  return routes.find(route => route.path === pathname) || null;
}

// /products matches /products ✓
// /products/123 doesn't match /products ✗

# Pattern Matching: Parameterized Routes

Most routers support parameterized routes like /products/:id. Implementing this requires parsing the pattern and extracting parameters.

# TypeScript Version: Route Matching with Parameters

typescript
interface RoutePattern {
  path: string; // e.g., "/products/:id/reviews/:reviewId"
  pattern: RegExp; // Compiled pattern for matching
  paramNames: string[]; // ["id", "reviewId"]
}

interface Params {
  [key: string]: string;
}

// Compile a route pattern into a regex
function compileRoute(routePath: string): RoutePattern {
  // Convert "/products/:id" → regex + param names

  // Split by segments
  const segments = routePath.split('/').filter(Boolean);

  // Replace :param with capture groups
  const patternSegments = segments.map(segment => {
    if (segment.startsWith(':')) {
      return '([^/]+)'; // Capture non-slash characters
    }
    return segment; // Literal segment
  });

  // Create regex
  const pattern = new RegExp(`^/${patternSegments.join('/')}/?

# How Client-Side Routing Works: From URLs to Components

When you click a link in a React application and the page changes instantly without a full reload, that's client-side routing at work. But what's actually happening under the hood? How does the browser know to render a different component when the URL changes? And why does the back button work without you implementing anything?

Understanding client-side routing mechanics is crucial for debugging navigation issues, building custom routers, and appreciating why libraries like React Router exist. Let's tear open the engine and see how it all works.

# Table of Contents

  1. The Problem Client-Side Routing Solves
  2. Browser History API: The Foundation
  3. URL Synchronization: Watching for Changes
  4. Route Matching: Finding the Right Component
  5. Building a Simple Router from Scratch
  6. Advanced Routing: Nested Routes and Parameters
  7. Handling Navigation Events
  8. Performance Considerations
  9. Real-World Routing Patterns
  10. FAQ

# The Problem Client-Side Routing Solves {#the-problem}

In traditional server-side applications, every navigation triggers a full HTTP request. The browser unloads the current page, requests a new one from the server, parses the HTML, loads CSS and JavaScript, and renders everything from scratch.

This works, but it has problems:

  1. Slow - Network latency, server processing, page parse time
  2. Jarring - The page flickers and reloads, losing scroll position
  3. State loss - Any client-side state (form inputs, scroll position, animations) is lost
  4. Bandwidth waste - Even if two pages share a layout, both are fully redownloaded

Client-side routing solves this by:

  • Keeping the application loaded in memory
  • Changing only what needs to change
  • Preserving state across "page" transitions
  • Making navigation instant

# TypeScript Version: The Problem Illustrated

typescript
// Server-side navigation (traditional)
// When you click a link:
// 1. Full page reload
// 2. All state lost
// 3. CSS re-parsed
// 4. JS re-executed
// 5. Components re-mounted

// Client-side routing
// When you click a link:
// 1. URL changes (browser history updated)
// 2. Component re-renders
// 3. Everything else stays in memory
// 4. Navigation is instant

interface NavigationComparison {
  serverSide: {
    time: number; // 500-2000ms
    stateLoss: boolean; // true
    renderTime: number; // Full page
  };
  clientSide: {
    time: number; // 10-100ms
    stateLoss: boolean; // false
    renderTime: number; // Only changed components
  };
}

export function NavigationComparison() {
  const comparison: NavigationComparison = {
    serverSide: {
      time: 1000,
      stateLoss: true,
      renderTime: 2000,
    },
    clientSide: {
      time: 50,
      stateLoss: false,
      renderTime: 50,
    },
  };

  return (
    <div>
      <p>Server-side navigation: {comparison.serverSide.time}ms</p>
      <p>Client-side navigation: {comparison.clientSide.time}ms</p>
      <p>Client-side is {comparison.serverSide.time / comparison.clientSide.time}x faster</p>
    </div>
  );
}

# JavaScript Version

javascript
export function NavigationComparison() {
  const comparison = {
    serverSide: {
      time: 1000,
      stateLoss: true,
      renderTime: 2000,
    },
    clientSide: {
      time: 50,
      stateLoss: false,
      renderTime: 50,
    },
  };

  return (
    <div>
      <p>Server-side navigation: {comparison.serverSide.time}ms</p>
      <p>Client-side navigation: {comparison.clientSide.time}ms</p>
      <p>Client-side is {(comparison.serverSide.time / comparison.clientSide.time).toFixed(0)}x faster</p>
    </div>
  );
}

# Browser History API: The Foundation {#history-api}

Client-side routing is built on the History API. This JavaScript API lets you manipulate the browser's session history (the list of URLs visited in the browser tab) and change the URL without triggering a page reload.

# The Three Key Methods

1. history.pushState() - Adds an entry to the history stack and changes the URL

typescript
// Changes URL to /products without reloading
history.pushState(
  { page: 'products' }, // State object
  'Products',            // Title (mostly ignored)
  '/products'            // New URL
);

2. history.replaceState() - Replaces the current history entry

typescript
// Changes URL without adding to back button history
history.replaceState(
  { page: 'login' },
  'Login',
  '/login'
);

3. window.addEventListener('popstate', ...) - Listens for back/forward button

typescript
window.addEventListener('popstate', (event) => {
  // Back/forward button was clicked
  const state = event.state;
  console.log('Navigated to:', state);
});

# TypeScript Version: History API Fundamentals

typescript
interface HistoryState {
  page: string;
  data?: Record<string, any>;
}

export function HistoryAPIExample() {
  const handleNavigate = (page: string, path: string, data?: Record<string, any>) => {
    // 1. Create state object
    const state: HistoryState = { page, data };

    // 2. Push to history (changes URL)
    history.pushState(state, page, path);

    // 3. Update UI (normally done in useEffect listening to popstate)
    console.log(`Navigated to ${page}`);
  };

  const handleBack = () => {
    // Browser's back button calls this automatically via popstate event
    history.back();
  };

  const handleForward = () => {
    history.forward();
  };

  const handleGoToSpecific = (n: number) => {
    history.go(n); // Positive = forward, negative = back
  };

  return (
    <div>
      <button onClick={() => handleNavigate('products', '/products')}>
        Products
      </button>
      <button onClick={() => handleNavigate('about', '/about')}>
        About
      </button>
      <button onClick={handleBack}>Back</button>
      <button onClick={handleForward}>Forward</button>
    </div>
  );
}

// History Stack Example:
// [Home] → [Products] → [Product Detail] ← [About]
//                              ↑
//                         You are here

// Back button: Takes you to [Product Detail]
// Forward button: Takes you to [About]

# JavaScript Version

javascript
export function HistoryAPIExample() {
  const handleNavigate = (page, path, data) => {
    const state = { page, data };
    history.pushState(state, page, path);
    console.log(`Navigated to ${page}`);
  };

  const handleBack = () => {
    history.back();
  };

  const handleForward = () => {
    history.forward();
  };

  return (
    <div>
      <button onClick={() => handleNavigate('products', '/products')}>
        Products
      </button>
      <button onClick={() => handleNavigate('about', '/about')}>
        About
      </button>
      <button onClick={handleBack}>Back</button>
      <button onClick={handleForward}>Forward</button>
    </div>
  );
}

# URL Synchronization: Watching for Changes {#url-synchronization}

Client-side routing works by syncing the URL to your component state. When the URL changes (either by user action or navigation), the router detects it and updates which component to render.

# The Core Loop

  1. User action - Click a <Link> or use navigate()
  2. URL update - Call history.pushState() to change the URL
  3. Event listener - popstate event fires (if user clicked back/forward) or you can detect immediately
  4. Component update - Determine which component matches the new URL
  5. Render - React re-renders with the new component

# TypeScript Version: URL Synchronization

typescript
import { useState, useEffect, useCallback } from 'react';

interface Route {
  path: string;
  component: React.ComponentType;
}

interface SimpleRouterProps {
  routes: Route[];
}

export function SimpleRouter({ routes }: SimpleRouterProps) {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  // Listen for URL changes (back/forward buttons)
  useEffect(() => {
    const handlePopState = () => {
      setCurrentPath(window.location.pathname);
    };

    window.addEventListener('popstate', handlePopState);
    return () => window.removeEventListener('popstate', handlePopState);
  }, []);

  // Navigate programmatically
  const navigate = useCallback((path: string) => {
    // 1. Update browser history
    history.pushState(null, '', path);

    // 2. Update component state (this is what triggers re-render)
    setCurrentPath(path);
  }, []);

  // Find matching route
  const matchedRoute = routes.find(route => route.path === currentPath);

  if (!matchedRoute) {
    return <div>404 - Not Found</div>;
  }

  const Component = matchedRoute.component;
  return <Component />;
}

// Usage
function HomePage() {
  return <div>Home Page</div>;
}

function ProductsPage() {
  return <div>Products Page</div>;
}

const routes: Route[] = [
  { path: '/', component: HomePage },
  { path: '/products', component: ProductsPage },
];

export function App() {
  return <SimpleRouter routes={routes} />;
}

# JavaScript Version

javascript
import { useState, useEffect, useCallback } from 'react';

export function SimpleRouter({ routes }) {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  useEffect(() => {
    const handlePopState = () => {
      setCurrentPath(window.location.pathname);
    };

    window.addEventListener('popstate', handlePopState);
    return () => window.removeEventListener('popstate', handlePopState);
  }, []);

  const navigate = useCallback((path) => {
    history.pushState(null, '', path);
    setCurrentPath(path);
  }, []);

  const matchedRoute = routes.find(route => route.path === currentPath);

  if (!matchedRoute) {
    return <div>404 - Not Found</div>;
  }

  const Component = matchedRoute.component;
  return <Component />;
}

function HomePage() {
  return <div>Home Page</div>;
}

function ProductsPage() {
  return <div>Products Page</div>;
}

const routes = [
  { path: '/', component: HomePage },
  { path: '/products', component: ProductsPage },
];

export function App() {
  return <SimpleRouter routes={routes} />;
}

Key insight: The magic is simply detecting when currentPath changes and re-rendering the matching component. The browser handles the URL and history automatically.

# Route Matching: Finding the Right Component {#route-matching}

The core challenge of routing is matching a URL to a route definition. URLs can have parameters, wildcards, and nested segments. Matching must be fast and reliable.

# Simple Matching: Exact Paths

typescript
// Simplest case: exact path matching
function matchRoute(pathname: string, routes: Route[]): Route | null {
  return routes.find(route => route.path === pathname) || null;
}

// /products matches /products ✓
// /products/123 doesn't match /products ✗

# Pattern Matching: Parameterized Routes

Most routers support parameterized routes like /products/:id. Implementing this requires parsing the pattern and extracting parameters.

# TypeScript Version: Route Matching with Parameters

);
// Extract param names const paramNames = segments .filter(s => s.startsWith(':')) .map(s => s.slice(1)); // Remove the ':' return { path: routePath, pattern, paramNames }; } // Match URL and extract parameters function matchRoute( pathname: string, compiledRoute: RoutePattern ): Params | null { const match = pathname.match(compiledRoute.pattern); if (!match) { return null; } // match[0] is the full match, match[1], match[2], etc. are groups const params: Params = {}; compiledRoute.paramNames.forEach((paramName, index) => { params[paramName] = match[index + 1]; }); return params; } // Usage Example const productRoute = compileRoute('/products/:id'); const match = matchRoute('/products/123', productRoute); console.log(match); // { id: "123" } // Real-world matching with multiple routes const routes = [ { path: '/', component: HomePage }, { path: '/products', component: ProductsPage }, { path: '/products/:id', component: ProductDetailPage }, { path: '/products/:id/reviews/:reviewId', component: ReviewPage }, ]; const compiledRoutes = routes.map(route => ({ ...route, ...compileRoute(route.path), })); function findMatchingRoute(pathname: string) { for (const route of compiledRoutes) { const params = matchRoute(pathname, route); if (params !== null) { return { route, params }; } } return null; } // Test routing console.log(findMatchingRoute('/products')); // ProductsPage, no params console.log(findMatchingRoute('/products/123')); // ProductDetailPage, { id: '123' } console.log(findMatchingRoute('/products/123/reviews/456')); // ReviewPage, { id: '123', reviewId: '456' }

# JavaScript Version

);
const paramNames = segments .filter(s => s.startsWith(':')) .map(s => s.slice(1)); return { path: routePath, pattern, paramNames }; } function matchRoute(pathname, compiledRoute) { const match = pathname.match(compiledRoute.pattern); if (!match) { return null; } const params = {}; compiledRoute.paramNames.forEach((paramName, index) => { params[paramName] = match[index + 1]; }); return params; } const productRoute = compileRoute('/products/:id'); const match = matchRoute('/products/123', productRoute); console.log(match); // { id: '123' }

# Building a Simple Router from Scratch {#building-router}

Now let's build a functional router that combines everything: History API, URL synchronization, and route matching.

# TypeScript Version: Custom Router Implementation

typescript
import {
  ReactNode,
  createContext,
  useContext,
  useState,
  useEffect,
  useCallback,
} from 'react';

interface Route {
  path: string;
  element: React.ComponentType;
}

interface RouterContextType {
  currentPath: string;
  navigate: (path: string) => void;
  params: Record<string, string>;
}

const RouterContext = createContext<RouterContextType | undefined>(undefined);

// Route matching logic
function compileRoute(routePath: string) {
  const segments = routePath.split('/').filter(Boolean);
  const patternSegments = segments.map(segment =>
    segment.startsWith(':') ? '([^/]+)' : segment
  );
  const pattern = new RegExp(`^/${patternSegments.join('/')}/?

# How Client-Side Routing Works: From URLs to Components

When you click a link in a React application and the page changes instantly without a full reload, that's client-side routing at work. But what's actually happening under the hood? How does the browser know to render a different component when the URL changes? And why does the back button work without you implementing anything?

Understanding client-side routing mechanics is crucial for debugging navigation issues, building custom routers, and appreciating why libraries like React Router exist. Let's tear open the engine and see how it all works.

# Table of Contents

  1. The Problem Client-Side Routing Solves
  2. Browser History API: The Foundation
  3. URL Synchronization: Watching for Changes
  4. Route Matching: Finding the Right Component
  5. Building a Simple Router from Scratch
  6. Advanced Routing: Nested Routes and Parameters
  7. Handling Navigation Events
  8. Performance Considerations
  9. Real-World Routing Patterns
  10. FAQ

# The Problem Client-Side Routing Solves {#the-problem}

In traditional server-side applications, every navigation triggers a full HTTP request. The browser unloads the current page, requests a new one from the server, parses the HTML, loads CSS and JavaScript, and renders everything from scratch.

This works, but it has problems:

  1. Slow - Network latency, server processing, page parse time
  2. Jarring - The page flickers and reloads, losing scroll position
  3. State loss - Any client-side state (form inputs, scroll position, animations) is lost
  4. Bandwidth waste - Even if two pages share a layout, both are fully redownloaded

Client-side routing solves this by:

  • Keeping the application loaded in memory
  • Changing only what needs to change
  • Preserving state across "page" transitions
  • Making navigation instant

# TypeScript Version: The Problem Illustrated

typescript
// Server-side navigation (traditional)
// When you click a link:
// 1. Full page reload
// 2. All state lost
// 3. CSS re-parsed
// 4. JS re-executed
// 5. Components re-mounted

// Client-side routing
// When you click a link:
// 1. URL changes (browser history updated)
// 2. Component re-renders
// 3. Everything else stays in memory
// 4. Navigation is instant

interface NavigationComparison {
  serverSide: {
    time: number; // 500-2000ms
    stateLoss: boolean; // true
    renderTime: number; // Full page
  };
  clientSide: {
    time: number; // 10-100ms
    stateLoss: boolean; // false
    renderTime: number; // Only changed components
  };
}

export function NavigationComparison() {
  const comparison: NavigationComparison = {
    serverSide: {
      time: 1000,
      stateLoss: true,
      renderTime: 2000,
    },
    clientSide: {
      time: 50,
      stateLoss: false,
      renderTime: 50,
    },
  };

  return (
    <div>
      <p>Server-side navigation: {comparison.serverSide.time}ms</p>
      <p>Client-side navigation: {comparison.clientSide.time}ms</p>
      <p>Client-side is {comparison.serverSide.time / comparison.clientSide.time}x faster</p>
    </div>
  );
}

# JavaScript Version

javascript
export function NavigationComparison() {
  const comparison = {
    serverSide: {
      time: 1000,
      stateLoss: true,
      renderTime: 2000,
    },
    clientSide: {
      time: 50,
      stateLoss: false,
      renderTime: 50,
    },
  };

  return (
    <div>
      <p>Server-side navigation: {comparison.serverSide.time}ms</p>
      <p>Client-side navigation: {comparison.clientSide.time}ms</p>
      <p>Client-side is {(comparison.serverSide.time / comparison.clientSide.time).toFixed(0)}x faster</p>
    </div>
  );
}

# Browser History API: The Foundation {#history-api}

Client-side routing is built on the History API. This JavaScript API lets you manipulate the browser's session history (the list of URLs visited in the browser tab) and change the URL without triggering a page reload.

# The Three Key Methods

1. history.pushState() - Adds an entry to the history stack and changes the URL

typescript
// Changes URL to /products without reloading
history.pushState(
  { page: 'products' }, // State object
  'Products',            // Title (mostly ignored)
  '/products'            // New URL
);

2. history.replaceState() - Replaces the current history entry

typescript
// Changes URL without adding to back button history
history.replaceState(
  { page: 'login' },
  'Login',
  '/login'
);

3. window.addEventListener('popstate', ...) - Listens for back/forward button

typescript
window.addEventListener('popstate', (event) => {
  // Back/forward button was clicked
  const state = event.state;
  console.log('Navigated to:', state);
});

# TypeScript Version: History API Fundamentals

typescript
interface HistoryState {
  page: string;
  data?: Record<string, any>;
}

export function HistoryAPIExample() {
  const handleNavigate = (page: string, path: string, data?: Record<string, any>) => {
    // 1. Create state object
    const state: HistoryState = { page, data };

    // 2. Push to history (changes URL)
    history.pushState(state, page, path);

    // 3. Update UI (normally done in useEffect listening to popstate)
    console.log(`Navigated to ${page}`);
  };

  const handleBack = () => {
    // Browser's back button calls this automatically via popstate event
    history.back();
  };

  const handleForward = () => {
    history.forward();
  };

  const handleGoToSpecific = (n: number) => {
    history.go(n); // Positive = forward, negative = back
  };

  return (
    <div>
      <button onClick={() => handleNavigate('products', '/products')}>
        Products
      </button>
      <button onClick={() => handleNavigate('about', '/about')}>
        About
      </button>
      <button onClick={handleBack}>Back</button>
      <button onClick={handleForward}>Forward</button>
    </div>
  );
}

// History Stack Example:
// [Home] → [Products] → [Product Detail] ← [About]
//                              ↑
//                         You are here

// Back button: Takes you to [Product Detail]
// Forward button: Takes you to [About]

# JavaScript Version

javascript
export function HistoryAPIExample() {
  const handleNavigate = (page, path, data) => {
    const state = { page, data };
    history.pushState(state, page, path);
    console.log(`Navigated to ${page}`);
  };

  const handleBack = () => {
    history.back();
  };

  const handleForward = () => {
    history.forward();
  };

  return (
    <div>
      <button onClick={() => handleNavigate('products', '/products')}>
        Products
      </button>
      <button onClick={() => handleNavigate('about', '/about')}>
        About
      </button>
      <button onClick={handleBack}>Back</button>
      <button onClick={handleForward}>Forward</button>
    </div>
  );
}

# URL Synchronization: Watching for Changes {#url-synchronization}

Client-side routing works by syncing the URL to your component state. When the URL changes (either by user action or navigation), the router detects it and updates which component to render.

# The Core Loop

  1. User action - Click a <Link> or use navigate()
  2. URL update - Call history.pushState() to change the URL
  3. Event listener - popstate event fires (if user clicked back/forward) or you can detect immediately
  4. Component update - Determine which component matches the new URL
  5. Render - React re-renders with the new component

# TypeScript Version: URL Synchronization

typescript
import { useState, useEffect, useCallback } from 'react';

interface Route {
  path: string;
  component: React.ComponentType;
}

interface SimpleRouterProps {
  routes: Route[];
}

export function SimpleRouter({ routes }: SimpleRouterProps) {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  // Listen for URL changes (back/forward buttons)
  useEffect(() => {
    const handlePopState = () => {
      setCurrentPath(window.location.pathname);
    };

    window.addEventListener('popstate', handlePopState);
    return () => window.removeEventListener('popstate', handlePopState);
  }, []);

  // Navigate programmatically
  const navigate = useCallback((path: string) => {
    // 1. Update browser history
    history.pushState(null, '', path);

    // 2. Update component state (this is what triggers re-render)
    setCurrentPath(path);
  }, []);

  // Find matching route
  const matchedRoute = routes.find(route => route.path === currentPath);

  if (!matchedRoute) {
    return <div>404 - Not Found</div>;
  }

  const Component = matchedRoute.component;
  return <Component />;
}

// Usage
function HomePage() {
  return <div>Home Page</div>;
}

function ProductsPage() {
  return <div>Products Page</div>;
}

const routes: Route[] = [
  { path: '/', component: HomePage },
  { path: '/products', component: ProductsPage },
];

export function App() {
  return <SimpleRouter routes={routes} />;
}

# JavaScript Version

javascript
import { useState, useEffect, useCallback } from 'react';

export function SimpleRouter({ routes }) {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  useEffect(() => {
    const handlePopState = () => {
      setCurrentPath(window.location.pathname);
    };

    window.addEventListener('popstate', handlePopState);
    return () => window.removeEventListener('popstate', handlePopState);
  }, []);

  const navigate = useCallback((path) => {
    history.pushState(null, '', path);
    setCurrentPath(path);
  }, []);

  const matchedRoute = routes.find(route => route.path === currentPath);

  if (!matchedRoute) {
    return <div>404 - Not Found</div>;
  }

  const Component = matchedRoute.component;
  return <Component />;
}

function HomePage() {
  return <div>Home Page</div>;
}

function ProductsPage() {
  return <div>Products Page</div>;
}

const routes = [
  { path: '/', component: HomePage },
  { path: '/products', component: ProductsPage },
];

export function App() {
  return <SimpleRouter routes={routes} />;
}

Key insight: The magic is simply detecting when currentPath changes and re-rendering the matching component. The browser handles the URL and history automatically.

# Route Matching: Finding the Right Component {#route-matching}

The core challenge of routing is matching a URL to a route definition. URLs can have parameters, wildcards, and nested segments. Matching must be fast and reliable.

# Simple Matching: Exact Paths

typescript
// Simplest case: exact path matching
function matchRoute(pathname: string, routes: Route[]): Route | null {
  return routes.find(route => route.path === pathname) || null;
}

// /products matches /products ✓
// /products/123 doesn't match /products ✗

# Pattern Matching: Parameterized Routes

Most routers support parameterized routes like /products/:id. Implementing this requires parsing the pattern and extracting parameters.

# TypeScript Version: Route Matching with Parameters

typescript
interface RoutePattern {
  path: string; // e.g., "/products/:id/reviews/:reviewId"
  pattern: RegExp; // Compiled pattern for matching
  paramNames: string[]; // ["id", "reviewId"]
}

interface Params {
  [key: string]: string;
}

// Compile a route pattern into a regex
function compileRoute(routePath: string): RoutePattern {
  // Convert "/products/:id" → regex + param names

  // Split by segments
  const segments = routePath.split('/').filter(Boolean);

  // Replace :param with capture groups
  const patternSegments = segments.map(segment => {
    if (segment.startsWith(':')) {
      return '([^/]+)'; // Capture non-slash characters
    }
    return segment; // Literal segment
  });

  // Create regex
  const pattern = new RegExp(`^/${patternSegments.join('/')}/?

# How Client-Side Routing Works: From URLs to Components

When you click a link in a React application and the page changes instantly without a full reload, that's client-side routing at work. But what's actually happening under the hood? How does the browser know to render a different component when the URL changes? And why does the back button work without you implementing anything?

Understanding client-side routing mechanics is crucial for debugging navigation issues, building custom routers, and appreciating why libraries like React Router exist. Let's tear open the engine and see how it all works.

# Table of Contents

  1. The Problem Client-Side Routing Solves
  2. Browser History API: The Foundation
  3. URL Synchronization: Watching for Changes
  4. Route Matching: Finding the Right Component
  5. Building a Simple Router from Scratch
  6. Advanced Routing: Nested Routes and Parameters
  7. Handling Navigation Events
  8. Performance Considerations
  9. Real-World Routing Patterns
  10. FAQ

# The Problem Client-Side Routing Solves {#the-problem}

In traditional server-side applications, every navigation triggers a full HTTP request. The browser unloads the current page, requests a new one from the server, parses the HTML, loads CSS and JavaScript, and renders everything from scratch.

This works, but it has problems:

  1. Slow - Network latency, server processing, page parse time
  2. Jarring - The page flickers and reloads, losing scroll position
  3. State loss - Any client-side state (form inputs, scroll position, animations) is lost
  4. Bandwidth waste - Even if two pages share a layout, both are fully redownloaded

Client-side routing solves this by:

  • Keeping the application loaded in memory
  • Changing only what needs to change
  • Preserving state across "page" transitions
  • Making navigation instant

# TypeScript Version: The Problem Illustrated

typescript
// Server-side navigation (traditional)
// When you click a link:
// 1. Full page reload
// 2. All state lost
// 3. CSS re-parsed
// 4. JS re-executed
// 5. Components re-mounted

// Client-side routing
// When you click a link:
// 1. URL changes (browser history updated)
// 2. Component re-renders
// 3. Everything else stays in memory
// 4. Navigation is instant

interface NavigationComparison {
  serverSide: {
    time: number; // 500-2000ms
    stateLoss: boolean; // true
    renderTime: number; // Full page
  };
  clientSide: {
    time: number; // 10-100ms
    stateLoss: boolean; // false
    renderTime: number; // Only changed components
  };
}

export function NavigationComparison() {
  const comparison: NavigationComparison = {
    serverSide: {
      time: 1000,
      stateLoss: true,
      renderTime: 2000,
    },
    clientSide: {
      time: 50,
      stateLoss: false,
      renderTime: 50,
    },
  };

  return (
    <div>
      <p>Server-side navigation: {comparison.serverSide.time}ms</p>
      <p>Client-side navigation: {comparison.clientSide.time}ms</p>
      <p>Client-side is {comparison.serverSide.time / comparison.clientSide.time}x faster</p>
    </div>
  );
}

# JavaScript Version

javascript
export function NavigationComparison() {
  const comparison = {
    serverSide: {
      time: 1000,
      stateLoss: true,
      renderTime: 2000,
    },
    clientSide: {
      time: 50,
      stateLoss: false,
      renderTime: 50,
    },
  };

  return (
    <div>
      <p>Server-side navigation: {comparison.serverSide.time}ms</p>
      <p>Client-side navigation: {comparison.clientSide.time}ms</p>
      <p>Client-side is {(comparison.serverSide.time / comparison.clientSide.time).toFixed(0)}x faster</p>
    </div>
  );
}

# Browser History API: The Foundation {#history-api}

Client-side routing is built on the History API. This JavaScript API lets you manipulate the browser's session history (the list of URLs visited in the browser tab) and change the URL without triggering a page reload.

# The Three Key Methods

1. history.pushState() - Adds an entry to the history stack and changes the URL

typescript
// Changes URL to /products without reloading
history.pushState(
  { page: 'products' }, // State object
  'Products',            // Title (mostly ignored)
  '/products'            // New URL
);

2. history.replaceState() - Replaces the current history entry

typescript
// Changes URL without adding to back button history
history.replaceState(
  { page: 'login' },
  'Login',
  '/login'
);

3. window.addEventListener('popstate', ...) - Listens for back/forward button

typescript
window.addEventListener('popstate', (event) => {
  // Back/forward button was clicked
  const state = event.state;
  console.log('Navigated to:', state);
});

# TypeScript Version: History API Fundamentals

typescript
interface HistoryState {
  page: string;
  data?: Record<string, any>;
}

export function HistoryAPIExample() {
  const handleNavigate = (page: string, path: string, data?: Record<string, any>) => {
    // 1. Create state object
    const state: HistoryState = { page, data };

    // 2. Push to history (changes URL)
    history.pushState(state, page, path);

    // 3. Update UI (normally done in useEffect listening to popstate)
    console.log(`Navigated to ${page}`);
  };

  const handleBack = () => {
    // Browser's back button calls this automatically via popstate event
    history.back();
  };

  const handleForward = () => {
    history.forward();
  };

  const handleGoToSpecific = (n: number) => {
    history.go(n); // Positive = forward, negative = back
  };

  return (
    <div>
      <button onClick={() => handleNavigate('products', '/products')}>
        Products
      </button>
      <button onClick={() => handleNavigate('about', '/about')}>
        About
      </button>
      <button onClick={handleBack}>Back</button>
      <button onClick={handleForward}>Forward</button>
    </div>
  );
}

// History Stack Example:
// [Home] → [Products] → [Product Detail] ← [About]
//                              ↑
//                         You are here

// Back button: Takes you to [Product Detail]
// Forward button: Takes you to [About]

# JavaScript Version

javascript
export function HistoryAPIExample() {
  const handleNavigate = (page, path, data) => {
    const state = { page, data };
    history.pushState(state, page, path);
    console.log(`Navigated to ${page}`);
  };

  const handleBack = () => {
    history.back();
  };

  const handleForward = () => {
    history.forward();
  };

  return (
    <div>
      <button onClick={() => handleNavigate('products', '/products')}>
        Products
      </button>
      <button onClick={() => handleNavigate('about', '/about')}>
        About
      </button>
      <button onClick={handleBack}>Back</button>
      <button onClick={handleForward}>Forward</button>
    </div>
  );
}

# URL Synchronization: Watching for Changes {#url-synchronization}

Client-side routing works by syncing the URL to your component state. When the URL changes (either by user action or navigation), the router detects it and updates which component to render.

# The Core Loop

  1. User action - Click a <Link> or use navigate()
  2. URL update - Call history.pushState() to change the URL
  3. Event listener - popstate event fires (if user clicked back/forward) or you can detect immediately
  4. Component update - Determine which component matches the new URL
  5. Render - React re-renders with the new component

# TypeScript Version: URL Synchronization

typescript
import { useState, useEffect, useCallback } from 'react';

interface Route {
  path: string;
  component: React.ComponentType;
}

interface SimpleRouterProps {
  routes: Route[];
}

export function SimpleRouter({ routes }: SimpleRouterProps) {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  // Listen for URL changes (back/forward buttons)
  useEffect(() => {
    const handlePopState = () => {
      setCurrentPath(window.location.pathname);
    };

    window.addEventListener('popstate', handlePopState);
    return () => window.removeEventListener('popstate', handlePopState);
  }, []);

  // Navigate programmatically
  const navigate = useCallback((path: string) => {
    // 1. Update browser history
    history.pushState(null, '', path);

    // 2. Update component state (this is what triggers re-render)
    setCurrentPath(path);
  }, []);

  // Find matching route
  const matchedRoute = routes.find(route => route.path === currentPath);

  if (!matchedRoute) {
    return <div>404 - Not Found</div>;
  }

  const Component = matchedRoute.component;
  return <Component />;
}

// Usage
function HomePage() {
  return <div>Home Page</div>;
}

function ProductsPage() {
  return <div>Products Page</div>;
}

const routes: Route[] = [
  { path: '/', component: HomePage },
  { path: '/products', component: ProductsPage },
];

export function App() {
  return <SimpleRouter routes={routes} />;
}

# JavaScript Version

javascript
import { useState, useEffect, useCallback } from 'react';

export function SimpleRouter({ routes }) {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  useEffect(() => {
    const handlePopState = () => {
      setCurrentPath(window.location.pathname);
    };

    window.addEventListener('popstate', handlePopState);
    return () => window.removeEventListener('popstate', handlePopState);
  }, []);

  const navigate = useCallback((path) => {
    history.pushState(null, '', path);
    setCurrentPath(path);
  }, []);

  const matchedRoute = routes.find(route => route.path === currentPath);

  if (!matchedRoute) {
    return <div>404 - Not Found</div>;
  }

  const Component = matchedRoute.component;
  return <Component />;
}

function HomePage() {
  return <div>Home Page</div>;
}

function ProductsPage() {
  return <div>Products Page</div>;
}

const routes = [
  { path: '/', component: HomePage },
  { path: '/products', component: ProductsPage },
];

export function App() {
  return <SimpleRouter routes={routes} />;
}

Key insight: The magic is simply detecting when currentPath changes and re-rendering the matching component. The browser handles the URL and history automatically.

# Route Matching: Finding the Right Component {#route-matching}

The core challenge of routing is matching a URL to a route definition. URLs can have parameters, wildcards, and nested segments. Matching must be fast and reliable.

# Simple Matching: Exact Paths

typescript
// Simplest case: exact path matching
function matchRoute(pathname: string, routes: Route[]): Route | null {
  return routes.find(route => route.path === pathname) || null;
}

// /products matches /products ✓
// /products/123 doesn't match /products ✗

# Pattern Matching: Parameterized Routes

Most routers support parameterized routes like /products/:id. Implementing this requires parsing the pattern and extracting parameters.

# TypeScript Version: Route Matching with Parameters

);
// Extract param names const paramNames = segments .filter(s => s.startsWith(':')) .map(s => s.slice(1)); // Remove the ':' return { path: routePath, pattern, paramNames }; } // Match URL and extract parameters function matchRoute( pathname: string, compiledRoute: RoutePattern ): Params | null { const match = pathname.match(compiledRoute.pattern); if (!match) { return null; } // match[0] is the full match, match[1], match[2], etc. are groups const params: Params = {}; compiledRoute.paramNames.forEach((paramName, index) => { params[paramName] = match[index + 1]; }); return params; } // Usage Example const productRoute = compileRoute('/products/:id'); const match = matchRoute('/products/123', productRoute); console.log(match); // { id: "123" } // Real-world matching with multiple routes const routes = [ { path: '/', component: HomePage }, { path: '/products', component: ProductsPage }, { path: '/products/:id', component: ProductDetailPage }, { path: '/products/:id/reviews/:reviewId', component: ReviewPage }, ]; const compiledRoutes = routes.map(route => ({ ...route, ...compileRoute(route.path), })); function findMatchingRoute(pathname: string) { for (const route of compiledRoutes) { const params = matchRoute(pathname, route); if (params !== null) { return { route, params }; } } return null; } // Test routing console.log(findMatchingRoute('/products')); // ProductsPage, no params console.log(findMatchingRoute('/products/123')); // ProductDetailPage, { id: '123' } console.log(findMatchingRoute('/products/123/reviews/456')); // ReviewPage, { id: '123', reviewId: '456' }

# JavaScript Version

javascript
function compileRoute(routePath) {
  const segments = routePath.split('/').filter(Boolean);

  const patternSegments = segments.map(segment => {
    if (segment.startsWith(':')) {
      return '([^/]+)';
    }
    return segment;
  });

  const pattern = new RegExp(`^/${patternSegments.join('/')}/?

# How Client-Side Routing Works: From URLs to Components

When you click a link in a React application and the page changes instantly without a full reload, that's client-side routing at work. But what's actually happening under the hood? How does the browser know to render a different component when the URL changes? And why does the back button work without you implementing anything?

Understanding client-side routing mechanics is crucial for debugging navigation issues, building custom routers, and appreciating why libraries like React Router exist. Let's tear open the engine and see how it all works.

# Table of Contents

  1. The Problem Client-Side Routing Solves
  2. Browser History API: The Foundation
  3. URL Synchronization: Watching for Changes
  4. Route Matching: Finding the Right Component
  5. Building a Simple Router from Scratch
  6. Advanced Routing: Nested Routes and Parameters
  7. Handling Navigation Events
  8. Performance Considerations
  9. Real-World Routing Patterns
  10. FAQ

# The Problem Client-Side Routing Solves {#the-problem}

In traditional server-side applications, every navigation triggers a full HTTP request. The browser unloads the current page, requests a new one from the server, parses the HTML, loads CSS and JavaScript, and renders everything from scratch.

This works, but it has problems:

  1. Slow - Network latency, server processing, page parse time
  2. Jarring - The page flickers and reloads, losing scroll position
  3. State loss - Any client-side state (form inputs, scroll position, animations) is lost
  4. Bandwidth waste - Even if two pages share a layout, both are fully redownloaded

Client-side routing solves this by:

  • Keeping the application loaded in memory
  • Changing only what needs to change
  • Preserving state across "page" transitions
  • Making navigation instant

# TypeScript Version: The Problem Illustrated

typescript
// Server-side navigation (traditional)
// When you click a link:
// 1. Full page reload
// 2. All state lost
// 3. CSS re-parsed
// 4. JS re-executed
// 5. Components re-mounted

// Client-side routing
// When you click a link:
// 1. URL changes (browser history updated)
// 2. Component re-renders
// 3. Everything else stays in memory
// 4. Navigation is instant

interface NavigationComparison {
  serverSide: {
    time: number; // 500-2000ms
    stateLoss: boolean; // true
    renderTime: number; // Full page
  };
  clientSide: {
    time: number; // 10-100ms
    stateLoss: boolean; // false
    renderTime: number; // Only changed components
  };
}

export function NavigationComparison() {
  const comparison: NavigationComparison = {
    serverSide: {
      time: 1000,
      stateLoss: true,
      renderTime: 2000,
    },
    clientSide: {
      time: 50,
      stateLoss: false,
      renderTime: 50,
    },
  };

  return (
    <div>
      <p>Server-side navigation: {comparison.serverSide.time}ms</p>
      <p>Client-side navigation: {comparison.clientSide.time}ms</p>
      <p>Client-side is {comparison.serverSide.time / comparison.clientSide.time}x faster</p>
    </div>
  );
}

# JavaScript Version

javascript
export function NavigationComparison() {
  const comparison = {
    serverSide: {
      time: 1000,
      stateLoss: true,
      renderTime: 2000,
    },
    clientSide: {
      time: 50,
      stateLoss: false,
      renderTime: 50,
    },
  };

  return (
    <div>
      <p>Server-side navigation: {comparison.serverSide.time}ms</p>
      <p>Client-side navigation: {comparison.clientSide.time}ms</p>
      <p>Client-side is {(comparison.serverSide.time / comparison.clientSide.time).toFixed(0)}x faster</p>
    </div>
  );
}

# Browser History API: The Foundation {#history-api}

Client-side routing is built on the History API. This JavaScript API lets you manipulate the browser's session history (the list of URLs visited in the browser tab) and change the URL without triggering a page reload.

# The Three Key Methods

1. history.pushState() - Adds an entry to the history stack and changes the URL

typescript
// Changes URL to /products without reloading
history.pushState(
  { page: 'products' }, // State object
  'Products',            // Title (mostly ignored)
  '/products'            // New URL
);

2. history.replaceState() - Replaces the current history entry

typescript
// Changes URL without adding to back button history
history.replaceState(
  { page: 'login' },
  'Login',
  '/login'
);

3. window.addEventListener('popstate', ...) - Listens for back/forward button

typescript
window.addEventListener('popstate', (event) => {
  // Back/forward button was clicked
  const state = event.state;
  console.log('Navigated to:', state);
});

# TypeScript Version: History API Fundamentals

typescript
interface HistoryState {
  page: string;
  data?: Record<string, any>;
}

export function HistoryAPIExample() {
  const handleNavigate = (page: string, path: string, data?: Record<string, any>) => {
    // 1. Create state object
    const state: HistoryState = { page, data };

    // 2. Push to history (changes URL)
    history.pushState(state, page, path);

    // 3. Update UI (normally done in useEffect listening to popstate)
    console.log(`Navigated to ${page}`);
  };

  const handleBack = () => {
    // Browser's back button calls this automatically via popstate event
    history.back();
  };

  const handleForward = () => {
    history.forward();
  };

  const handleGoToSpecific = (n: number) => {
    history.go(n); // Positive = forward, negative = back
  };

  return (
    <div>
      <button onClick={() => handleNavigate('products', '/products')}>
        Products
      </button>
      <button onClick={() => handleNavigate('about', '/about')}>
        About
      </button>
      <button onClick={handleBack}>Back</button>
      <button onClick={handleForward}>Forward</button>
    </div>
  );
}

// History Stack Example:
// [Home] → [Products] → [Product Detail] ← [About]
//                              ↑
//                         You are here

// Back button: Takes you to [Product Detail]
// Forward button: Takes you to [About]

# JavaScript Version

javascript
export function HistoryAPIExample() {
  const handleNavigate = (page, path, data) => {
    const state = { page, data };
    history.pushState(state, page, path);
    console.log(`Navigated to ${page}`);
  };

  const handleBack = () => {
    history.back();
  };

  const handleForward = () => {
    history.forward();
  };

  return (
    <div>
      <button onClick={() => handleNavigate('products', '/products')}>
        Products
      </button>
      <button onClick={() => handleNavigate('about', '/about')}>
        About
      </button>
      <button onClick={handleBack}>Back</button>
      <button onClick={handleForward}>Forward</button>
    </div>
  );
}

# URL Synchronization: Watching for Changes {#url-synchronization}

Client-side routing works by syncing the URL to your component state. When the URL changes (either by user action or navigation), the router detects it and updates which component to render.

# The Core Loop

  1. User action - Click a <Link> or use navigate()
  2. URL update - Call history.pushState() to change the URL
  3. Event listener - popstate event fires (if user clicked back/forward) or you can detect immediately
  4. Component update - Determine which component matches the new URL
  5. Render - React re-renders with the new component

# TypeScript Version: URL Synchronization

typescript
import { useState, useEffect, useCallback } from 'react';

interface Route {
  path: string;
  component: React.ComponentType;
}

interface SimpleRouterProps {
  routes: Route[];
}

export function SimpleRouter({ routes }: SimpleRouterProps) {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  // Listen for URL changes (back/forward buttons)
  useEffect(() => {
    const handlePopState = () => {
      setCurrentPath(window.location.pathname);
    };

    window.addEventListener('popstate', handlePopState);
    return () => window.removeEventListener('popstate', handlePopState);
  }, []);

  // Navigate programmatically
  const navigate = useCallback((path: string) => {
    // 1. Update browser history
    history.pushState(null, '', path);

    // 2. Update component state (this is what triggers re-render)
    setCurrentPath(path);
  }, []);

  // Find matching route
  const matchedRoute = routes.find(route => route.path === currentPath);

  if (!matchedRoute) {
    return <div>404 - Not Found</div>;
  }

  const Component = matchedRoute.component;
  return <Component />;
}

// Usage
function HomePage() {
  return <div>Home Page</div>;
}

function ProductsPage() {
  return <div>Products Page</div>;
}

const routes: Route[] = [
  { path: '/', component: HomePage },
  { path: '/products', component: ProductsPage },
];

export function App() {
  return <SimpleRouter routes={routes} />;
}

# JavaScript Version

javascript
import { useState, useEffect, useCallback } from 'react';

export function SimpleRouter({ routes }) {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  useEffect(() => {
    const handlePopState = () => {
      setCurrentPath(window.location.pathname);
    };

    window.addEventListener('popstate', handlePopState);
    return () => window.removeEventListener('popstate', handlePopState);
  }, []);

  const navigate = useCallback((path) => {
    history.pushState(null, '', path);
    setCurrentPath(path);
  }, []);

  const matchedRoute = routes.find(route => route.path === currentPath);

  if (!matchedRoute) {
    return <div>404 - Not Found</div>;
  }

  const Component = matchedRoute.component;
  return <Component />;
}

function HomePage() {
  return <div>Home Page</div>;
}

function ProductsPage() {
  return <div>Products Page</div>;
}

const routes = [
  { path: '/', component: HomePage },
  { path: '/products', component: ProductsPage },
];

export function App() {
  return <SimpleRouter routes={routes} />;
}

Key insight: The magic is simply detecting when currentPath changes and re-rendering the matching component. The browser handles the URL and history automatically.

# Route Matching: Finding the Right Component {#route-matching}

The core challenge of routing is matching a URL to a route definition. URLs can have parameters, wildcards, and nested segments. Matching must be fast and reliable.

# Simple Matching: Exact Paths

typescript
// Simplest case: exact path matching
function matchRoute(pathname: string, routes: Route[]): Route | null {
  return routes.find(route => route.path === pathname) || null;
}

// /products matches /products ✓
// /products/123 doesn't match /products ✗

# Pattern Matching: Parameterized Routes

Most routers support parameterized routes like /products/:id. Implementing this requires parsing the pattern and extracting parameters.

# TypeScript Version: Route Matching with Parameters

typescript
interface RoutePattern {
  path: string; // e.g., "/products/:id/reviews/:reviewId"
  pattern: RegExp; // Compiled pattern for matching
  paramNames: string[]; // ["id", "reviewId"]
}

interface Params {
  [key: string]: string;
}

// Compile a route pattern into a regex
function compileRoute(routePath: string): RoutePattern {
  // Convert "/products/:id" → regex + param names

  // Split by segments
  const segments = routePath.split('/').filter(Boolean);

  // Replace :param with capture groups
  const patternSegments = segments.map(segment => {
    if (segment.startsWith(':')) {
      return '([^/]+)'; // Capture non-slash characters
    }
    return segment; // Literal segment
  });

  // Create regex
  const pattern = new RegExp(`^/${patternSegments.join('/')}/?

# How Client-Side Routing Works: From URLs to Components

When you click a link in a React application and the page changes instantly without a full reload, that's client-side routing at work. But what's actually happening under the hood? How does the browser know to render a different component when the URL changes? And why does the back button work without you implementing anything?

Understanding client-side routing mechanics is crucial for debugging navigation issues, building custom routers, and appreciating why libraries like React Router exist. Let's tear open the engine and see how it all works.

# Table of Contents

  1. The Problem Client-Side Routing Solves
  2. Browser History API: The Foundation
  3. URL Synchronization: Watching for Changes
  4. Route Matching: Finding the Right Component
  5. Building a Simple Router from Scratch
  6. Advanced Routing: Nested Routes and Parameters
  7. Handling Navigation Events
  8. Performance Considerations
  9. Real-World Routing Patterns
  10. FAQ

# The Problem Client-Side Routing Solves {#the-problem}

In traditional server-side applications, every navigation triggers a full HTTP request. The browser unloads the current page, requests a new one from the server, parses the HTML, loads CSS and JavaScript, and renders everything from scratch.

This works, but it has problems:

  1. Slow - Network latency, server processing, page parse time
  2. Jarring - The page flickers and reloads, losing scroll position
  3. State loss - Any client-side state (form inputs, scroll position, animations) is lost
  4. Bandwidth waste - Even if two pages share a layout, both are fully redownloaded

Client-side routing solves this by:

  • Keeping the application loaded in memory
  • Changing only what needs to change
  • Preserving state across "page" transitions
  • Making navigation instant

# TypeScript Version: The Problem Illustrated

typescript
// Server-side navigation (traditional)
// When you click a link:
// 1. Full page reload
// 2. All state lost
// 3. CSS re-parsed
// 4. JS re-executed
// 5. Components re-mounted

// Client-side routing
// When you click a link:
// 1. URL changes (browser history updated)
// 2. Component re-renders
// 3. Everything else stays in memory
// 4. Navigation is instant

interface NavigationComparison {
  serverSide: {
    time: number; // 500-2000ms
    stateLoss: boolean; // true
    renderTime: number; // Full page
  };
  clientSide: {
    time: number; // 10-100ms
    stateLoss: boolean; // false
    renderTime: number; // Only changed components
  };
}

export function NavigationComparison() {
  const comparison: NavigationComparison = {
    serverSide: {
      time: 1000,
      stateLoss: true,
      renderTime: 2000,
    },
    clientSide: {
      time: 50,
      stateLoss: false,
      renderTime: 50,
    },
  };

  return (
    <div>
      <p>Server-side navigation: {comparison.serverSide.time}ms</p>
      <p>Client-side navigation: {comparison.clientSide.time}ms</p>
      <p>Client-side is {comparison.serverSide.time / comparison.clientSide.time}x faster</p>
    </div>
  );
}

# JavaScript Version

javascript
export function NavigationComparison() {
  const comparison = {
    serverSide: {
      time: 1000,
      stateLoss: true,
      renderTime: 2000,
    },
    clientSide: {
      time: 50,
      stateLoss: false,
      renderTime: 50,
    },
  };

  return (
    <div>
      <p>Server-side navigation: {comparison.serverSide.time}ms</p>
      <p>Client-side navigation: {comparison.clientSide.time}ms</p>
      <p>Client-side is {(comparison.serverSide.time / comparison.clientSide.time).toFixed(0)}x faster</p>
    </div>
  );
}

# Browser History API: The Foundation {#history-api}

Client-side routing is built on the History API. This JavaScript API lets you manipulate the browser's session history (the list of URLs visited in the browser tab) and change the URL without triggering a page reload.

# The Three Key Methods

1. history.pushState() - Adds an entry to the history stack and changes the URL

typescript
// Changes URL to /products without reloading
history.pushState(
  { page: 'products' }, // State object
  'Products',            // Title (mostly ignored)
  '/products'            // New URL
);

2. history.replaceState() - Replaces the current history entry

typescript
// Changes URL without adding to back button history
history.replaceState(
  { page: 'login' },
  'Login',
  '/login'
);

3. window.addEventListener('popstate', ...) - Listens for back/forward button

typescript
window.addEventListener('popstate', (event) => {
  // Back/forward button was clicked
  const state = event.state;
  console.log('Navigated to:', state);
});

# TypeScript Version: History API Fundamentals

typescript
interface HistoryState {
  page: string;
  data?: Record<string, any>;
}

export function HistoryAPIExample() {
  const handleNavigate = (page: string, path: string, data?: Record<string, any>) => {
    // 1. Create state object
    const state: HistoryState = { page, data };

    // 2. Push to history (changes URL)
    history.pushState(state, page, path);

    // 3. Update UI (normally done in useEffect listening to popstate)
    console.log(`Navigated to ${page}`);
  };

  const handleBack = () => {
    // Browser's back button calls this automatically via popstate event
    history.back();
  };

  const handleForward = () => {
    history.forward();
  };

  const handleGoToSpecific = (n: number) => {
    history.go(n); // Positive = forward, negative = back
  };

  return (
    <div>
      <button onClick={() => handleNavigate('products', '/products')}>
        Products
      </button>
      <button onClick={() => handleNavigate('about', '/about')}>
        About
      </button>
      <button onClick={handleBack}>Back</button>
      <button onClick={handleForward}>Forward</button>
    </div>
  );
}

// History Stack Example:
// [Home] → [Products] → [Product Detail] ← [About]
//                              ↑
//                         You are here

// Back button: Takes you to [Product Detail]
// Forward button: Takes you to [About]

# JavaScript Version

javascript
export function HistoryAPIExample() {
  const handleNavigate = (page, path, data) => {
    const state = { page, data };
    history.pushState(state, page, path);
    console.log(`Navigated to ${page}`);
  };

  const handleBack = () => {
    history.back();
  };

  const handleForward = () => {
    history.forward();
  };

  return (
    <div>
      <button onClick={() => handleNavigate('products', '/products')}>
        Products
      </button>
      <button onClick={() => handleNavigate('about', '/about')}>
        About
      </button>
      <button onClick={handleBack}>Back</button>
      <button onClick={handleForward}>Forward</button>
    </div>
  );
}

# URL Synchronization: Watching for Changes {#url-synchronization}

Client-side routing works by syncing the URL to your component state. When the URL changes (either by user action or navigation), the router detects it and updates which component to render.

# The Core Loop

  1. User action - Click a <Link> or use navigate()
  2. URL update - Call history.pushState() to change the URL
  3. Event listener - popstate event fires (if user clicked back/forward) or you can detect immediately
  4. Component update - Determine which component matches the new URL
  5. Render - React re-renders with the new component

# TypeScript Version: URL Synchronization

typescript
import { useState, useEffect, useCallback } from 'react';

interface Route {
  path: string;
  component: React.ComponentType;
}

interface SimpleRouterProps {
  routes: Route[];
}

export function SimpleRouter({ routes }: SimpleRouterProps) {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  // Listen for URL changes (back/forward buttons)
  useEffect(() => {
    const handlePopState = () => {
      setCurrentPath(window.location.pathname);
    };

    window.addEventListener('popstate', handlePopState);
    return () => window.removeEventListener('popstate', handlePopState);
  }, []);

  // Navigate programmatically
  const navigate = useCallback((path: string) => {
    // 1. Update browser history
    history.pushState(null, '', path);

    // 2. Update component state (this is what triggers re-render)
    setCurrentPath(path);
  }, []);

  // Find matching route
  const matchedRoute = routes.find(route => route.path === currentPath);

  if (!matchedRoute) {
    return <div>404 - Not Found</div>;
  }

  const Component = matchedRoute.component;
  return <Component />;
}

// Usage
function HomePage() {
  return <div>Home Page</div>;
}

function ProductsPage() {
  return <div>Products Page</div>;
}

const routes: Route[] = [
  { path: '/', component: HomePage },
  { path: '/products', component: ProductsPage },
];

export function App() {
  return <SimpleRouter routes={routes} />;
}

# JavaScript Version

javascript
import { useState, useEffect, useCallback } from 'react';

export function SimpleRouter({ routes }) {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  useEffect(() => {
    const handlePopState = () => {
      setCurrentPath(window.location.pathname);
    };

    window.addEventListener('popstate', handlePopState);
    return () => window.removeEventListener('popstate', handlePopState);
  }, []);

  const navigate = useCallback((path) => {
    history.pushState(null, '', path);
    setCurrentPath(path);
  }, []);

  const matchedRoute = routes.find(route => route.path === currentPath);

  if (!matchedRoute) {
    return <div>404 - Not Found</div>;
  }

  const Component = matchedRoute.component;
  return <Component />;
}

function HomePage() {
  return <div>Home Page</div>;
}

function ProductsPage() {
  return <div>Products Page</div>;
}

const routes = [
  { path: '/', component: HomePage },
  { path: '/products', component: ProductsPage },
];

export function App() {
  return <SimpleRouter routes={routes} />;
}

Key insight: The magic is simply detecting when currentPath changes and re-rendering the matching component. The browser handles the URL and history automatically.

# Route Matching: Finding the Right Component {#route-matching}

The core challenge of routing is matching a URL to a route definition. URLs can have parameters, wildcards, and nested segments. Matching must be fast and reliable.

# Simple Matching: Exact Paths

typescript
// Simplest case: exact path matching
function matchRoute(pathname: string, routes: Route[]): Route | null {
  return routes.find(route => route.path === pathname) || null;
}

// /products matches /products ✓
// /products/123 doesn't match /products ✗

# Pattern Matching: Parameterized Routes

Most routers support parameterized routes like /products/:id. Implementing this requires parsing the pattern and extracting parameters.

# TypeScript Version: Route Matching with Parameters

);
// Extract param names const paramNames = segments .filter(s => s.startsWith(':')) .map(s => s.slice(1)); // Remove the ':' return { path: routePath, pattern, paramNames }; } // Match URL and extract parameters function matchRoute( pathname: string, compiledRoute: RoutePattern ): Params | null { const match = pathname.match(compiledRoute.pattern); if (!match) { return null; } // match[0] is the full match, match[1], match[2], etc. are groups const params: Params = {}; compiledRoute.paramNames.forEach((paramName, index) => { params[paramName] = match[index + 1]; }); return params; } // Usage Example const productRoute = compileRoute('/products/:id'); const match = matchRoute('/products/123', productRoute); console.log(match); // { id: "123" } // Real-world matching with multiple routes const routes = [ { path: '/', component: HomePage }, { path: '/products', component: ProductsPage }, { path: '/products/:id', component: ProductDetailPage }, { path: '/products/:id/reviews/:reviewId', component: ReviewPage }, ]; const compiledRoutes = routes.map(route => ({ ...route, ...compileRoute(route.path), })); function findMatchingRoute(pathname: string) { for (const route of compiledRoutes) { const params = matchRoute(pathname, route); if (params !== null) { return { route, params }; } } return null; } // Test routing console.log(findMatchingRoute('/products')); // ProductsPage, no params console.log(findMatchingRoute('/products/123')); // ProductDetailPage, { id: '123' } console.log(findMatchingRoute('/products/123/reviews/456')); // ReviewPage, { id: '123', reviewId: '456' }

# JavaScript Version

);
const paramNames = segments .filter(s => s.startsWith(':')) .map(s => s.slice(1)); return { path: routePath, pattern, paramNames }; } function matchRoute(pathname, compiledRoute) { const match = pathname.match(compiledRoute.pattern); if (!match) { return null; } const params = {}; compiledRoute.paramNames.forEach((paramName, index) => { params[paramName] = match[index + 1]; }); return params; } const productRoute = compileRoute('/products/:id'); const match = matchRoute('/products/123', productRoute); console.log(match); // { id: '123' }

# Building a Simple Router from Scratch {#building-router}

Now let's build a functional router that combines everything: History API, URL synchronization, and route matching.

# TypeScript Version: Custom Router Implementation

);
const paramNames = segments .filter(s => s.startsWith(':')) .map(s => s.slice(1)); return { path: routePath, pattern, paramNames }; } function matchRoute( pathname: string, compiledRoute: ReturnType<typeof compileRoute> ): Record<string, string> | null { const match = pathname.match(compiledRoute.pattern); if (!match) return null; const params: Record<string, string> = {}; compiledRoute.paramNames.forEach((paramName, index) => { params[paramName] = match[index + 1]; }); return params; } // Router Provider Component interface RouterProviderProps { routes: Route[]; children?: ReactNode; } export function Router({ routes, children }: RouterProviderProps) { const [currentPath, setCurrentPath] = useState(window.location.pathname); const [params, setParams] = useState<Record<string, string>>({}); // Compile routes once const compiledRoutes = routes.map(route => ({ ...route, ...compileRoute(route.path), })); // Listen for back/forward button useEffect(() => { const handlePopState = () => { setCurrentPath(window.location.pathname); }; window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); }, []); // Find matching route and extract params useEffect(() => { for (const route of compiledRoutes) { const matchedParams = matchRoute(currentPath, route); if (matchedParams !== null) { setParams(matchedParams); return; } } setParams({}); }, [currentPath, compiledRoutes]); const navigate = useCallback((path: string) => { history.pushState(null, '', path); setCurrentPath(path); }, []); const contextValue: RouterContextType = { currentPath, navigate, params, }; // Find component to render const matchedRoute = compiledRoutes.find(route => { const match = matchRoute(currentPath, route); return match !== null; }); if (!matchedRoute) { return <div>404 - Not Found</div>; } const Component = matchedRoute.element; return ( <RouterContext.Provider value={contextValue}> <Component /> {children} </RouterContext.Provider> ); } // Hooks for using the router export function useRouter() { const context = useContext(RouterContext); if (!context) { throw new Error('useRouter must be used within Router'); } return context; } export function useParams(): Record<string, string> { const { params } = useRouter(); return params; } export function useNavigate() { const { navigate } = useRouter(); return navigate; } // Link Component interface LinkProps { to: string; children: ReactNode; className?: string; } export function Link({ to, children, className }: LinkProps) { const navigate = useNavigate(); const { currentPath } = useRouter(); const isActive = currentPath === to; return ( <a href={to} className={`${className} ${isActive ? 'active' : ''}`} onClick={e => { e.preventDefault(); navigate(to); }} > {children} </a> ); } // Example components function HomePage() { return <h1>Welcome Home</h1>; } function ProductsPage() { return <h1>Products List</h1>; } interface ProductDetailPageProps { id?: string; } function ProductDetailPage() { const params = useParams(); return <h1>Product {params.id}</h1>; } // Usage const routes: Route[] = [ { path: '/', element: HomePage }, { path: '/products', element: ProductsPage }, { path: '/products/:id', element: ProductDetailPage }, ]; export function App() { return ( <Router routes={routes}> <nav> <Link to="/">Home</Link> <Link to="/products">Products</Link> <Link to="/products/123">Product 123</Link> </nav> </Router> ); }

# JavaScript Version

);
const paramNames = segments .filter(s => s.startsWith(':')) .map(s => s.slice(1)); return { path: routePath, pattern, paramNames }; } function matchRoute(pathname, compiledRoute) { const match = pathname.match(compiledRoute.pattern); if (!match) return null; const params = {}; compiledRoute.paramNames.forEach((paramName, index) => { params[paramName] = match[index + 1]; }); return params; } export function Router({ routes, children }) { const [currentPath, setCurrentPath] = useState(window.location.pathname); const [params, setParams] = useState({}); const compiledRoutes = routes.map(route => ({ ...route, ...compileRoute(route.path), })); useEffect(() => { const handlePopState = () => { setCurrentPath(window.location.pathname); }; window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); }, []); useEffect(() => { for (const route of compiledRoutes) { const matchedParams = matchRoute(currentPath, route); if (matchedParams !== null) { setParams(matchedParams); return; } } setParams({}); }, [currentPath, compiledRoutes]); const navigate = useCallback((path) => { history.pushState(null, '', path); setCurrentPath(path); }, []); const contextValue = { currentPath, navigate, params, }; const matchedRoute = compiledRoutes.find(route => { const match = matchRoute(currentPath, route); return match !== null; }); if (!matchedRoute) { return <div>404 - Not Found</div>; } const Component = matchedRoute.element; return ( <RouterContext.Provider value={contextValue}> <Component /> {children} </RouterContext.Provider> ); } export function useRouter() { const context = useContext(RouterContext); if (!context) { throw new Error('useRouter must be used within Router'); } return context; } export function useParams() { const { params } = useRouter(); return params; } export function useNavigate() { const { navigate } = useRouter(); return navigate; } export function Link({ to, children, className }) { const navigate = useNavigate(); const { currentPath } = useRouter(); const isActive = currentPath === to; return ( <a href={to} className={`${className} ${isActive ? 'active' : ''}`} onClick={e => { e.preventDefault(); navigate(to); }} > {children} </a> ); } function HomePage() { return <h1>Welcome Home</h1>; } function ProductsPage() { return <h1>Products List</h1>; } function ProductDetailPage() { const params = useParams(); return <h1>Product {params.id}</h1>; } const routes = [ { path: '/', element: HomePage }, { path: '/products', element: ProductsPage }, { path: '/products/:id', element: ProductDetailPage }, ]; export function App() { return ( <Router routes={routes}> <nav> <Link to="/">Home</Link> <Link to="/products">Products</Link> <Link to="/products/123">Product 123</Link> </nav> </Router> ); }

This is the core of how every client-side router works, from React Router to Next.js. The specifics are more complex (nested routes, path-to-regexp for advanced matching, middleware), but the fundamentals remain the same.

# Advanced Routing: Nested Routes and Parameters {#advanced-routing}

Real-world routing gets more complex with nested routes, middleware, and data loading. Here's how to extend the basic router.

# TypeScript Version: Nested Routes

typescript
interface NestedRoute {
  path: string;
  element: React.ComponentType;
  children?: NestedRoute[];
}

// Find matching route in nested structure
function findNestedRoute(
  pathname: string,
  routes: NestedRoute[],
  parentPath = ''
): { route: NestedRoute; params: Record<string, string>; breadcrumbs: string[] } | null {
  for (const route of routes) {
    const fullPath = parentPath + route.path;
    const compiled = compileRoute(fullPath);
    const params = matchRoute(pathname, compiled);

    if (params !== null) {
      return {
        route,
        params,
        breadcrumbs: fullPath.split('/').filter(Boolean),
      };
    }

    // Check children
    if (route.children) {
      const nested = findNestedRoute(pathname, route.children, fullPath);
      if (nested) {
        return nested;
      }
    }
  }

  return null;
}

// Layout component that renders nested routes
interface LayoutProps {
  children?: React.ReactNode;
}

function AdminLayout({ children }: LayoutProps) {
  return (
    <div className="admin">
      <aside className="sidebar">
        <AdminMenu />
      </aside>
      <main>{children}</main>
    </div>
  );
}

const nestedRoutes: NestedRoute[] = [
  {
    path: '/',
    element: HomePage,
  },
  {
    path: '/admin',
    element: AdminLayout,
    children: [
      {
        path: '/dashboard',
        element: AdminDashboard,
      },
      {
        path: '/users',
        element: AdminUsers,
      },
      {
        path: '/users/:id',
        element: AdminUserDetail,
      },
    ],
  },
];

// Routes:
// / → HomePage
// /admin/dashboard → AdminLayout > AdminDashboard
// /admin/users → AdminLayout > AdminUsers
// /admin/users/123 → AdminLayout > AdminUserDetail (id=123)

# JavaScript Version

javascript
function findNestedRoute(
  pathname,
  routes,
  parentPath = ''
) {
  for (const route of routes) {
    const fullPath = parentPath + route.path;
    const compiled = compileRoute(fullPath);
    const params = matchRoute(pathname, compiled);

    if (params !== null) {
      return {
        route,
        params,
        breadcrumbs: fullPath.split('/').filter(Boolean),
      };
    }

    if (route.children) {
      const nested = findNestedRoute(pathname, route.children, fullPath);
      if (nested) {
        return nested;
      }
    }
  }

  return null;
}

function AdminLayout({ children }) {
  return (
    <div className="admin">
      <aside className="sidebar">
        <AdminMenu />
      </aside>
      <main>{children}</main>
    </div>
  );
}

const nestedRoutes = [
  {
    path: '/',
    element: HomePage,
  },
  {
    path: '/admin',
    element: AdminLayout,
    children: [
      {
        path: '/dashboard',
        element: AdminDashboard,
      },
      {
        path: '/users',
        element: AdminUsers,
      },
      {
        path: '/users/:id',
        element: AdminUserDetail,
      },
    ],
  },
];

# Handling Navigation Events {#navigation-events}

Client-side routers can intercept navigation to prevent data loss, show confirmation dialogs, or handle authentication.

# TypeScript Version: Navigation Guards

typescript
type NavigationGuard = (
  to: string,
  from: string
) => boolean | Promise<boolean>;

interface RouterWithGuardsProps {
  routes: Route[];
  guards?: NavigationGuard[];
  children?: ReactNode;
}

export function RouterWithGuards({
  routes,
  guards = [],
  children,
}: RouterWithGuardsProps) {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);
  const [isNavigating, setIsNavigating] = useState(false);

  const navigate = useCallback(
    async (path: string) => {
      setIsNavigating(true);

      try {
        // Run all guards
        for (const guard of guards) {
          const canNavigate = await guard(path, currentPath);
          if (!canNavigate) {
            console.log('Navigation blocked by guard');
            return;
          }
        }

        // All guards passed, navigate
        history.pushState(null, '', path);
        setCurrentPath(path);
      } finally {
        setIsNavigating(false);
      }
    },
    [currentPath, guards]
  );

  return (
    <div>
      {/* Router content */}
    </div>
  );
}

// Usage: Prevent navigation if form is dirty
const unsavedDataGuard: NavigationGuard = async (to, from) => {
  const formIsDirty = true; // Check actual form state

  if (formIsDirty) {
    return confirm('You have unsaved changes. Leave anyway?');
  }

  return true;
};

// Auth guard
const authGuard: NavigationGuard = async (to, from) => {
  if (to.startsWith('/admin')) {
    const isAuthenticated = await checkAuth();
    if (!isAuthenticated) {
      console.log('Access denied: not authenticated');
      return false;
    }
  }
  return true;
};

# JavaScript Version

javascript
export function RouterWithGuards({
  routes,
  guards = [],
  children,
}) {
  const [currentPath, setCurrentPath] = useState(window.location.pathname);
  const [isNavigating, setIsNavigating] = useState(false);

  const navigate = useCallback(
    async (path) => {
      setIsNavigating(true);

      try {
        for (const guard of guards) {
          const canNavigate = await guard(path, currentPath);
          if (!canNavigate) {
            console.log('Navigation blocked by guard');
            return;
          }
        }

        history.pushState(null, '', path);
        setCurrentPath(path);
      } finally {
        setIsNavigating(false);
      }
    },
    [currentPath, guards]
  );

  return (
    <div>
      {/* Router content */}
    </div>
  );
}

# Performance Considerations {#performance}

# URL Parsing Overhead

Route matching happens on every navigation. For large route sets, this can be slow.

typescript
// Inefficient: Linear search through all routes
function findRoute(pathname: string, routes: Route[]): Route | null {
  for (const route of routes) {
    if (matchRoute(pathname, route)) {
      return route;
    }
  }
  return null;
}

// Better: Pre-compile routes to regex
// Compile once at startup, not on every navigation

# Avoiding Unnecessary Re-Renders

typescript
// ❌ Inefficient: Re-creates params object on every render
export function Component() {
  const params = { id: '123' }; // Created fresh each render
  return <div>{params.id}</div>;
}

// ✅ Better: Memoize params
export function Component() {
  const params = useParams(); // Cached from context
  return <div>{params.id}</div>;
}

# Code Splitting and Lazy Loading

typescript
// Load routes on demand
const routes = [
  {
    path: '/',
    element: HomePage,
  },
  {
    path: '/admin',
    element: lazy(() => import('./pages/AdminPage')),
  },
  {
    path: '/blog',
    element: lazy(() => import('./pages/BlogPage')),
  },
];

// Only download admin and blog code when needed

# Real-World Routing Patterns {#real-world-patterns}

# Pattern 1: Dynamic Route Configuration

typescript
// Routes loaded from config (useful for multi-tenant apps)
const routeConfig = await fetch('/api/routes').then(r => r.json());
const routes = routeConfig.map(config => ({
  path: config.path,
  element: React.lazy(() => import(`./pages/${config.component}`)),
}));

# Pattern 2: Hash-Based Routing (for SPAs served from static hosts)

typescript
// Instead of /products, use /#/products
// Useful when you can't configure server routing

function useHashRouter() {
  const [currentPath, setCurrentPath] = useState(() => {
    return window.location.hash.slice(1) || '/';
  });

  useEffect(() => {
    const handleHashChange = () => {
      setCurrentPath(window.location.hash.slice(1) || '/');
    };

    window.addEventListener('hashchange', handleHashChange);
    return () => window.removeEventListener('hashchange', handleHashChange);
  }, []);

  const navigate = (path: string) => {
    window.location.hash = path;
  };

  return { currentPath, navigate };
}

# Pattern 3: Scroll Restoration

typescript
// Reset scroll to top on navigation
useEffect(() => {
  window.scrollTo(0, 0);
}, [currentPath]);

// Restore scroll position on back button
useEffect(() => {
  const handlePopState = () => {
    const scrollPos = sessionStorage.getItem(`scroll-${currentPath}`);
    if (scrollPos) {
      window.scrollTo(0, parseInt(scrollPos));
    }
  };

  window.addEventListener('popstate', handlePopState);
  return () => window.removeEventListener('popstate', handlePopState);
}, [currentPath]);

// Save scroll position before navigation
const navigate = (path: string) => {
  sessionStorage.setItem(`scroll-${currentPath}`, String(window.scrollY));
  history.pushState(null, '', path);
};

# FAQ

# Q: Why not just use window.location.href?

A: window.location.href causes a full page reload, losing all client state. Client-side routing uses the History API to change the URL without reloading, keeping your application in memory.

# Q: How does the back button work without implementing it?

A: When the user clicks the back button, the browser fires a popstate event. Listening to this event and updating your component state is enough—you don't implement the back button itself.

# Q: Can I access window.location in a client-side router?

A: Yes, but prefer using your router's useNavigate() hook. It ensures the History API is used correctly and your app state stays in sync with the URL.

# Q: What happens if two routes match the same URL?

A: The first matching route is used. This is why route order matters. Put specific routes before general ones:

typescript
// Correct order
const routes = [
  { path: '/products/:id', component: ProductDetail },
  { path: '/products', component: ProductsList }, // More specific first
  { path: '*', component: NotFound }, // Catch-all last
];

# Q: How do I handle 404s in client-side routing?

A: Add a catch-all route at the end:

typescript
{
  path: '*',
  element: <NotFoundPage />
}

# Q: Is client-side routing bad for SEO?

A: It can be if not handled carefully. Use meta tags (Helmet), prerender critical pages, or use a framework like Next.js that can server-render. Client-side routing itself isn't bad for SEO; poor implementation is.


Ready to build your own router? Try implementing one from scratch using this guide. You'll gain a much deeper understanding of how React Router and Next.js work under the hood. Drop your questions about client-side routing in the comments!

Sponsored Content

Google AdSense Placeholder

CONTENT SLOT

Sponsored

Google AdSense Placeholder

FOOTER SLOT