Building React router from scratch

React intermediate 13 min read

Who This Is For:

senior-react-developers frontend-architects

Building React router from scratch

Technical Overview

Building a React router from scratch involves understanding browser history management, route matching algorithms, and component rendering patterns. A custom router provides deep insights into how routing works and allows you to create tailored solutions for specific use cases. The core components include a history manager for navigation, a route matcher for URL-to-component mapping, and navigation components for user interaction.

Architecture & Approach

Our custom router will follow a modular architecture with three main layers: History Management (handling browser navigation), Route Resolution (matching URLs to components), and Component Rendering (displaying the matched components). We’ll use React Context for state management, hooks for navigation logic, and a component-based API similar to popular routing libraries for familiarity.

Implementation Details

Core Components

History Manager:

interface Location {
  pathname: string;
  search: string;
  hash: string;
  state: any;
}

interface History {
  location: Location;
  push: (path: string, state?: any) => void;
  replace: (path: string, state?: any) => void;
  goBack: () => void;
  goForward: () => void;
  listen: (listener: (location: Location) => void) => () => void;
}

const createBrowserHistory = (): History => {
  let listeners: ((location: Location) => void)[] = [];
  let location: Location = {
    pathname: window.location.pathname,
    search: window.location.search,
    hash: window.location.hash,
    state: window.history.state,
  };

  const notifyListeners = () => {
    listeners.forEach((listener) => listener(location));
  };

  const updateLocation = (path: string, state?: any, method: 'push' | 'replace' = 'push') => {
    const url = new URL(path, window.location.origin);
    location = {
      pathname: url.pathname,
      search: url.search,
      hash: url.hash,
      state,
    };

    if (method === 'push') {
      window.history.pushState(state, '', path);
    } else {
      window.history.replaceState(state, '', path);
    }

    notifyListeners();
  };

  window.addEventListener('popstate', () => {
    location = {
      pathname: window.location.pathname,
      search: window.location.search,
      hash: window.location.hash,
      state: window.history.state,
    };
    notifyListeners();
  });

  return {
    location,
    push: (path, state) => updateLocation(path, state, 'push'),
    replace: (path, state) => updateLocation(path, state, 'replace'),
    goBack: () => window.history.back(),
    goForward: () => window.history.forward(),
    listen: (listener) => {
      listeners.push(listener);
      return () => {
        listeners = listeners.filter((l) => l !== listener);
      };
    },
  };
};

Router Context:

interface RouterContextValue {
  history: History;
  location: Location;
}

const RouterContext = React.createContext<RouterContextValue | null>(null);

export const useRouter = (): RouterContextValue => {
  const context = useContext(RouterContext);
  if (!context) {
    throw new Error('useRouter must be used within a Router');
  }
  return context;
};

Route Matching:

interface RouteConfig {
  path: string;
  component: React.ComponentType<any>;
  exact?: boolean;
}

const matchPath = (pathname: string, route: RouteConfig): boolean => {
  const { path, exact = false } = route;

  if (exact) {
    return pathname === path;
  }

  return pathname.startsWith(path);
};

const matchRoute = (pathname: string, routes: RouteConfig[]): RouteConfig | null => {
  for (const route of routes) {
    if (matchPath(pathname, route)) {
      return route;
    }
  }
  return null;
};

Configuration

Main Router Component:

interface RouterProps {
  children: React.ReactNode;
  routes?: RouteConfig[];
}

export const Router: React.FC<RouterProps> = ({ children, routes = [] }) => {
  const [history] = useState(() => createBrowserHistory());
  const [location, setLocation] = useState(history.location);

  useEffect(() => {
    const unlisten = history.listen((newLocation) => {
      setLocation(newLocation);
    });
    return unlisten;
  }, [history]);

  const contextValue: RouterContextValue = {
    history,
    location,
  };

  return (
    <RouterContext.Provider value={contextValue}>
      {children}
    </RouterContext.Provider>
  );
};

Route Component:

interface RouteProps {
  path: string;
  component: React.ComponentType<any>;
  exact?: boolean;
}

export const Route: React.FC<RouteProps> = ({
  path,
  component: Component,
  exact = false
}) => {
  const { location } = useRouter();

  const matches = exact
    ? location.pathname === path
    : location.pathname.startsWith(path);

  if (!matches) {
    return null;
  }

  return <Component />;
};

Switch Component for Exclusive Routing:

interface SwitchProps {
  children: React.ReactNode;
}

export const Switch: React.FC<SwitchProps> = ({ children }) => {
  const { location } = useRouter();

  const childrenArray = React.Children.toArray(children);

  for (const child of childrenArray) {
    if (React.isValidElement(child)) {
      const { path, exact = false } = child.props as RouteProps;

      const matches = exact ? location.pathname === path : location.pathname.startsWith(path);

      if (matches) {
        return child;
      }
    }
  }

  return null;
};

Integration Points

Navigation Components:

interface LinkProps {
  to: string;
  replace?: boolean;
  children: React.ReactNode;
  className?: string;
}

export const Link: React.FC<LinkProps> = ({
  to,
  replace = false,
  children,
  className
}) => {
  const { history } = useRouter();

  const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
    e.preventDefault();

    if (replace) {
      history.replace(to);
    } else {
      history.push(to);
    }
  };

  return (
    <a href={to} onClick={handleClick} className={className}>
      {children}
    </a>
  );
};

export const NavLink: React.FC<LinkProps & { activeClassName?: string }> = ({
  to,
  replace = false,
  children,
  className,
  activeClassName = 'active'
}) => {
  const { location } = useRouter();
  const history = useRouter().history;

  const isActive = location.pathname === to;
  const combinedClassName = isActive
    ? `${className || ''} ${activeClassName}`.trim()
    : className;

  const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
    e.preventDefault();

    if (replace) {
      history.replace(to);
    } else {
      history.push(to);
    }
  };

  return (
    <a href={to} onClick={handleClick} className={combinedClassName}>
      {children}
    </a>
  );
};

Navigation Hooks:

export const useNavigate = () => {
  const { history } = useRouter();

  return {
    push: (path: string, state?: any) => history.push(path, state),
    replace: (path: string, state?: any) => history.replace(path, state),
    goBack: () => history.goBack(),
    goForward: () => history.goForward(),
  };
};

export const useParams = <T = Record<string, string>>(): T => {
  const { location } = useRouter();

  // Simple parameter extraction (can be enhanced for complex patterns)
  const params: Record<string, string> = {};
  const pathSegments = location.pathname.split('/').filter(Boolean);

  // This is a simplified version - real implementation would use regex patterns
  return params as T;
};

Advanced Techniques

Route Parameters and Wildcards

Enhanced Route Matching:

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

const pathToRegexp = (path: string): RegExp => {
  // Convert path like "/users/:id" to regex
  const paramNames: string[] = [];
  const regexPath = path
    .replace(/:([^/]+)/g, (_, paramName) => {
      paramNames.push(paramName);
      return '([^/]+)';
    })
    .replace(/\*/g, '(.*)');

  return new RegExp(`^${regexPath}$`);
};

const matchPathWithParams = (pathname: string, route: RouteConfig): { matches: boolean; params: RouteParams } => {
  const { path, exact = false } = route;
  const regex = pathToRegexp(path);
  const match = pathname.match(regex);

  if (!match) {
    return { matches: false, params: {} };
  }

  const paramNames = (path.match(/:([^/]+)/g) || []).map((param) => param.slice(1));

  const params: RouteParams = {};
  paramNames.forEach((name, index) => {
    params[name] = match[index + 1];
  });

  return { matches: true, params };
};

Nested Routes

Nested Route Component:

interface NestedRouteProps {
  path: string;
  component: React.ComponentType<any>;
  children?: React.ReactNode;
}

export const NestedRoute: React.FC<NestedRouteProps> = ({
  path,
  component: Component,
  children
}) => {
  const { location } = useRouter();
  const { matches, params } = matchPathWithParams(location.pathname, { path });

  if (!matches) {
    return null;
  }

  return (
    <Component params={params}>
      {children}
    </Component>
  );
};

Route Guards and Authentication

Protected Route Component:

interface ProtectedRouteProps {
  path: string;
  component: React.ComponentType<any>;
  isAuthenticated: boolean;
  redirectTo?: string;
}

export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
  path,
  component: Component,
  isAuthenticated,
  redirectTo = '/login'
}) => {
  const { location } = useRouter();
  const { history } = useRouter();
  const { matches } = matchPathWithParams(location.pathname, { path });

  useEffect(() => {
    if (matches && !isAuthenticated) {
      history.push(redirectTo);
    }
  }, [matches, isAuthenticated, history, redirectTo]);

  if (!matches || !isAuthenticated) {
    return null;
  }

  return <Component />;
};

Performance & Optimization

Route Memoization:

const MemoizedRoute = React.memo(Route, (prevProps, nextProps) => {
  return (
    prevProps.path === nextProps.path &&
    prevProps.exact === nextProps.exact &&
    prevProps.component === nextProps.component
  );
});

Lazy Loading Routes:

const LazyRoute: React.FC<RouteProps> = ({ path, component: Component, exact }) => {
  const [LazyComponent, setLazyComponent] = useState<React.ComponentType | null>(null);

  useEffect(() => {
    // Simulate dynamic import
    import(`../components/${Component.name}`).then(module => {
      setLazyComponent(() => module.default);
    });
  }, [Component]);

  if (!LazyComponent) {
    return <div>Loading...</div>;
  }

  return <Route path={path} component={LazyComponent} exact={exact} />;
};

Troubleshooting

Common Issues:

  1. History not updating:

    • Ensure proper event listeners for popstate
    • Check that state updates trigger re-renders
    • Verify context provider wraps all components
  2. Route matching failures:

    • Check path normalization (leading/trailing slashes)
    • Verify exact vs non-exact matching logic
    • Test parameter extraction patterns
  3. Memory leaks:

    • Clean up history listeners in useEffect
    • Remove event listeners on component unmount
    • Avoid creating functions in render that capture state

Common Questions

Q: Why build a custom router instead of using React Router? Building a custom router helps you understand routing fundamentals, allows for tailored solutions, and reduces bundle size for simple use cases. It’s also excellent learning for understanding how routing libraries work.

Q: How does this compare to React Router? Our implementation covers core functionality but lacks advanced features like route code splitting, route configs, and extensive browser support. React Router provides these features out of the box with better browser compatibility.

Q: Can I add server-side rendering? Yes, but you’d need to implement server-side history management and static route matching. The core concepts remain the same, but you’d need to handle initial route resolution on the server.

Tools & Resources

  • Browser History API Documentation - Understanding native browser navigation
  • URL Pattern API - Modern browser API for pattern matching
  • React Context Documentation - State management patterns
  • Path-to-RegExp Library - Advanced route pattern matching

Core React Patterns

Performance & Optimization

Error Handling & Testing

JavaScript Fundamentals

Framework Comparisons

Need Help With Implementation?

Building a custom router is an excellent learning exercise, but production routing requires handling edge cases, browser compatibility, and performance optimization. Built By Dakic specializes in custom React development, including routing solutions tailored to specific application requirements. Get in touch for a free consultation and discover how we can help you build robust, scalable routing solutions.

Related Topics

Need Help With Implementation?

While these steps provide a solid foundation, proper implementation often requires expertise and experience.

Get Free Consultation